@pix3/cli 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +788 -754
- package/dist/index.js +208 -203
- package/dist/runtime-types.json +1 -1
- package/dist/smoke/prebuilt/{chunk-J2SJVMDO.mjs → chunk-7MZGPAUK.mjs} +1 -1
- package/dist/smoke/prebuilt/chunk-7WOFGLW3.mjs +239 -0
- package/dist/smoke/prebuilt/runtime.mjs +1 -1
- package/dist/smoke/prebuilt/smoke-worker.mjs +1 -1
- package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
- package/dist/validate/prebuilt/bundle-main.js +13 -13
- package/dist/validate/prebuilt/chunk-HKG6YI6Y.js +239 -0
- package/dist/validate/prebuilt/runtime.js +1 -1
- package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -210
- package/kit/files/.claude/skills/pix3-nodes/reference.md +32 -24
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -28
- package/kit/files/.claude/skills/pix3-scripts/SKILL.md +285 -281
- package/kit/files/.claude/skills/pix3-scripts/reference.md +69 -52
- package/kit/files/.claude/skills/pix3-verify/SKILL.md +298 -256
- package/kit/files/.claude/skills/pix3-verify/reference.md +21 -21
- package/kit/files/AGENTS.md +203 -203
- package/kit/files/CLAUDE.md +1 -1
- package/kit/kit.json +2 -2
- package/package.json +54 -54
- package/templates/empty-2d/files/README.md +20 -20
- package/templates/empty-2d/files/scenes/main.pix3scene +68 -68
- package/templates/empty-2d/template.yaml +7 -7
- package/templates/empty-3d/files/README.md +20 -20
- package/templates/empty-3d/files/scenes/main.pix3scene +108 -108
- package/templates/empty-3d/template.yaml +7 -7
- package/templates/idea-blank/files/README.md +22 -22
- package/templates/idea-blank/files/scenes/main.pix3scene +37 -37
- package/templates/idea-blank/template.yaml +10 -10
- package/templates/minigame-2d/files/README.md +44 -44
- package/templates/minigame-2d/files/design/tests/reachability.json +1 -1
- package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -44
- package/templates/minigame-2d/files/scenes/main.pix3scene +84 -84
- package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -118
- package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -138
- package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -133
- package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -217
- package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -198
- package/templates/minigame-2d/template.yaml +10 -10
- package/templates/playable-2d/files/README.md +30 -30
- package/templates/playable-2d/files/design/recipe.md +83 -83
- package/templates/playable-2d/files/design/tests/reachability.json +4 -4
- package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -32
- package/templates/playable-2d/files/scenes/main.pix3scene +111 -111
- package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -91
- package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -65
- package/templates/playable-2d/files/scripts/GameFlow.ts +256 -256
- package/templates/playable-2d/template.yaml +9 -9
- package/templates/playable-3d/files/README.md +32 -32
- package/templates/playable-3d/files/design/recipe.md +127 -127
- package/templates/playable-3d/files/design/tests/reachability.json +4 -4
- package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -32
- package/templates/playable-3d/files/scenes/main.pix3scene +230 -230
- package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -91
- package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -65
- package/templates/playable-3d/files/scripts/GameFlow.ts +309 -309
- package/templates/playable-3d/files/scripts/GameRules.ts +290 -290
- package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -113
- package/templates/playable-3d/template.yaml +9 -9
- package/templates/recipe-arena-2d/files/README.md +48 -48
- package/templates/recipe-arena-2d/files/design/recipe.md +127 -127
- package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -292
- package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
- package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -45
- package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -368
- package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -151
- package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -214
- package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -174
- package/templates/recipe-arena-2d/template.yaml +10 -10
- package/templates/recipe-blank-2d/files/README.md +61 -61
- package/templates/recipe-blank-2d/files/design/recipe.md +148 -148
- package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -4
- package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -27
- package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -224
- package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -350
- package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -110
- package/templates/recipe-blank-2d/template.yaml +10 -10
- package/templates/recipe-bouncer-2d/files/README.md +81 -81
- package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -146
- package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -615
- package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -191
- package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -87
- package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -298
- package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -377
- package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -109
- package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -112
- package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -263
- package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -307
- package/templates/recipe-bouncer-2d/template.yaml +10 -10
- package/templates/recipe-grid-3d/files/README.md +22 -22
- package/templates/recipe-grid-3d/files/design/recipe.md +110 -110
- package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -169
- package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -105
- package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -319
- package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -278
- package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-grid-3d/template.yaml +10 -10
- package/templates/recipe-tapper-2d/files/README.md +39 -39
- package/templates/recipe-tapper-2d/files/design/recipe.md +132 -132
- package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -4
- package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -23
- package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -269
- package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
- package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -45
- package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -368
- package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -214
- package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -192
- package/templates/recipe-tapper-2d/template.yaml +10 -10
- package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +0 -239
- package/dist/validate/prebuilt/chunk-BS2OZM7U.js +0 -239
|
@@ -1,94 +1,94 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ScoreHud — signal-driven HUD. Display only, no game logic.
|
|
3
|
-
*
|
|
4
|
-
* Listens on `sourceNode` (the node carrying `GameRules`) for:
|
|
5
|
-
* `score-changed` (score)
|
|
6
|
-
* `lives-changed` (lives, maxLives)
|
|
7
|
-
* `time-changed` (seconds)
|
|
8
|
-
*
|
|
9
|
-
* If a HUD widget is missing from the scene it is quietly skipped, so you can
|
|
10
|
-
* delete the timer or the lives bar without touching any script.
|
|
11
|
-
*/
|
|
12
|
-
import { Bar2D, Label2D, Script, type PropertySchema } from '@pix3/runtime';
|
|
13
|
-
|
|
14
|
-
export class ScoreHud extends Script {
|
|
15
|
-
constructor(id: string, type: string) {
|
|
16
|
-
super(id, type);
|
|
17
|
-
this.config = {
|
|
18
|
-
// Node that emits the HUD signals (the GameRules host).
|
|
19
|
-
sourceNode: 'game-root',
|
|
20
|
-
scoreLabel: 'score-label',
|
|
21
|
-
scoreFormat: 'SCORE {value}',
|
|
22
|
-
timeLabel: 'time-label',
|
|
23
|
-
timeFormat: '{value}',
|
|
24
|
-
livesBar: 'lives-bar',
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
static getPropertySchema(): PropertySchema {
|
|
29
|
-
const str = (name: string, label: string, description?: string) => ({
|
|
30
|
-
name,
|
|
31
|
-
type: 'string' as const,
|
|
32
|
-
ui: { label, group: 'HUD', description },
|
|
33
|
-
getValue: (s: unknown) => (s as ScoreHud).config[name],
|
|
34
|
-
setValue: (s: unknown, v: unknown) => {
|
|
35
|
-
(s as ScoreHud).config[name] = typeof v === 'string' ? v : '';
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
nodeType: 'ScoreHud',
|
|
41
|
-
properties: [
|
|
42
|
-
str('sourceNode', 'Source Node', 'Node that emits score-changed / lives-changed / time-changed'),
|
|
43
|
-
str('scoreLabel', 'Score Label'),
|
|
44
|
-
str('scoreFormat', 'Score Format', '{value} is replaced by the score'),
|
|
45
|
-
str('timeLabel', 'Time Label'),
|
|
46
|
-
str('timeFormat', 'Time Format', '{value} is replaced by whole seconds'),
|
|
47
|
-
str('livesBar', 'Lives Bar'),
|
|
48
|
-
],
|
|
49
|
-
groups: { HUD: { label: 'HUD', expanded: true } },
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
onStart(): void {
|
|
54
|
-
const source = this.findNode(String(this.config.sourceNode ?? ''));
|
|
55
|
-
if (!source) {
|
|
56
|
-
console.warn(`[ScoreHud] Source node "${this.config.sourceNode}" not found.`);
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
source.connect('score-changed', this, (...args: unknown[]) => this.setScore(Number(args[0]) || 0));
|
|
60
|
-
source.connect('time-changed', this, (...args: unknown[]) => this.setTime(Number(args[0]) || 0));
|
|
61
|
-
source.connect('lives-changed', this, (...args: unknown[]) =>
|
|
62
|
-
this.setLives(Number(args[0]) || 0, Number(args[1]) || 0)
|
|
63
|
-
);
|
|
64
|
-
// Ask for the opening values now that the listeners are live: GameRules
|
|
65
|
-
// starts before this HUD (parent components tick before children).
|
|
66
|
-
source.emit('hud-ready');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
private setScore(score: number): void {
|
|
70
|
-
const label = this.findNode(String(this.config.scoreLabel ?? ''));
|
|
71
|
-
if (label instanceof Label2D) {
|
|
72
|
-
label.setText(String(this.config.scoreFormat ?? '{value}').replace('{value}', String(Math.round(score))));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private setTime(seconds: number): void {
|
|
77
|
-
const label = this.findNode(String(this.config.timeLabel ?? ''));
|
|
78
|
-
if (label instanceof Label2D) {
|
|
79
|
-
label.setText(
|
|
80
|
-
String(this.config.timeFormat ?? '{value}').replace('{value}', String(Math.max(0, Math.ceil(seconds))))
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
private setLives(lives: number, maxLives: number): void {
|
|
86
|
-
const bar = this.findNode(String(this.config.livesBar ?? ''));
|
|
87
|
-
if (bar instanceof Bar2D) {
|
|
88
|
-
if (maxLives > 0) {
|
|
89
|
-
bar.maxValue = maxLives;
|
|
90
|
-
}
|
|
91
|
-
bar.setValue(lives);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ScoreHud — signal-driven HUD. Display only, no game logic.
|
|
3
|
+
*
|
|
4
|
+
* Listens on `sourceNode` (the node carrying `GameRules`) for:
|
|
5
|
+
* `score-changed` (score)
|
|
6
|
+
* `lives-changed` (lives, maxLives)
|
|
7
|
+
* `time-changed` (seconds)
|
|
8
|
+
*
|
|
9
|
+
* If a HUD widget is missing from the scene it is quietly skipped, so you can
|
|
10
|
+
* delete the timer or the lives bar without touching any script.
|
|
11
|
+
*/
|
|
12
|
+
import { Bar2D, Label2D, Script, type PropertySchema } from '@pix3/runtime';
|
|
13
|
+
|
|
14
|
+
export class ScoreHud extends Script {
|
|
15
|
+
constructor(id: string, type: string) {
|
|
16
|
+
super(id, type);
|
|
17
|
+
this.config = {
|
|
18
|
+
// Node that emits the HUD signals (the GameRules host).
|
|
19
|
+
sourceNode: 'game-root',
|
|
20
|
+
scoreLabel: 'score-label',
|
|
21
|
+
scoreFormat: 'SCORE {value}',
|
|
22
|
+
timeLabel: 'time-label',
|
|
23
|
+
timeFormat: '{value}',
|
|
24
|
+
livesBar: 'lives-bar',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static getPropertySchema(): PropertySchema {
|
|
29
|
+
const str = (name: string, label: string, description?: string) => ({
|
|
30
|
+
name,
|
|
31
|
+
type: 'string' as const,
|
|
32
|
+
ui: { label, group: 'HUD', description },
|
|
33
|
+
getValue: (s: unknown) => (s as ScoreHud).config[name],
|
|
34
|
+
setValue: (s: unknown, v: unknown) => {
|
|
35
|
+
(s as ScoreHud).config[name] = typeof v === 'string' ? v : '';
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
nodeType: 'ScoreHud',
|
|
41
|
+
properties: [
|
|
42
|
+
str('sourceNode', 'Source Node', 'Node that emits score-changed / lives-changed / time-changed'),
|
|
43
|
+
str('scoreLabel', 'Score Label'),
|
|
44
|
+
str('scoreFormat', 'Score Format', '{value} is replaced by the score'),
|
|
45
|
+
str('timeLabel', 'Time Label'),
|
|
46
|
+
str('timeFormat', 'Time Format', '{value} is replaced by whole seconds'),
|
|
47
|
+
str('livesBar', 'Lives Bar'),
|
|
48
|
+
],
|
|
49
|
+
groups: { HUD: { label: 'HUD', expanded: true } },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onStart(): void {
|
|
54
|
+
const source = this.findNode(String(this.config.sourceNode ?? ''));
|
|
55
|
+
if (!source) {
|
|
56
|
+
console.warn(`[ScoreHud] Source node "${this.config.sourceNode}" not found.`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
source.connect('score-changed', this, (...args: unknown[]) => this.setScore(Number(args[0]) || 0));
|
|
60
|
+
source.connect('time-changed', this, (...args: unknown[]) => this.setTime(Number(args[0]) || 0));
|
|
61
|
+
source.connect('lives-changed', this, (...args: unknown[]) =>
|
|
62
|
+
this.setLives(Number(args[0]) || 0, Number(args[1]) || 0)
|
|
63
|
+
);
|
|
64
|
+
// Ask for the opening values now that the listeners are live: GameRules
|
|
65
|
+
// starts before this HUD (parent components tick before children).
|
|
66
|
+
source.emit('hud-ready');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private setScore(score: number): void {
|
|
70
|
+
const label = this.findNode(String(this.config.scoreLabel ?? ''));
|
|
71
|
+
if (label instanceof Label2D) {
|
|
72
|
+
label.setText(String(this.config.scoreFormat ?? '{value}').replace('{value}', String(Math.round(score))));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private setTime(seconds: number): void {
|
|
77
|
+
const label = this.findNode(String(this.config.timeLabel ?? ''));
|
|
78
|
+
if (label instanceof Label2D) {
|
|
79
|
+
label.setText(
|
|
80
|
+
String(this.config.timeFormat ?? '{value}').replace('{value}', String(Math.max(0, Math.ceil(seconds))))
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private setLives(lives: number, maxLives: number): void {
|
|
86
|
+
const bar = this.findNode(String(this.config.livesBar ?? ''));
|
|
87
|
+
if (bar instanceof Bar2D) {
|
|
88
|
+
if (maxLives > 0) {
|
|
89
|
+
bar.maxValue = maxLives;
|
|
90
|
+
}
|
|
91
|
+
bar.setValue(lives);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Spawner — drops prefab instances into the field on a timer.
|
|
3
|
-
*
|
|
4
|
-
* One spawner = one kind of thing. Put a second Spawner on a second node for a
|
|
5
|
-
* second kind (the recipe ships `spawner-pickups` + `spawner-hazards`).
|
|
6
|
-
*
|
|
7
|
-
* It owns the whole life of what it spawns: pick a random point in the spawn
|
|
8
|
-
* rect (optionally grid-snapped), instantiate the prefab under `parentNode`,
|
|
9
|
-
* drift it every frame, and `queueFree` it when its lifetime runs out or it
|
|
10
|
-
* leaves `despawnRadius`. What a spawned thing DOES on contact is not its
|
|
11
|
-
* business — that is `TouchRules` reading the prefab's `core:Hitbox2D` group.
|
|
12
|
-
*/
|
|
13
|
-
import { Script, type NodeBase, type PropertySchema } from '@pix3/runtime';
|
|
14
|
-
|
|
15
|
-
interface SpawnedEntry {
|
|
16
|
-
node: NodeBase;
|
|
17
|
-
ttl: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** World point → the coordinate space of `node` (2×2 inverse of its world basis). */
|
|
21
|
-
function worldToLocal2D(node: NodeBase, wx: number, wy: number): { x: number; y: number } {
|
|
22
|
-
node.updateWorldMatrix(true, false);
|
|
23
|
-
const e = node.matrixWorld.elements;
|
|
24
|
-
const dx = wx - e[12];
|
|
25
|
-
const dy = wy - e[13];
|
|
26
|
-
const det = e[0] * e[5] - e[1] * e[4];
|
|
27
|
-
if (Math.abs(det) < 1e-8) {
|
|
28
|
-
return { x: dx, y: dy };
|
|
29
|
-
}
|
|
30
|
-
return { x: (e[5] * dx - e[4] * dy) / det, y: (e[0] * dy - e[1] * dx) / det };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Local point in `node`'s space → 2D world. */
|
|
34
|
-
function localToWorld2D(node: NodeBase, lx: number, ly: number): { x: number; y: number } {
|
|
35
|
-
node.updateWorldMatrix(true, false);
|
|
36
|
-
const e = node.matrixWorld.elements;
|
|
37
|
-
return { x: e[0] * lx + e[4] * ly + e[12], y: e[1] * lx + e[5] * ly + e[13] };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export class Spawner extends Script {
|
|
41
|
-
private alive: SpawnedEntry[] = [];
|
|
42
|
-
private cooldown = 0;
|
|
43
|
-
private pending = 0;
|
|
44
|
-
|
|
45
|
-
constructor(id: string, type: string) {
|
|
46
|
-
super(id, type);
|
|
47
|
-
this.config = {
|
|
48
|
-
// Prefab (.pix3scene with exactly one root node) to instantiate.
|
|
49
|
-
prefab: 'res://scenes/prefabs/pickup.pix3scene',
|
|
50
|
-
// Node id/name the instances are parented to. Empty = this spawner node.
|
|
51
|
-
parentNode: '',
|
|
52
|
-
// Seconds between spawns, plus a random 0..jitter extra.
|
|
53
|
-
intervalSec: 1.1,
|
|
54
|
-
intervalJitter: 0.35,
|
|
55
|
-
// Hard cap on simultaneously alive instances from THIS spawner.
|
|
56
|
-
maxAlive: 10,
|
|
57
|
-
// Spawn rect (centred on this node), in logical pixels.
|
|
58
|
-
spawnWidth: 860,
|
|
59
|
-
spawnHeight: 60,
|
|
60
|
-
// Snap spawn coordinates to a grid of this size (0 = free placement).
|
|
61
|
-
gridSnap: 0,
|
|
62
|
-
// Constant per-second drift applied to every live instance.
|
|
63
|
-
driftX: 0,
|
|
64
|
-
driftY: -300,
|
|
65
|
-
// Seconds an instance lives (0 = until it leaves despawnRadius).
|
|
66
|
-
lifetimeSec: 7,
|
|
67
|
-
// Distance from this node beyond which an instance is freed.
|
|
68
|
-
despawnRadius: 1800,
|
|
69
|
-
// Delay before the first spawn.
|
|
70
|
-
startDelaySec: 0.4,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
static getPropertySchema(): PropertySchema {
|
|
75
|
-
const num = (name: string, label: string, min: number, max: number, step: number, group: string) => ({
|
|
76
|
-
name,
|
|
77
|
-
type: 'number' as const,
|
|
78
|
-
ui: { label, group, min, max, step, slider: true },
|
|
79
|
-
getValue: (s: unknown) => (s as Spawner).config[name],
|
|
80
|
-
setValue: (s: unknown, v: unknown) => {
|
|
81
|
-
const n = Number(v);
|
|
82
|
-
(s as Spawner).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
const str = (name: string, label: string) => ({
|
|
86
|
-
name,
|
|
87
|
-
type: 'string' as const,
|
|
88
|
-
ui: { label, group: 'Source' },
|
|
89
|
-
getValue: (s: unknown) => (s as Spawner).config[name],
|
|
90
|
-
setValue: (s: unknown, v: unknown) => {
|
|
91
|
-
(s as Spawner).config[name] = typeof v === 'string' ? v : '';
|
|
92
|
-
},
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
nodeType: 'Spawner',
|
|
97
|
-
properties: [
|
|
98
|
-
str('prefab', 'Prefab'),
|
|
99
|
-
str('parentNode', 'Parent Node'),
|
|
100
|
-
num('intervalSec', 'Interval (s)', 0.05, 20, 0.05, 'Rate'),
|
|
101
|
-
num('intervalJitter', 'Interval Jitter (s)', 0, 10, 0.05, 'Rate'),
|
|
102
|
-
num('maxAlive', 'Max Alive', 1, 200, 1, 'Rate'),
|
|
103
|
-
num('startDelaySec', 'Start Delay (s)', 0, 20, 0.1, 'Rate'),
|
|
104
|
-
num('spawnWidth', 'Spawn Width', 0, 4000, 10, 'Placement'),
|
|
105
|
-
num('spawnHeight', 'Spawn Height', 0, 4000, 10, 'Placement'),
|
|
106
|
-
num('gridSnap', 'Grid Snap', 0, 500, 1, 'Placement'),
|
|
107
|
-
num('driftX', 'Drift X (px/s)', -3000, 3000, 10, 'Motion'),
|
|
108
|
-
num('driftY', 'Drift Y (px/s)', -3000, 3000, 10, 'Motion'),
|
|
109
|
-
num('lifetimeSec', 'Lifetime (s)', 0, 120, 0.5, 'Motion'),
|
|
110
|
-
num('despawnRadius', 'Despawn Radius', 100, 8000, 50, 'Motion'),
|
|
111
|
-
],
|
|
112
|
-
groups: {
|
|
113
|
-
Source: { label: 'Source', expanded: true },
|
|
114
|
-
Rate: { label: 'Rate', expanded: true },
|
|
115
|
-
Placement: { label: 'Placement', expanded: true },
|
|
116
|
-
Motion: { label: 'Motion', expanded: true },
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
onStart(): void {
|
|
122
|
-
this.cooldown = Math.max(0, Number(this.config.startDelaySec) || 0);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
onUpdate(dt: number): void {
|
|
126
|
-
this.driftAndExpire(dt);
|
|
127
|
-
|
|
128
|
-
this.cooldown -= dt;
|
|
129
|
-
if (this.cooldown > 0) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
const interval = Math.max(0.05, Number(this.config.intervalSec) || 0.05);
|
|
133
|
-
const jitter = Math.max(0, Number(this.config.intervalJitter) || 0);
|
|
134
|
-
this.cooldown = interval + Math.random() * jitter;
|
|
135
|
-
|
|
136
|
-
const maxAlive = Math.max(1, Number(this.config.maxAlive) || 1);
|
|
137
|
-
if (this.alive.length + this.pending < maxAlive) {
|
|
138
|
-
void this.spawnOne();
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Free everything this spawner currently owns (e.g. on a restart). */
|
|
143
|
-
clear(): void {
|
|
144
|
-
for (const entry of this.alive) {
|
|
145
|
-
entry.node.queueFree();
|
|
146
|
-
}
|
|
147
|
-
this.alive = [];
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private driftAndExpire(dt: number): void {
|
|
151
|
-
const dx = (Number(this.config.driftX) || 0) * dt;
|
|
152
|
-
const dy = (Number(this.config.driftY) || 0) * dt;
|
|
153
|
-
const lifetime = Math.max(0, Number(this.config.lifetimeSec) || 0);
|
|
154
|
-
const despawn = Math.max(1, Number(this.config.despawnRadius) || 1);
|
|
155
|
-
const survivors: SpawnedEntry[] = [];
|
|
156
|
-
|
|
157
|
-
for (const entry of this.alive) {
|
|
158
|
-
if (!entry.node.parent) {
|
|
159
|
-
continue; // already freed elsewhere (TouchRules consumed it)
|
|
160
|
-
}
|
|
161
|
-
entry.node.position.x += dx;
|
|
162
|
-
entry.node.position.y += dy;
|
|
163
|
-
entry.ttl -= dt;
|
|
164
|
-
|
|
165
|
-
const world = localToWorld2D(entry.node, 0, 0);
|
|
166
|
-
const here = worldToLocal2D(this.node ?? entry.node, world.x, world.y);
|
|
167
|
-
const outOfRange = Math.abs(here.x) > despawn || Math.abs(here.y) > despawn;
|
|
168
|
-
if (outOfRange || (lifetime > 0 && entry.ttl <= 0)) {
|
|
169
|
-
entry.node.queueFree();
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
survivors.push(entry);
|
|
173
|
-
}
|
|
174
|
-
this.alive = survivors;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
private async spawnOne(): Promise<void> {
|
|
178
|
-
const scene = this.scene;
|
|
179
|
-
const owner = this.node;
|
|
180
|
-
const prefab = String(this.config.prefab ?? '');
|
|
181
|
-
if (!scene || !owner || !prefab) {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
const parentQuery = String(this.config.parentNode ?? '');
|
|
185
|
-
const parent = parentQuery ? this.findNode(parentQuery) : owner;
|
|
186
|
-
if (!parent) {
|
|
187
|
-
console.warn(`[Spawner] Parent "${parentQuery}" not found.`);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const snap = Math.max(0, Number(this.config.gridSnap) || 0);
|
|
192
|
-
const halfW = Math.max(0, Number(this.config.spawnWidth) || 0) / 2;
|
|
193
|
-
const halfH = Math.max(0, Number(this.config.spawnHeight) || 0) / 2;
|
|
194
|
-
let localX = (Math.random() * 2 - 1) * halfW;
|
|
195
|
-
let localY = (Math.random() * 2 - 1) * halfH;
|
|
196
|
-
if (snap > 0) {
|
|
197
|
-
localX = Math.round(localX / snap) * snap;
|
|
198
|
-
localY = Math.round(localY / snap) * snap;
|
|
199
|
-
}
|
|
200
|
-
const world = localToWorld2D(owner, localX, localY);
|
|
201
|
-
|
|
202
|
-
this.pending += 1;
|
|
203
|
-
try {
|
|
204
|
-
const node = await scene.instantiate(prefab, { parent });
|
|
205
|
-
const local = worldToLocal2D(parent, world.x, world.y);
|
|
206
|
-
node.position.set(local.x, local.y, node.position.z);
|
|
207
|
-
this.alive.push({ node, ttl: Math.max(0, Number(this.config.lifetimeSec) || 0) });
|
|
208
|
-
} catch (error) {
|
|
209
|
-
console.warn(`[Spawner] Failed to spawn "${prefab}":`, error);
|
|
210
|
-
} finally {
|
|
211
|
-
this.pending -= 1;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Spawner — drops prefab instances into the field on a timer.
|
|
3
|
+
*
|
|
4
|
+
* One spawner = one kind of thing. Put a second Spawner on a second node for a
|
|
5
|
+
* second kind (the recipe ships `spawner-pickups` + `spawner-hazards`).
|
|
6
|
+
*
|
|
7
|
+
* It owns the whole life of what it spawns: pick a random point in the spawn
|
|
8
|
+
* rect (optionally grid-snapped), instantiate the prefab under `parentNode`,
|
|
9
|
+
* drift it every frame, and `queueFree` it when its lifetime runs out or it
|
|
10
|
+
* leaves `despawnRadius`. What a spawned thing DOES on contact is not its
|
|
11
|
+
* business — that is `TouchRules` reading the prefab's `core:Hitbox2D` group.
|
|
12
|
+
*/
|
|
13
|
+
import { Script, type NodeBase, type PropertySchema } from '@pix3/runtime';
|
|
14
|
+
|
|
15
|
+
interface SpawnedEntry {
|
|
16
|
+
node: NodeBase;
|
|
17
|
+
ttl: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** World point → the coordinate space of `node` (2×2 inverse of its world basis). */
|
|
21
|
+
function worldToLocal2D(node: NodeBase, wx: number, wy: number): { x: number; y: number } {
|
|
22
|
+
node.updateWorldMatrix(true, false);
|
|
23
|
+
const e = node.matrixWorld.elements;
|
|
24
|
+
const dx = wx - e[12];
|
|
25
|
+
const dy = wy - e[13];
|
|
26
|
+
const det = e[0] * e[5] - e[1] * e[4];
|
|
27
|
+
if (Math.abs(det) < 1e-8) {
|
|
28
|
+
return { x: dx, y: dy };
|
|
29
|
+
}
|
|
30
|
+
return { x: (e[5] * dx - e[4] * dy) / det, y: (e[0] * dy - e[1] * dx) / det };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Local point in `node`'s space → 2D world. */
|
|
34
|
+
function localToWorld2D(node: NodeBase, lx: number, ly: number): { x: number; y: number } {
|
|
35
|
+
node.updateWorldMatrix(true, false);
|
|
36
|
+
const e = node.matrixWorld.elements;
|
|
37
|
+
return { x: e[0] * lx + e[4] * ly + e[12], y: e[1] * lx + e[5] * ly + e[13] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class Spawner extends Script {
|
|
41
|
+
private alive: SpawnedEntry[] = [];
|
|
42
|
+
private cooldown = 0;
|
|
43
|
+
private pending = 0;
|
|
44
|
+
|
|
45
|
+
constructor(id: string, type: string) {
|
|
46
|
+
super(id, type);
|
|
47
|
+
this.config = {
|
|
48
|
+
// Prefab (.pix3scene with exactly one root node) to instantiate.
|
|
49
|
+
prefab: 'res://scenes/prefabs/pickup.pix3scene',
|
|
50
|
+
// Node id/name the instances are parented to. Empty = this spawner node.
|
|
51
|
+
parentNode: '',
|
|
52
|
+
// Seconds between spawns, plus a random 0..jitter extra.
|
|
53
|
+
intervalSec: 1.1,
|
|
54
|
+
intervalJitter: 0.35,
|
|
55
|
+
// Hard cap on simultaneously alive instances from THIS spawner.
|
|
56
|
+
maxAlive: 10,
|
|
57
|
+
// Spawn rect (centred on this node), in logical pixels.
|
|
58
|
+
spawnWidth: 860,
|
|
59
|
+
spawnHeight: 60,
|
|
60
|
+
// Snap spawn coordinates to a grid of this size (0 = free placement).
|
|
61
|
+
gridSnap: 0,
|
|
62
|
+
// Constant per-second drift applied to every live instance.
|
|
63
|
+
driftX: 0,
|
|
64
|
+
driftY: -300,
|
|
65
|
+
// Seconds an instance lives (0 = until it leaves despawnRadius).
|
|
66
|
+
lifetimeSec: 7,
|
|
67
|
+
// Distance from this node beyond which an instance is freed.
|
|
68
|
+
despawnRadius: 1800,
|
|
69
|
+
// Delay before the first spawn.
|
|
70
|
+
startDelaySec: 0.4,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static getPropertySchema(): PropertySchema {
|
|
75
|
+
const num = (name: string, label: string, min: number, max: number, step: number, group: string) => ({
|
|
76
|
+
name,
|
|
77
|
+
type: 'number' as const,
|
|
78
|
+
ui: { label, group, min, max, step, slider: true },
|
|
79
|
+
getValue: (s: unknown) => (s as Spawner).config[name],
|
|
80
|
+
setValue: (s: unknown, v: unknown) => {
|
|
81
|
+
const n = Number(v);
|
|
82
|
+
(s as Spawner).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const str = (name: string, label: string) => ({
|
|
86
|
+
name,
|
|
87
|
+
type: 'string' as const,
|
|
88
|
+
ui: { label, group: 'Source' },
|
|
89
|
+
getValue: (s: unknown) => (s as Spawner).config[name],
|
|
90
|
+
setValue: (s: unknown, v: unknown) => {
|
|
91
|
+
(s as Spawner).config[name] = typeof v === 'string' ? v : '';
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
nodeType: 'Spawner',
|
|
97
|
+
properties: [
|
|
98
|
+
str('prefab', 'Prefab'),
|
|
99
|
+
str('parentNode', 'Parent Node'),
|
|
100
|
+
num('intervalSec', 'Interval (s)', 0.05, 20, 0.05, 'Rate'),
|
|
101
|
+
num('intervalJitter', 'Interval Jitter (s)', 0, 10, 0.05, 'Rate'),
|
|
102
|
+
num('maxAlive', 'Max Alive', 1, 200, 1, 'Rate'),
|
|
103
|
+
num('startDelaySec', 'Start Delay (s)', 0, 20, 0.1, 'Rate'),
|
|
104
|
+
num('spawnWidth', 'Spawn Width', 0, 4000, 10, 'Placement'),
|
|
105
|
+
num('spawnHeight', 'Spawn Height', 0, 4000, 10, 'Placement'),
|
|
106
|
+
num('gridSnap', 'Grid Snap', 0, 500, 1, 'Placement'),
|
|
107
|
+
num('driftX', 'Drift X (px/s)', -3000, 3000, 10, 'Motion'),
|
|
108
|
+
num('driftY', 'Drift Y (px/s)', -3000, 3000, 10, 'Motion'),
|
|
109
|
+
num('lifetimeSec', 'Lifetime (s)', 0, 120, 0.5, 'Motion'),
|
|
110
|
+
num('despawnRadius', 'Despawn Radius', 100, 8000, 50, 'Motion'),
|
|
111
|
+
],
|
|
112
|
+
groups: {
|
|
113
|
+
Source: { label: 'Source', expanded: true },
|
|
114
|
+
Rate: { label: 'Rate', expanded: true },
|
|
115
|
+
Placement: { label: 'Placement', expanded: true },
|
|
116
|
+
Motion: { label: 'Motion', expanded: true },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onStart(): void {
|
|
122
|
+
this.cooldown = Math.max(0, Number(this.config.startDelaySec) || 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
onUpdate(dt: number): void {
|
|
126
|
+
this.driftAndExpire(dt);
|
|
127
|
+
|
|
128
|
+
this.cooldown -= dt;
|
|
129
|
+
if (this.cooldown > 0) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const interval = Math.max(0.05, Number(this.config.intervalSec) || 0.05);
|
|
133
|
+
const jitter = Math.max(0, Number(this.config.intervalJitter) || 0);
|
|
134
|
+
this.cooldown = interval + Math.random() * jitter;
|
|
135
|
+
|
|
136
|
+
const maxAlive = Math.max(1, Number(this.config.maxAlive) || 1);
|
|
137
|
+
if (this.alive.length + this.pending < maxAlive) {
|
|
138
|
+
void this.spawnOne();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Free everything this spawner currently owns (e.g. on a restart). */
|
|
143
|
+
clear(): void {
|
|
144
|
+
for (const entry of this.alive) {
|
|
145
|
+
entry.node.queueFree();
|
|
146
|
+
}
|
|
147
|
+
this.alive = [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private driftAndExpire(dt: number): void {
|
|
151
|
+
const dx = (Number(this.config.driftX) || 0) * dt;
|
|
152
|
+
const dy = (Number(this.config.driftY) || 0) * dt;
|
|
153
|
+
const lifetime = Math.max(0, Number(this.config.lifetimeSec) || 0);
|
|
154
|
+
const despawn = Math.max(1, Number(this.config.despawnRadius) || 1);
|
|
155
|
+
const survivors: SpawnedEntry[] = [];
|
|
156
|
+
|
|
157
|
+
for (const entry of this.alive) {
|
|
158
|
+
if (!entry.node.parent) {
|
|
159
|
+
continue; // already freed elsewhere (TouchRules consumed it)
|
|
160
|
+
}
|
|
161
|
+
entry.node.position.x += dx;
|
|
162
|
+
entry.node.position.y += dy;
|
|
163
|
+
entry.ttl -= dt;
|
|
164
|
+
|
|
165
|
+
const world = localToWorld2D(entry.node, 0, 0);
|
|
166
|
+
const here = worldToLocal2D(this.node ?? entry.node, world.x, world.y);
|
|
167
|
+
const outOfRange = Math.abs(here.x) > despawn || Math.abs(here.y) > despawn;
|
|
168
|
+
if (outOfRange || (lifetime > 0 && entry.ttl <= 0)) {
|
|
169
|
+
entry.node.queueFree();
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
survivors.push(entry);
|
|
173
|
+
}
|
|
174
|
+
this.alive = survivors;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async spawnOne(): Promise<void> {
|
|
178
|
+
const scene = this.scene;
|
|
179
|
+
const owner = this.node;
|
|
180
|
+
const prefab = String(this.config.prefab ?? '');
|
|
181
|
+
if (!scene || !owner || !prefab) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const parentQuery = String(this.config.parentNode ?? '');
|
|
185
|
+
const parent = parentQuery ? this.findNode(parentQuery) : owner;
|
|
186
|
+
if (!parent) {
|
|
187
|
+
console.warn(`[Spawner] Parent "${parentQuery}" not found.`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const snap = Math.max(0, Number(this.config.gridSnap) || 0);
|
|
192
|
+
const halfW = Math.max(0, Number(this.config.spawnWidth) || 0) / 2;
|
|
193
|
+
const halfH = Math.max(0, Number(this.config.spawnHeight) || 0) / 2;
|
|
194
|
+
let localX = (Math.random() * 2 - 1) * halfW;
|
|
195
|
+
let localY = (Math.random() * 2 - 1) * halfH;
|
|
196
|
+
if (snap > 0) {
|
|
197
|
+
localX = Math.round(localX / snap) * snap;
|
|
198
|
+
localY = Math.round(localY / snap) * snap;
|
|
199
|
+
}
|
|
200
|
+
const world = localToWorld2D(owner, localX, localY);
|
|
201
|
+
|
|
202
|
+
this.pending += 1;
|
|
203
|
+
try {
|
|
204
|
+
const node = await scene.instantiate(prefab, { parent });
|
|
205
|
+
const local = worldToLocal2D(parent, world.x, world.y);
|
|
206
|
+
node.position.set(local.x, local.y, node.position.z);
|
|
207
|
+
this.alive.push({ node, ttl: Math.max(0, Number(this.config.lifetimeSec) || 0) });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.warn(`[Spawner] Failed to spawn "${prefab}":`, error);
|
|
210
|
+
} finally {
|
|
211
|
+
this.pending -= 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|