|
| 1 | +import { |
| 2 | + CUSTOM_ELEMENTS_SCHEMA, |
| 3 | + ChangeDetectionStrategy, |
| 4 | + Component, |
| 5 | + ElementRef, |
| 6 | + InjectionToken, |
| 7 | + Injector, |
| 8 | + Signal, |
| 9 | + afterNextRender, |
| 10 | + computed, |
| 11 | + inject, |
| 12 | + input, |
| 13 | + signal, |
| 14 | + viewChild, |
| 15 | +} from '@angular/core'; |
| 16 | +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
| 17 | +import { CylinderArgs, Triplet } from '@pmndrs/cannon-worker-api'; |
| 18 | +import { NgtcPhysics } from 'angular-three-cannon'; |
| 19 | +import { injectBox, injectCylinder, injectSphere } from 'angular-three-cannon/body'; |
| 20 | +import { injectConeTwist } from 'angular-three-cannon/constraint'; |
| 21 | +import { NgtcDebug } from 'angular-three-cannon/debug'; |
| 22 | +import { NgtArgs, extend, injectBeforeRender, injectStore } from 'angular-three-core-new'; |
| 23 | +import * as THREE from 'three'; |
| 24 | +import { Color, ColorRepresentation, Mesh, Object3D } from 'three'; |
| 25 | + |
| 26 | +extend(THREE); |
| 27 | + |
| 28 | +const Parent = new InjectionToken<{ position: Signal<Triplet>; ref: Signal<ElementRef<Object3D>> }>('PARENT'); |
| 29 | + |
| 30 | +@Component({ |
| 31 | + selector: 'app-chain-link', |
| 32 | + standalone: true, |
| 33 | + template: ` |
| 34 | + <ngt-mesh #mesh> |
| 35 | + <ngt-cylinder-geometry *args="args()" /> |
| 36 | + <ngt-mesh-standard-material [roughness]="0.3" [color]="color()" /> |
| 37 | + </ngt-mesh> |
| 38 | + <ng-content /> |
| 39 | + `, |
| 40 | + imports: [NgtArgs], |
| 41 | + schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 42 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 43 | + providers: [ |
| 44 | + { |
| 45 | + provide: Parent, |
| 46 | + useFactory: (chainLink: ChainLink) => ({ ref: chainLink.mesh, position: chainLink.position }), |
| 47 | + deps: [ChainLink], |
| 48 | + }, |
| 49 | + ], |
| 50 | +}) |
| 51 | +export class ChainLink { |
| 52 | + parent = inject(Parent, { skipSelf: true }); |
| 53 | + |
| 54 | + maxMultiplier = input<number>(); |
| 55 | + color = input<ColorRepresentation>('#575757'); |
| 56 | + args = input<CylinderArgs>([0.5, 0.5, 2, 16]); |
| 57 | + |
| 58 | + height = computed(() => this.args()[2] ?? 2); |
| 59 | + position = computed<Triplet>(() => { |
| 60 | + const [[x, y, z], height] = [this.parent.position(), this.height()]; |
| 61 | + return [x, y - height, z]; |
| 62 | + }); |
| 63 | + |
| 64 | + mesh = viewChild.required<ElementRef<Mesh>>('mesh'); |
| 65 | + |
| 66 | + cylinder = injectCylinder( |
| 67 | + () => ({ mass: 1, args: this.args(), linearDamping: 0.8, position: this.position() }), |
| 68 | + this.mesh, |
| 69 | + ); |
| 70 | + |
| 71 | + constructor() { |
| 72 | + const injector = inject(Injector); |
| 73 | + // NOTE: we want to run this in afterNextRender because we want the input to resolve |
| 74 | + afterNextRender(() => { |
| 75 | + injectConeTwist(this.parent.ref, this.mesh, { |
| 76 | + injector, |
| 77 | + options: { |
| 78 | + angle: Math.PI / 8, |
| 79 | + axisA: [0, 1, 0], |
| 80 | + axisB: [0, 1, 0], |
| 81 | + maxMultiplier: this.maxMultiplier(), |
| 82 | + pivotA: [0, -this.height() / 2, 0], |
| 83 | + pivotB: [0, this.height() / 2, 0], |
| 84 | + twistAngle: 0, |
| 85 | + }, |
| 86 | + }); |
| 87 | + }); |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +function notUndefined<T>(value: T | undefined): value is T { |
| 92 | + return value !== undefined; |
| 93 | +} |
| 94 | + |
| 95 | +const maxMultiplierExamples = [0, 500, 1000, 1500, undefined] as const; |
| 96 | + |
| 97 | +@Component({ |
| 98 | + selector: 'app-chain', |
| 99 | + standalone: true, |
| 100 | + template: ` |
| 101 | + @if (length() > 0) { |
| 102 | + <app-chain-link [color]="color()" [maxMultiplier]="maxMultiplier()"> |
| 103 | + <app-chain [length]="length() - 1" [maxMultiplier]="maxMultiplier()" /> |
| 104 | + </app-chain-link> |
| 105 | + } |
| 106 | + `, |
| 107 | + imports: [ChainLink], |
| 108 | + schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 109 | +}) |
| 110 | +export class Chain { |
| 111 | + length = input.required<number>(); |
| 112 | + maxMultiplier = input<number>(); |
| 113 | + |
| 114 | + color = computed(() => { |
| 115 | + const maxMultiplier = this.maxMultiplier(); |
| 116 | + if (maxMultiplier === undefined) return '#575757'; |
| 117 | + |
| 118 | + const maxExample = Math.max(...maxMultiplierExamples.filter(notUndefined)); |
| 119 | + const red = Math.floor(Math.min(100, (maxMultiplier / maxExample) * 100)); |
| 120 | + |
| 121 | + return new Color(`rgb(${red}%, 0%, ${100 - red}%)`); |
| 122 | + }); |
| 123 | +} |
| 124 | + |
| 125 | +@Component({ |
| 126 | + selector: 'app-pointer-handle', |
| 127 | + standalone: true, |
| 128 | + template: ` |
| 129 | + <ngt-group> |
| 130 | + <ngt-mesh #mesh> |
| 131 | + <ngt-box-geometry *args="args()" /> |
| 132 | + <ngt-mesh-standard-material [roughness]="0.3" color="#575757" /> |
| 133 | + </ngt-mesh> |
| 134 | + <ng-content /> |
| 135 | + </ngt-group> |
| 136 | + `, |
| 137 | + imports: [NgtArgs], |
| 138 | + schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 139 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 140 | + providers: [ |
| 141 | + { |
| 142 | + provide: Parent, |
| 143 | + useFactory: (handle: PointerHandle) => ({ ref: handle.mesh, position: () => handle.position }), |
| 144 | + deps: [PointerHandle], |
| 145 | + }, |
| 146 | + ], |
| 147 | +}) |
| 148 | +export class PointerHandle { |
| 149 | + size = input.required<number>(); |
| 150 | + args = computed<Triplet>(() => [this.size(), this.size(), this.size() * 2]); |
| 151 | + |
| 152 | + position: Triplet = [0, 0, 0]; |
| 153 | + mesh = viewChild.required<ElementRef<Mesh>>('mesh'); |
| 154 | + |
| 155 | + boxApi = injectBox(() => ({ args: this.args(), position: this.position, type: 'Kinematic' }), this.mesh); |
| 156 | + |
| 157 | + constructor() { |
| 158 | + injectBeforeRender(({ pointer: { x, y }, viewport: { width, height } }) => { |
| 159 | + this.boxApi()?.position.set((x * width) / 2, (y * height) / 2, 0); |
| 160 | + }); |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +@Component({ |
| 165 | + selector: 'app-static-handle', |
| 166 | + standalone: true, |
| 167 | + template: ` |
| 168 | + <ngt-group> |
| 169 | + <ngt-mesh #mesh> |
| 170 | + <ngt-sphere-geometry *args="[radius(), 64, 64]" /> |
| 171 | + <ngt-mesh-standard-material [roughness]="0.3" color="#575757" /> |
| 172 | + </ngt-mesh> |
| 173 | + <ng-content /> |
| 174 | + </ngt-group> |
| 175 | + `, |
| 176 | + imports: [NgtArgs], |
| 177 | + providers: [ |
| 178 | + { |
| 179 | + provide: Parent, |
| 180 | + useFactory: (handle: StaticHandle) => ({ ref: handle.mesh, position: handle.position }), |
| 181 | + deps: [StaticHandle], |
| 182 | + }, |
| 183 | + ], |
| 184 | + schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 185 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 186 | +}) |
| 187 | +export class StaticHandle { |
| 188 | + position = input.required<Triplet>(); |
| 189 | + radius = input.required<number>(); |
| 190 | + mesh = viewChild.required<ElementRef<Mesh>>('mesh'); |
| 191 | + |
| 192 | + sphere = injectSphere(() => ({ args: [this.radius()], position: this.position(), type: 'Static' }), this.mesh); |
| 193 | +} |
| 194 | + |
| 195 | +@Component({ |
| 196 | + standalone: true, |
| 197 | + template: ` |
| 198 | + <ngt-color *args="['#171720']" attach="background" /> |
| 199 | + <ngt-ambient-light [intensity]="0.5 * Math.PI" /> |
| 200 | + <ngt-point-light [position]="[-10, -10, -10]" [intensity]="Math.PI" [decay]="0" /> |
| 201 | + <ngt-spot-light |
| 202 | + [position]="[10, 10, 10]" |
| 203 | + [angle]="0.8" |
| 204 | + [penumbra]="1" |
| 205 | + [intensity]="Math.PI" |
| 206 | + [decay]="0" |
| 207 | + [castShadow]="true" |
| 208 | + /> |
| 209 | +
|
| 210 | + <ngtc-physics [options]="{ gravity: [0, -40, 0], allowSleep: false }"> |
| 211 | + <app-pointer-handle [size]="1.5"> |
| 212 | + <app-chain [length]="7" /> |
| 213 | + </app-pointer-handle> |
| 214 | + @for (maxMultiplier of maxMultiplierExamples(); track maxMultiplier.key) { |
| 215 | + <app-static-handle [radius]="1.5" [position]="maxMultiplier.position"> |
| 216 | + <app-chain [maxMultiplier]="maxMultiplier.value" [length]="8" /> |
| 217 | + </app-static-handle> |
| 218 | + } |
| 219 | + </ngtc-physics> |
| 220 | + `, |
| 221 | + imports: [NgtcPhysics, NgtcDebug, NgtArgs, PointerHandle, Chain, StaticHandle], |
| 222 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 223 | + schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 224 | + host: { class: 'chain-experience' }, |
| 225 | +}) |
| 226 | +export class Experience { |
| 227 | + Math = Math; |
| 228 | + |
| 229 | + private store = injectStore(); |
| 230 | + |
| 231 | + resetCount = signal(0); |
| 232 | + maxMultiplierExamples = computed(() => |
| 233 | + maxMultiplierExamples.map((value, index, array) => ({ |
| 234 | + value, |
| 235 | + key: `${value}-${this.resetCount()}`, |
| 236 | + position: [(array.length * -4) / 2 + index * 4, 8, 0] as Triplet, |
| 237 | + })), |
| 238 | + ); |
| 239 | + |
| 240 | + constructor() { |
| 241 | + this.store |
| 242 | + .get('pointerMissed$') |
| 243 | + .pipe(takeUntilDestroyed()) |
| 244 | + .subscribe(() => { |
| 245 | + this.resetCount.update((prev) => prev + 1); |
| 246 | + }); |
| 247 | + } |
| 248 | +} |
0 commit comments