@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,263 +1,263 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TouchRules — turns ball contacts into meaning, and into feel.
|
|
3
|
-
*
|
|
4
|
-
* Same role (and the same `rules` format) as in the other recipes; only the
|
|
5
|
-
* source of the contact differs. Here it listens to `BallBody`'s `ball-hit`
|
|
6
|
-
* signal instead of running its own overlap test, because the swept solver
|
|
7
|
-
* already knows exactly what was hit and how hard.
|
|
8
|
-
*
|
|
9
|
-
* `ball-hit` payload: (kind, nodeId, speed, x, y) where kind is `wall`,
|
|
10
|
-
* `paddle`, `bumper` or `drain`. `x`/`y` are WORLD coordinates, which is why the
|
|
11
|
-
* particles and the score popup can be spawned straight at the contact point.
|
|
12
|
-
*
|
|
13
|
-
* It emits the same semantic signals every recipe uses, on `game-root`:
|
|
14
|
-
* `touch-scored` (amount, x, y)
|
|
15
|
-
* `touch-damaged` (amount, x, y)
|
|
16
|
-
*
|
|
17
|
-
* `rules` format: `kind:outcome:amount`, entries separated by `;`.
|
|
18
|
-
*
|
|
19
|
-
* The feel is all engine one-liners — copy them next to whatever mechanic you
|
|
20
|
-
* add, they cost nothing to call:
|
|
21
|
-
* `scene.audio.sfx('score')` procedural SFX, no asset needed
|
|
22
|
-
* `scene.juice.burst({x, y})` 2D particles that free themselves
|
|
23
|
-
* `scene.juice.floatText('+100')` score popup
|
|
24
|
-
* `scene.juice.punchScale(node)` / `flash` / `shake` / `scene.time.hitstop`
|
|
25
|
-
* Spawn them at BOARD-space anchors (a node or the hit point) rather than on the
|
|
26
|
-
* HUD: the HUD is a CanvasLayer2D drawn after post-processing, so effects
|
|
27
|
-
* parented there never bloom.
|
|
28
|
-
*/
|
|
29
|
-
import { Script, type PropertySchema } from '@pix3/runtime';
|
|
30
|
-
|
|
31
|
-
type TouchOutcome = 'score' | 'damage';
|
|
32
|
-
|
|
33
|
-
interface TouchRule {
|
|
34
|
-
kind: string;
|
|
35
|
-
outcome: TouchOutcome;
|
|
36
|
-
amount: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function parseRules(text: string): Map<string, TouchRule> {
|
|
40
|
-
const rules = new Map<string, TouchRule>();
|
|
41
|
-
for (const entry of text.split(';')) {
|
|
42
|
-
const parts = entry.trim().split(':');
|
|
43
|
-
if (parts.length < 2 || !parts[0].trim()) {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
const amount = Number(parts[2] ?? 1);
|
|
47
|
-
rules.set(parts[0].trim(), {
|
|
48
|
-
kind: parts[0].trim(),
|
|
49
|
-
outcome: parts[1].trim() === 'damage' ? 'damage' : 'score',
|
|
50
|
-
amount: Number.isFinite(amount) && amount > 0 ? amount : 1,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
return rules;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export class TouchRules extends Script {
|
|
57
|
-
constructor(id: string, type: string) {
|
|
58
|
-
super(id, type);
|
|
59
|
-
this.config = {
|
|
60
|
-
// Node carrying BallBody.
|
|
61
|
-
ballNode: 'ball',
|
|
62
|
-
// kind:outcome:amount pairs, separated by ';'.
|
|
63
|
-
rules: 'bumper:score:100;paddle:score:10;drain:damage:1',
|
|
64
|
-
// Ignore contacts softer than this (px/s) so a resting ball scores nothing.
|
|
65
|
-
minImpactSpeed: 60,
|
|
66
|
-
// Feel. Raise for arcade, drop to 0 to switch a piece of it off.
|
|
67
|
-
punchAmount: 0.34,
|
|
68
|
-
hitstopMs: 50,
|
|
69
|
-
burstCount: 18,
|
|
70
|
-
popupFormat: '+{value}',
|
|
71
|
-
feedbackColor: '#ffe066',
|
|
72
|
-
sfxEnabled: true,
|
|
73
|
-
// Optional asset overrides. When set they REPLACE the synth preset.
|
|
74
|
-
scoreSound: '',
|
|
75
|
-
damageSound: '',
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
static getPropertySchema(): PropertySchema {
|
|
80
|
-
const str = (name: string, label: string, group: string, editor?: 'audio-resource') => ({
|
|
81
|
-
name,
|
|
82
|
-
type: 'string' as const,
|
|
83
|
-
ui: { label, group, editor },
|
|
84
|
-
getValue: (s: unknown) => (s as TouchRules).config[name],
|
|
85
|
-
setValue: (s: unknown, v: unknown) => {
|
|
86
|
-
(s as TouchRules).config[name] = typeof v === 'string' ? v : '';
|
|
87
|
-
},
|
|
88
|
-
});
|
|
89
|
-
const num = (name: string, label: string, min: number, max: number, step: number) => ({
|
|
90
|
-
name,
|
|
91
|
-
type: 'number' as const,
|
|
92
|
-
ui: { label, group: 'Feel', min, max, step, slider: true },
|
|
93
|
-
getValue: (s: unknown) => (s as TouchRules).config[name],
|
|
94
|
-
setValue: (s: unknown, v: unknown) => {
|
|
95
|
-
const n = Number(v);
|
|
96
|
-
(s as TouchRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
97
|
-
},
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
return {
|
|
101
|
-
nodeType: 'TouchRules',
|
|
102
|
-
properties: [
|
|
103
|
-
str('ballNode', 'Ball Node', 'Contact'),
|
|
104
|
-
str('rules', 'Rules', 'Contact'),
|
|
105
|
-
{
|
|
106
|
-
name: 'minImpactSpeed',
|
|
107
|
-
type: 'number',
|
|
108
|
-
ui: { label: 'Min Impact Speed', group: 'Contact', min: 0, max: 2000, step: 10, slider: true },
|
|
109
|
-
getValue: s => (s as TouchRules).config.minImpactSpeed,
|
|
110
|
-
setValue: (s, v) => {
|
|
111
|
-
const n = Number(v);
|
|
112
|
-
(s as TouchRules).config.minImpactSpeed = Math.min(2000, Math.max(0, Number.isFinite(n) ? n : 0));
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
num('punchAmount', 'Punch Scale', 0, 1, 0.01),
|
|
116
|
-
num('hitstopMs', 'Hitstop (ms)', 0, 200, 5),
|
|
117
|
-
num('burstCount', 'Burst Particles', 0, 120, 1),
|
|
118
|
-
str('popupFormat', 'Score Popup', 'Feel'),
|
|
119
|
-
str('feedbackColor', 'Feedback Colour', 'Feel'),
|
|
120
|
-
{
|
|
121
|
-
name: 'sfxEnabled',
|
|
122
|
-
type: 'boolean',
|
|
123
|
-
ui: { label: 'Sound', group: 'Feedback' },
|
|
124
|
-
getValue: s => (s as TouchRules).config.sfxEnabled,
|
|
125
|
-
setValue: (s, v) => {
|
|
126
|
-
(s as TouchRules).config.sfxEnabled = Boolean(v);
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
str('scoreSound', 'Score Sound', 'Feedback', 'audio-resource'),
|
|
130
|
-
str('damageSound', 'Damage Sound', 'Feedback', 'audio-resource'),
|
|
131
|
-
],
|
|
132
|
-
groups: {
|
|
133
|
-
Contact: { label: 'Contact', expanded: true },
|
|
134
|
-
Feel: { label: 'Feel', expanded: true },
|
|
135
|
-
Feedback: { label: 'Sound', expanded: false },
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
onStart(): void {
|
|
141
|
-
const ball = this.findNode(String(this.config.ballNode ?? ''));
|
|
142
|
-
if (!ball) {
|
|
143
|
-
console.warn(`[TouchRules] Ball node "${this.config.ballNode}" not found.`);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
ball.connect('ball-hit', this, (...args: unknown[]) =>
|
|
147
|
-
this.resolve(String(args[0] ?? ''), String(args[1] ?? ''), Number(args[2]) || 0, Number(args[3]) || 0, Number(args[4]) || 0)
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private resolve(kind: string, nodeId: string, speed: number, x: number, y: number): void {
|
|
152
|
-
const scene = this.scene;
|
|
153
|
-
const owner = this.node;
|
|
154
|
-
if (!scene || !owner) {
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
// A resting ball rattling on the paddle must not score — or click.
|
|
158
|
-
if (kind !== 'drain' && speed < Math.max(0, Number(this.config.minImpactSpeed) || 0)) {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const rule = parseRules(String(this.config.rules ?? '')).get(kind);
|
|
163
|
-
// Every contact is audible, including the walls the rules say nothing about.
|
|
164
|
-
this.playContactSound(kind, rule?.outcome ?? null, speed);
|
|
165
|
-
if (!rule) {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (rule.outcome === 'damage') {
|
|
170
|
-
owner.emit('touch-damaged', rule.amount, x, y);
|
|
171
|
-
scene.juice.flash({ color: '#ff2d6f', intensity: 0.45, durationSec: 0.24 });
|
|
172
|
-
// The board is safe to shake here: BallBody freezes the ball for
|
|
173
|
-
// `resetDelaySec` after a drain, so no collider moves under a live ball.
|
|
174
|
-
scene.juice.shake('board', { amplitude: 26, frequency: 22, duration: 0.3 });
|
|
175
|
-
this.burst(x, y, { colors: ['#ff2d6f', '#ffffff'], scale: 1, speed: 320 });
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
owner.emit('touch-scored', rule.amount, x, y);
|
|
180
|
-
const target = nodeId ? this.findNode(nodeId) : null;
|
|
181
|
-
if (target) {
|
|
182
|
-
const amount = Math.max(0, Number(this.config.punchAmount) || 0);
|
|
183
|
-
if (amount > 0) {
|
|
184
|
-
scene.juice.punchScale(target, { amount, duration: 0.24 });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
// Hitstop is the bumper's weight — the paddle is touched constantly and
|
|
188
|
-
// would stutter the whole run.
|
|
189
|
-
if (kind === 'bumper') {
|
|
190
|
-
scene.time.hitstop(Math.max(0, Number(this.config.hitstopMs) || 0));
|
|
191
|
-
}
|
|
192
|
-
const accent = this.accentColor();
|
|
193
|
-
this.burst(x, y, {
|
|
194
|
-
colors: ['#ffffff', accent],
|
|
195
|
-
scale: kind === 'bumper' ? 1 : 0.5,
|
|
196
|
-
speed: kind === 'bumper' ? 420 : 260,
|
|
197
|
-
});
|
|
198
|
-
this.popup(rule.amount, x, y, accent);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
private accentColor(): string {
|
|
202
|
-
const color = String(this.config.feedbackColor ?? '').trim();
|
|
203
|
-
return color.length > 0 ? color : '#ffe066';
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Contact particles at the hit point (world space, so they bloom with the board). */
|
|
207
|
-
private burst(
|
|
208
|
-
x: number,
|
|
209
|
-
y: number,
|
|
210
|
-
options: { colors: string[]; scale: number; speed: number }
|
|
211
|
-
): void {
|
|
212
|
-
const count = Math.round(Math.max(0, Number(this.config.burstCount) || 0) * options.scale);
|
|
213
|
-
if (count <= 0) {
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
this.scene?.juice.burst(
|
|
217
|
-
{ x, y },
|
|
218
|
-
{ count, colors: options.colors, speed: options.speed, sizePx: 12, lifeSec: 0.45 }
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** "+100" rising off the contact. Empty `popupFormat` switches it off. */
|
|
223
|
-
private popup(amount: number, x: number, y: number, color: string): void {
|
|
224
|
-
const format = String(this.config.popupFormat ?? '');
|
|
225
|
-
if (!format) {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
this.scene?.juice.floatText(format.replace('{value}', String(Math.round(amount))), {
|
|
229
|
-
at: { x, y },
|
|
230
|
-
color,
|
|
231
|
-
fontSizePx: 42,
|
|
232
|
-
glow: true,
|
|
233
|
-
glowStrength: 2,
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* An asset wins when one is authored; otherwise the procedural preset plays,
|
|
239
|
-
* so a fresh project has sound with no audio files at all. Bounces are
|
|
240
|
-
* pitched by impact speed — the cheapest "harder hit = brighter sound" there is.
|
|
241
|
-
*/
|
|
242
|
-
private playContactSound(kind: string, outcome: TouchOutcome | null, speed: number): void {
|
|
243
|
-
if (this.config.sfxEnabled === false) {
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
const damage = outcome === 'damage';
|
|
247
|
-
const asset = String((damage ? this.config.damageSound : this.config.scoreSound) ?? '');
|
|
248
|
-
if (asset && (damage || outcome === 'score')) {
|
|
249
|
-
void this.scene?.audio.play(asset, { bus: 'sfx', pitchVariation: 0.08 });
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
if (damage) {
|
|
253
|
-
this.scene?.audio.sfx('lose', { volume: 0.9 });
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
if (kind === 'bumper') {
|
|
257
|
-
this.scene?.audio.sfx('score', { pitch: 1 + Math.random() * 0.12 });
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
const pitch = Math.min(1.5, Math.max(0.8, 0.85 + speed / 3200));
|
|
261
|
-
this.scene?.audio.sfx('bounce', { pitch, volume: 0.8 });
|
|
262
|
-
}
|
|
263
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* TouchRules — turns ball contacts into meaning, and into feel.
|
|
3
|
+
*
|
|
4
|
+
* Same role (and the same `rules` format) as in the other recipes; only the
|
|
5
|
+
* source of the contact differs. Here it listens to `BallBody`'s `ball-hit`
|
|
6
|
+
* signal instead of running its own overlap test, because the swept solver
|
|
7
|
+
* already knows exactly what was hit and how hard.
|
|
8
|
+
*
|
|
9
|
+
* `ball-hit` payload: (kind, nodeId, speed, x, y) where kind is `wall`,
|
|
10
|
+
* `paddle`, `bumper` or `drain`. `x`/`y` are WORLD coordinates, which is why the
|
|
11
|
+
* particles and the score popup can be spawned straight at the contact point.
|
|
12
|
+
*
|
|
13
|
+
* It emits the same semantic signals every recipe uses, on `game-root`:
|
|
14
|
+
* `touch-scored` (amount, x, y)
|
|
15
|
+
* `touch-damaged` (amount, x, y)
|
|
16
|
+
*
|
|
17
|
+
* `rules` format: `kind:outcome:amount`, entries separated by `;`.
|
|
18
|
+
*
|
|
19
|
+
* The feel is all engine one-liners — copy them next to whatever mechanic you
|
|
20
|
+
* add, they cost nothing to call:
|
|
21
|
+
* `scene.audio.sfx('score')` procedural SFX, no asset needed
|
|
22
|
+
* `scene.juice.burst({x, y})` 2D particles that free themselves
|
|
23
|
+
* `scene.juice.floatText('+100')` score popup
|
|
24
|
+
* `scene.juice.punchScale(node)` / `flash` / `shake` / `scene.time.hitstop`
|
|
25
|
+
* Spawn them at BOARD-space anchors (a node or the hit point) rather than on the
|
|
26
|
+
* HUD: the HUD is a CanvasLayer2D drawn after post-processing, so effects
|
|
27
|
+
* parented there never bloom.
|
|
28
|
+
*/
|
|
29
|
+
import { Script, type PropertySchema } from '@pix3/runtime';
|
|
30
|
+
|
|
31
|
+
type TouchOutcome = 'score' | 'damage';
|
|
32
|
+
|
|
33
|
+
interface TouchRule {
|
|
34
|
+
kind: string;
|
|
35
|
+
outcome: TouchOutcome;
|
|
36
|
+
amount: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseRules(text: string): Map<string, TouchRule> {
|
|
40
|
+
const rules = new Map<string, TouchRule>();
|
|
41
|
+
for (const entry of text.split(';')) {
|
|
42
|
+
const parts = entry.trim().split(':');
|
|
43
|
+
if (parts.length < 2 || !parts[0].trim()) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const amount = Number(parts[2] ?? 1);
|
|
47
|
+
rules.set(parts[0].trim(), {
|
|
48
|
+
kind: parts[0].trim(),
|
|
49
|
+
outcome: parts[1].trim() === 'damage' ? 'damage' : 'score',
|
|
50
|
+
amount: Number.isFinite(amount) && amount > 0 ? amount : 1,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return rules;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class TouchRules extends Script {
|
|
57
|
+
constructor(id: string, type: string) {
|
|
58
|
+
super(id, type);
|
|
59
|
+
this.config = {
|
|
60
|
+
// Node carrying BallBody.
|
|
61
|
+
ballNode: 'ball',
|
|
62
|
+
// kind:outcome:amount pairs, separated by ';'.
|
|
63
|
+
rules: 'bumper:score:100;paddle:score:10;drain:damage:1',
|
|
64
|
+
// Ignore contacts softer than this (px/s) so a resting ball scores nothing.
|
|
65
|
+
minImpactSpeed: 60,
|
|
66
|
+
// Feel. Raise for arcade, drop to 0 to switch a piece of it off.
|
|
67
|
+
punchAmount: 0.34,
|
|
68
|
+
hitstopMs: 50,
|
|
69
|
+
burstCount: 18,
|
|
70
|
+
popupFormat: '+{value}',
|
|
71
|
+
feedbackColor: '#ffe066',
|
|
72
|
+
sfxEnabled: true,
|
|
73
|
+
// Optional asset overrides. When set they REPLACE the synth preset.
|
|
74
|
+
scoreSound: '',
|
|
75
|
+
damageSound: '',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static getPropertySchema(): PropertySchema {
|
|
80
|
+
const str = (name: string, label: string, group: string, editor?: 'audio-resource') => ({
|
|
81
|
+
name,
|
|
82
|
+
type: 'string' as const,
|
|
83
|
+
ui: { label, group, editor },
|
|
84
|
+
getValue: (s: unknown) => (s as TouchRules).config[name],
|
|
85
|
+
setValue: (s: unknown, v: unknown) => {
|
|
86
|
+
(s as TouchRules).config[name] = typeof v === 'string' ? v : '';
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const num = (name: string, label: string, min: number, max: number, step: number) => ({
|
|
90
|
+
name,
|
|
91
|
+
type: 'number' as const,
|
|
92
|
+
ui: { label, group: 'Feel', min, max, step, slider: true },
|
|
93
|
+
getValue: (s: unknown) => (s as TouchRules).config[name],
|
|
94
|
+
setValue: (s: unknown, v: unknown) => {
|
|
95
|
+
const n = Number(v);
|
|
96
|
+
(s as TouchRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
nodeType: 'TouchRules',
|
|
102
|
+
properties: [
|
|
103
|
+
str('ballNode', 'Ball Node', 'Contact'),
|
|
104
|
+
str('rules', 'Rules', 'Contact'),
|
|
105
|
+
{
|
|
106
|
+
name: 'minImpactSpeed',
|
|
107
|
+
type: 'number',
|
|
108
|
+
ui: { label: 'Min Impact Speed', group: 'Contact', min: 0, max: 2000, step: 10, slider: true },
|
|
109
|
+
getValue: s => (s as TouchRules).config.minImpactSpeed,
|
|
110
|
+
setValue: (s, v) => {
|
|
111
|
+
const n = Number(v);
|
|
112
|
+
(s as TouchRules).config.minImpactSpeed = Math.min(2000, Math.max(0, Number.isFinite(n) ? n : 0));
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
num('punchAmount', 'Punch Scale', 0, 1, 0.01),
|
|
116
|
+
num('hitstopMs', 'Hitstop (ms)', 0, 200, 5),
|
|
117
|
+
num('burstCount', 'Burst Particles', 0, 120, 1),
|
|
118
|
+
str('popupFormat', 'Score Popup', 'Feel'),
|
|
119
|
+
str('feedbackColor', 'Feedback Colour', 'Feel'),
|
|
120
|
+
{
|
|
121
|
+
name: 'sfxEnabled',
|
|
122
|
+
type: 'boolean',
|
|
123
|
+
ui: { label: 'Sound', group: 'Feedback' },
|
|
124
|
+
getValue: s => (s as TouchRules).config.sfxEnabled,
|
|
125
|
+
setValue: (s, v) => {
|
|
126
|
+
(s as TouchRules).config.sfxEnabled = Boolean(v);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
str('scoreSound', 'Score Sound', 'Feedback', 'audio-resource'),
|
|
130
|
+
str('damageSound', 'Damage Sound', 'Feedback', 'audio-resource'),
|
|
131
|
+
],
|
|
132
|
+
groups: {
|
|
133
|
+
Contact: { label: 'Contact', expanded: true },
|
|
134
|
+
Feel: { label: 'Feel', expanded: true },
|
|
135
|
+
Feedback: { label: 'Sound', expanded: false },
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onStart(): void {
|
|
141
|
+
const ball = this.findNode(String(this.config.ballNode ?? ''));
|
|
142
|
+
if (!ball) {
|
|
143
|
+
console.warn(`[TouchRules] Ball node "${this.config.ballNode}" not found.`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
ball.connect('ball-hit', this, (...args: unknown[]) =>
|
|
147
|
+
this.resolve(String(args[0] ?? ''), String(args[1] ?? ''), Number(args[2]) || 0, Number(args[3]) || 0, Number(args[4]) || 0)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private resolve(kind: string, nodeId: string, speed: number, x: number, y: number): void {
|
|
152
|
+
const scene = this.scene;
|
|
153
|
+
const owner = this.node;
|
|
154
|
+
if (!scene || !owner) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// A resting ball rattling on the paddle must not score — or click.
|
|
158
|
+
if (kind !== 'drain' && speed < Math.max(0, Number(this.config.minImpactSpeed) || 0)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rule = parseRules(String(this.config.rules ?? '')).get(kind);
|
|
163
|
+
// Every contact is audible, including the walls the rules say nothing about.
|
|
164
|
+
this.playContactSound(kind, rule?.outcome ?? null, speed);
|
|
165
|
+
if (!rule) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (rule.outcome === 'damage') {
|
|
170
|
+
owner.emit('touch-damaged', rule.amount, x, y);
|
|
171
|
+
scene.juice.flash({ color: '#ff2d6f', intensity: 0.45, durationSec: 0.24 });
|
|
172
|
+
// The board is safe to shake here: BallBody freezes the ball for
|
|
173
|
+
// `resetDelaySec` after a drain, so no collider moves under a live ball.
|
|
174
|
+
scene.juice.shake('board', { amplitude: 26, frequency: 22, duration: 0.3 });
|
|
175
|
+
this.burst(x, y, { colors: ['#ff2d6f', '#ffffff'], scale: 1, speed: 320 });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
owner.emit('touch-scored', rule.amount, x, y);
|
|
180
|
+
const target = nodeId ? this.findNode(nodeId) : null;
|
|
181
|
+
if (target) {
|
|
182
|
+
const amount = Math.max(0, Number(this.config.punchAmount) || 0);
|
|
183
|
+
if (amount > 0) {
|
|
184
|
+
scene.juice.punchScale(target, { amount, duration: 0.24 });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Hitstop is the bumper's weight — the paddle is touched constantly and
|
|
188
|
+
// would stutter the whole run.
|
|
189
|
+
if (kind === 'bumper') {
|
|
190
|
+
scene.time.hitstop(Math.max(0, Number(this.config.hitstopMs) || 0));
|
|
191
|
+
}
|
|
192
|
+
const accent = this.accentColor();
|
|
193
|
+
this.burst(x, y, {
|
|
194
|
+
colors: ['#ffffff', accent],
|
|
195
|
+
scale: kind === 'bumper' ? 1 : 0.5,
|
|
196
|
+
speed: kind === 'bumper' ? 420 : 260,
|
|
197
|
+
});
|
|
198
|
+
this.popup(rule.amount, x, y, accent);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private accentColor(): string {
|
|
202
|
+
const color = String(this.config.feedbackColor ?? '').trim();
|
|
203
|
+
return color.length > 0 ? color : '#ffe066';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Contact particles at the hit point (world space, so they bloom with the board). */
|
|
207
|
+
private burst(
|
|
208
|
+
x: number,
|
|
209
|
+
y: number,
|
|
210
|
+
options: { colors: string[]; scale: number; speed: number }
|
|
211
|
+
): void {
|
|
212
|
+
const count = Math.round(Math.max(0, Number(this.config.burstCount) || 0) * options.scale);
|
|
213
|
+
if (count <= 0) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this.scene?.juice.burst(
|
|
217
|
+
{ x, y },
|
|
218
|
+
{ count, colors: options.colors, speed: options.speed, sizePx: 12, lifeSec: 0.45 }
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** "+100" rising off the contact. Empty `popupFormat` switches it off. */
|
|
223
|
+
private popup(amount: number, x: number, y: number, color: string): void {
|
|
224
|
+
const format = String(this.config.popupFormat ?? '');
|
|
225
|
+
if (!format) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.scene?.juice.floatText(format.replace('{value}', String(Math.round(amount))), {
|
|
229
|
+
at: { x, y },
|
|
230
|
+
color,
|
|
231
|
+
fontSizePx: 42,
|
|
232
|
+
glow: true,
|
|
233
|
+
glowStrength: 2,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* An asset wins when one is authored; otherwise the procedural preset plays,
|
|
239
|
+
* so a fresh project has sound with no audio files at all. Bounces are
|
|
240
|
+
* pitched by impact speed — the cheapest "harder hit = brighter sound" there is.
|
|
241
|
+
*/
|
|
242
|
+
private playContactSound(kind: string, outcome: TouchOutcome | null, speed: number): void {
|
|
243
|
+
if (this.config.sfxEnabled === false) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const damage = outcome === 'damage';
|
|
247
|
+
const asset = String((damage ? this.config.damageSound : this.config.scoreSound) ?? '');
|
|
248
|
+
if (asset && (damage || outcome === 'score')) {
|
|
249
|
+
void this.scene?.audio.play(asset, { bus: 'sfx', pitchVariation: 0.08 });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (damage) {
|
|
253
|
+
this.scene?.audio.sfx('lose', { volume: 0.9 });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (kind === 'bumper') {
|
|
257
|
+
this.scene?.audio.sfx('score', { pitch: 1 + Math.random() * 0.12 });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const pitch = Math.min(1.5, Math.max(0.8, 0.85 + speed / 3200));
|
|
261
|
+
this.scene?.audio.sfx('bounce', { pitch, volume: 0.8 });
|
|
262
|
+
}
|
|
263
|
+
}
|