Skip to content

Commit 060c425

Browse files
committed
docs: neck deep with host directives for aviator 😅
1 parent 5a40db2 commit 060c425

File tree

5 files changed

+254
-168
lines changed

5 files changed

+254
-168
lines changed
Lines changed: 25 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, viewChild } from '@angular/core';
2-
import { injectBeforeRender, NgtArgs } from 'angular-three';
3-
import { gsap, Power2 } from 'gsap';
41
import {
5-
Color,
6-
ColorRepresentation,
7-
CylinderGeometry,
8-
Mesh,
9-
MeshPhongMaterial,
10-
TetrahedronGeometry,
11-
Vector3,
12-
} from 'three';
2+
ChangeDetectionStrategy,
3+
Component,
4+
CUSTOM_ELEMENTS_SCHEMA,
5+
ElementRef,
6+
inject,
7+
viewChild,
8+
} from '@angular/core';
9+
import { injectBeforeRender, NgtArgs } from 'angular-three';
10+
import { CylinderGeometry, Mesh, MeshPhongMaterial } from 'three';
1311
import { COIN_DISTANCE_TOLERANCE, COLOR_COINS } from '../constants';
12+
import { GameStore } from '../game.store';
13+
import { Spawnable, SPAWNABLE_DISTANCE_TOLERANCE, SPAWNABLE_PARTICLE_COLOR } from '../spawnable/spawnables.store';
1414
import { Collectible } from './collectibles.store';
1515

1616
const coinGeometry = new CylinderGeometry(4, 4, 1, 10);
@@ -21,14 +21,6 @@ const coinMaterial = new MeshPhongMaterial({
2121
flatShading: true,
2222
});
2323

24-
const particleGeometry = new TetrahedronGeometry(3, 0);
25-
const particleMaterial = new MeshPhongMaterial({
26-
color: 0x009999,
27-
shininess: 0,
28-
specular: 0xffffff,
29-
flatShading: true,
30-
});
31-
3224
@Component({
3325
selector: 'app-coin',
3426
standalone: true,
@@ -38,78 +30,35 @@ const particleMaterial = new MeshPhongMaterial({
3830
[castShadow]="true"
3931
[geometry]="coinGeometry"
4032
[material]="coinMaterial"
41-
[position]="[positionX(), positionY(), 0]"
33+
[position]="[spawnable.positionX(), spawnable.positionY(), 0]"
4234
/>
4335
`,
4436
schemas: [CUSTOM_ELEMENTS_SCHEMA],
4537
changeDetection: ChangeDetectionStrategy.OnPush,
4638
imports: [NgtArgs],
39+
hostDirectives: [{ directive: Collectible, inputs: ['state'], outputs: ['stateChange'] }],
40+
providers: [
41+
{ provide: SPAWNABLE_DISTANCE_TOLERANCE, useValue: COIN_DISTANCE_TOLERANCE },
42+
{ provide: SPAWNABLE_PARTICLE_COLOR, useValue: COLOR_COINS },
43+
],
4744
})
48-
export class Coin extends Collectible {
45+
export class Coin {
4946
protected coinGeometry = coinGeometry;
5047
protected coinMaterial = coinMaterial;
5148

5249
private coinRef = viewChild.required<ElementRef<Mesh>>('coin');
5350

51+
private gameStore = inject(GameStore);
52+
protected spawnable = inject(Spawnable, { host: true });
53+
5454
constructor() {
55-
super();
56-
injectBeforeRender(({ delta }) => {
57-
const coin = this.coinRef().nativeElement;
55+
this.spawnable.spawnable = this.coinRef;
56+
this.spawnable.onCollide(() => this.gameStore.incrementCoin());
5857

59-
this.rotateAroundSea(coin, delta);
58+
injectBeforeRender(() => {
59+
const coin = this.coinRef().nativeElement;
6060
coin.rotation.x += Math.random() * 0.1;
6161
coin.rotation.y += Math.random() * 0.1;
62-
63-
const airplaneRef = this.gameStore.airplaneRef;
64-
if (!airplaneRef) return;
65-
66-
const airplane = airplaneRef().nativeElement;
67-
if (!airplane) return;
68-
69-
if (this.collide(airplane, coin, COIN_DISTANCE_TOLERANCE)) {
70-
this.spawnParticles(coin.position.clone(), 5, COLOR_COINS, 0.8);
71-
this.gameStore.incrementCoin();
72-
this.state.set('collected');
73-
} else if (this.angle > Math.PI) {
74-
this.state.set('skipped');
75-
}
7662
});
7763
}
78-
79-
// NOTE: we don't render the particles on the template because of performance
80-
// If we were to render them on the template, we would need to use a Signal for the condition render.
81-
// This would mean triggering CD.
82-
private spawnParticles(pos: Vector3, count: number, color: ColorRepresentation, scale: number) {
83-
for (let i = 0; i < count; i++) {
84-
const mesh = new Mesh(particleGeometry, particleMaterial);
85-
this.store.snapshot.scene.add(mesh);
86-
87-
mesh.visible = true;
88-
mesh.position.copy(pos);
89-
mesh.material.color = new Color(color);
90-
mesh.material.needsUpdate = true;
91-
mesh.scale.set(scale, scale, scale);
92-
const targetX = pos.x + (-1 + Math.random() * 2) * 50;
93-
const targetY = pos.y + (-1 + Math.random() * 2) * 50;
94-
const targetZ = pos.z + (-1 + Math.random() * 2) * 50;
95-
const speed = 0.6 + Math.random() * 0.2;
96-
gsap.to(mesh.rotation, {
97-
duration: speed,
98-
x: Math.random() * 12,
99-
y: Math.random() * 12,
100-
});
101-
gsap.to(mesh.scale, { duration: speed, x: 0.1, y: 0.1, z: 0.1 });
102-
gsap.to(mesh.position, {
103-
duration: speed,
104-
x: targetX,
105-
y: targetY,
106-
z: targetZ,
107-
delay: Math.random() * 0.1,
108-
ease: Power2.easeOut,
109-
onComplete: () => {
110-
this.store.snapshot.scene.remove(mesh);
111-
},
112-
});
113-
}
114-
}
11564
}

‎apps/kitchen-sink/src/app/misc/aviator/collectible/collectibles.store.ts

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,30 @@
1-
import { DestroyRef, Directive, effect, inject, input, model, signal, untracked, WritableSignal } from '@angular/core';
2-
import { injectStore } from 'angular-three';
3-
import { Object3D } from 'three';
4-
import { COLLECTIBLES_SPEED, PLANE_AMP_HEIGHT, PLANE_DEFAULT_HEIGHT, SEA_RADIUS } from '../constants';
1+
import { DestroyRef, Directive, inject, model, signal, untracked, WritableSignal } from '@angular/core';
2+
import { PLANE_AMP_HEIGHT, PLANE_DEFAULT_HEIGHT, SEA_RADIUS } from '../constants';
53
import { GameStore } from '../game.store';
4+
import { Spawnable } from '../spawnable/spawnables.store';
65

76
export type CollectibleState = 'spawned' | 'collected' | 'skipped';
87

9-
@Directive()
8+
@Directive({
9+
standalone: true,
10+
hostDirectives: [{ directive: Spawnable, inputs: ['initialAngle', 'initialDistance', 'positionX', 'positionY'] }],
11+
})
1012
export class Collectible {
11-
initialAngle = input(0);
12-
initialDistance = input(0);
13-
positionX = input(0);
14-
positionY = input(0);
1513
state = model.required<CollectibleState>();
1614

17-
protected angle = 0;
18-
protected distance = 0;
15+
private destroyRef = inject(DestroyRef);
16+
private collectiblesStore = inject(CollectiblesStore);
17+
private spawnable = inject(Spawnable, { host: true });
1918

20-
protected gameStore = inject(GameStore);
21-
protected collectiblesStore = inject(CollectiblesStore);
22-
protected store = injectStore();
19+
constructor() {
20+
this.spawnable.onCollide(() => this.state.set('collected'));
21+
this.spawnable.onSkip(() => this.state.set('skipped'));
2322

24-
protected constructor() {
2523
this.collectiblesStore.collectibles.add(this);
26-
27-
effect(() => {
28-
this.angle = this.initialAngle();
29-
this.distance = this.initialDistance();
30-
});
31-
32-
inject(DestroyRef).onDestroy(() => {
24+
this.destroyRef.onDestroy(() => {
3325
this.collectiblesStore.collectibles.delete(this);
3426
});
3527
}
36-
37-
protected rotateAroundSea(object: Object3D, deltaTime: number) {
38-
this.angle += deltaTime * 1_000 * this.gameStore.state.speed * COLLECTIBLES_SPEED;
39-
if (this.angle > Math.PI * 2) {
40-
this.angle -= Math.PI * 2;
41-
}
42-
object.position.x = Math.cos(this.angle) * (this.distance ?? 1);
43-
object.position.y = -SEA_RADIUS + Math.sin(this.angle) * (this.distance ?? 1);
44-
}
45-
46-
protected collide(a: Object3D, b: Object3D, tolerance: number) {
47-
const diffPos = a.position.clone().sub(b.position.clone());
48-
const d = diffPos.length();
49-
return d < tolerance;
50-
}
5128
}
5229

5330
export class CollectiblesStore {

‎apps/kitchen-sink/src/app/misc/aviator/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export const DISTANCE_FOR_COINS_SPAWN = 50;
3939
export const COLLECTIBLE_DISTANCE_TOLERANCE = 15;
4040
export const COLLECTIBLES_SPEED = 0.6;
4141

42+
export const SPAWNABLES_SPEED = 0.6;
43+
4244
export const ENEMY_DISTANCE_TOLERANCE = 10;
4345
export const ENEMIES_SPEED = 0.6;
4446
export const DISTANCE_FOR_ENEMIES_SPAWN = 50;
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {
2+
DestroyRef,
3+
Directive,
4+
effect,
5+
ElementRef,
6+
inject,
7+
Injectable,
8+
InjectionToken,
9+
input,
10+
Signal,
11+
} from '@angular/core';
12+
import { injectBeforeRender, injectStore } from 'angular-three';
13+
import { gsap, Power2 } from 'gsap';
14+
import { ColorRepresentation, Mesh, MeshPhongMaterial, Object3D, TetrahedronGeometry, Vector3 } from 'three';
15+
import { SEA_RADIUS, SPAWNABLES_SPEED } from '../constants';
16+
import { GameStore } from '../game.store';
17+
18+
const particleGeometry = new TetrahedronGeometry(3, 0);
19+
const particleMaterial = new MeshPhongMaterial({ shininess: 0, specular: 0xffffff, flatShading: true });
20+
21+
export const SPAWNABLE_DISTANCE_TOLERANCE = new InjectionToken<number>('SPAWNABLE_DISTANCE_TOLERANCE');
22+
export const SPAWNABLE_PARTICLE_COLOR = new InjectionToken<ColorRepresentation>('SPAWNABLE_PARTICLE_COLOR');
23+
24+
@Directive({ standalone: true })
25+
export class Spawnable {
26+
initialAngle = input(0);
27+
initialDistance = input(0);
28+
positionX = input(0);
29+
positionY = input(0);
30+
31+
private angle = 0;
32+
private distance = 0;
33+
34+
private destroyRef = inject(DestroyRef);
35+
private gameStore = inject(GameStore);
36+
private store = injectStore();
37+
38+
private distanceTolerance = inject(SPAWNABLE_DISTANCE_TOLERANCE);
39+
private particleColor = inject(SPAWNABLE_PARTICLE_COLOR);
40+
41+
// TODO: is there a better way to do this?
42+
spawnable?: Signal<ElementRef<Object3D> | undefined>;
43+
44+
private onCollides: Array<() => void> = [];
45+
private onSkips: Array<() => void> = [];
46+
47+
constructor() {
48+
effect(() => {
49+
this.angle = this.initialAngle();
50+
this.distance = this.initialDistance();
51+
});
52+
53+
injectBeforeRender(({ delta }) => {
54+
const spawnable = this.spawnable?.()?.nativeElement;
55+
if (!spawnable) return;
56+
57+
this.rotateAroundSea(spawnable, delta);
58+
59+
const airplane = this.gameStore.airplaneRef?.()?.nativeElement;
60+
if (!airplane) return;
61+
62+
if (this.collide(airplane, spawnable, this.distanceTolerance)) {
63+
this.spawnParticles(spawnable.position.clone(), 5, this.particleColor, 0.8);
64+
this.onCollides.forEach((onCollide) => onCollide());
65+
} else if (this.angle > Math.PI) {
66+
this.onSkips.forEach((onSkip) => onSkip());
67+
}
68+
});
69+
70+
this.destroyRef.onDestroy(() => {});
71+
}
72+
73+
onCollide(callback: () => void) {
74+
this.onCollides.push(callback);
75+
}
76+
77+
onSkip(callback: () => void) {
78+
this.onSkips.push(callback);
79+
}
80+
81+
private rotateAroundSea(object: Object3D, deltaTime: number) {
82+
this.angle += deltaTime * 1_000 * this.gameStore.state.speed * SPAWNABLES_SPEED;
83+
if (this.angle > Math.PI * 2) {
84+
this.angle -= Math.PI * 2;
85+
}
86+
object.position.x = Math.cos(this.angle) * (this.distance ?? 1);
87+
object.position.y = -SEA_RADIUS + Math.sin(this.angle) * (this.distance ?? 1);
88+
}
89+
90+
private collide(a: Object3D, b: Object3D, tolerance: number) {
91+
const diffPos = a.position.clone().sub(b.position.clone());
92+
const d = diffPos.length();
93+
return d < tolerance;
94+
}
95+
96+
// NOTE: we don't render the particles on the template because of performance
97+
// If we were to render them on the template, we would need to use a Signal for the condition render.
98+
// This would mean triggering CD.
99+
private spawnParticles(pos: Vector3, count: number, color: ColorRepresentation, scale: number) {
100+
for (let i = 0; i < count; i++) {
101+
const mesh = new Mesh(particleGeometry, particleMaterial);
102+
this.store.snapshot.scene.add(mesh);
103+
104+
mesh.visible = true;
105+
106+
mesh.position.copy(pos);
107+
mesh.scale.setScalar(scale);
108+
109+
mesh.material.color.set(color);
110+
mesh.material.needsUpdate = true;
111+
112+
const targetX = pos.x + (-1 + Math.random() * 2) * 50;
113+
const targetY = pos.y + (-1 + Math.random() * 2) * 50;
114+
const targetZ = pos.z + (-1 + Math.random() * 2) * 50;
115+
const speed = 0.6 + Math.random() * 0.2;
116+
117+
gsap.to(mesh.rotation, {
118+
duration: speed,
119+
x: Math.random() * 12,
120+
y: Math.random() * 12,
121+
});
122+
gsap.to(mesh.scale, { duration: speed, x: 0.1, y: 0.1, z: 0.1 });
123+
gsap.to(mesh.position, {
124+
duration: speed,
125+
x: targetX,
126+
y: targetY,
127+
z: targetZ,
128+
delay: Math.random() * 0.1,
129+
ease: Power2.easeOut,
130+
onComplete: () => {
131+
this.store.snapshot.scene.remove(mesh);
132+
},
133+
});
134+
}
135+
}
136+
}
137+
138+
@Injectable()
139+
export class SpawnablesStore {}

0 commit comments

Comments
 (0)