@pix3/cli 1.6.4 → 2.0.0-alpha.0
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 +722 -788
- package/dist/index.js +557 -421
- package/dist/runtime-types.json +1 -1
- package/dist/smoke/prebuilt/{build-7MIXIMSZ.mjs → build-7REMSA2S.mjs} +52 -52
- package/dist/smoke/prebuilt/chunk-4DGG2BKR.mjs +2 -0
- package/dist/smoke/prebuilt/chunk-YLBMUJ2H.mjs +238 -0
- package/dist/smoke/prebuilt/runtime.mjs +1 -1
- package/dist/smoke/prebuilt/smoke-worker.mjs +211 -2
- package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
- package/dist/validate/prebuilt/{build-AUGKXER3.js → build-ADF5EEE2.js} +52 -52
- package/dist/validate/prebuilt/bundle-main.js +223 -18
- package/dist/validate/prebuilt/chunk-VIHIF4G2.js +238 -0
- package/dist/validate/prebuilt/runtime.js +1 -1
- package/kit/files/.claude/skills/pix3-editor/SKILL.md +200 -0
- package/kit/files/.claude/skills/pix3-nodes/SKILL.md +13 -15
- package/kit/files/.claude/skills/pix3-nodes/reference.md +77 -71
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +38 -41
- package/kit/files/.claude/skills/pix3-scene-format/pix3anim.md +185 -0
- package/kit/files/.claude/skills/pix3-scene-format/project-files.md +244 -0
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -16
- package/kit/files/.claude/skills/pix3-scripts/SKILL.md +34 -24
- package/kit/files/.claude/skills/pix3-scripts/reference.md +68 -141
- package/kit/files/.claude/skills/pix3-verify/SKILL.md +65 -326
- package/kit/files/.claude/skills/pix3-verify/reference.md +2 -14
- package/kit/files/AGENTS.md +76 -84
- package/kit/files/design/tests/bots/pix3-test-bot.d.ts +89 -0
- package/kit/kit.json +8 -5
- package/package.json +62 -54
- package/templates/2d/files/scenes/main.pix3scene +31 -0
- package/templates/2d/template.yaml +11 -0
- package/templates/3d/files/scenes/main.pix3scene +50 -0
- package/templates/3d/template.yaml +11 -0
- package/templates/base/files/README.md +39 -0
- package/templates/base/files/gitignore +11 -0
- package/templates/base/files/index.html +23 -0
- package/templates/base/files/package.json +28 -0
- package/templates/base/files/src/main.ts +3 -0
- package/templates/base/files/tsconfig.json +17 -0
- package/templates/base/files/vite.config.ts +10 -0
- package/dist/smoke/prebuilt/chunk-CBFYKDS7.mjs +0 -2
- package/dist/smoke/prebuilt/chunk-VJHJ5BCO.mjs +0 -239
- package/dist/validate/prebuilt/chunk-CFQFEI52.js +0 -239
- package/templates/empty-2d/files/README.md +0 -20
- package/templates/empty-2d/files/scenes/main.pix3scene +0 -68
- package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
- package/templates/empty-2d/template.yaml +0 -7
- package/templates/empty-3d/files/README.md +0 -20
- package/templates/empty-3d/files/scenes/main.pix3scene +0 -108
- package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
- package/templates/empty-3d/template.yaml +0 -7
- package/templates/idea-blank/files/README.md +0 -22
- package/templates/idea-blank/files/scenes/main.pix3scene +0 -37
- package/templates/idea-blank/template.yaml +0 -10
- package/templates/minigame-2d/files/README.md +0 -44
- package/templates/minigame-2d/files/design/tests/reachability.json +0 -1
- package/templates/minigame-2d/files/design/tests/routines/mute-music.json +0 -44
- package/templates/minigame-2d/files/scenes/main.pix3scene +0 -84
- package/templates/minigame-2d/files/scenes/menu.pix3scene +0 -118
- package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +0 -138
- package/templates/minigame-2d/files/scripts/GameFlow.ts +0 -133
- package/templates/minigame-2d/files/scripts/MenuFlow.ts +0 -217
- package/templates/minigame-2d/files/scripts/SettingsWindow.ts +0 -198
- package/templates/minigame-2d/template.yaml +0 -10
- package/templates/playable-2d/files/README.md +0 -30
- package/templates/playable-2d/files/design/recipe.md +0 -83
- package/templates/playable-2d/files/design/tests/reachability.json +0 -4
- package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +0 -32
- package/templates/playable-2d/files/scenes/main.pix3scene +0 -111
- package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +0 -91
- package/templates/playable-2d/files/scenes/ui/intro.pix3scene +0 -65
- package/templates/playable-2d/files/scripts/CtaButton.ts +0 -55
- package/templates/playable-2d/files/scripts/GameFlow.ts +0 -256
- package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
- package/templates/playable-2d/template.yaml +0 -9
- package/templates/playable-3d/files/README.md +0 -32
- package/templates/playable-3d/files/design/recipe.md +0 -127
- package/templates/playable-3d/files/design/tests/reachability.json +0 -4
- package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +0 -32
- package/templates/playable-3d/files/scenes/main.pix3scene +0 -230
- package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +0 -91
- package/templates/playable-3d/files/scenes/ui/intro.pix3scene +0 -65
- package/templates/playable-3d/files/scripts/CtaButton.ts +0 -55
- package/templates/playable-3d/files/scripts/GameFlow.ts +0 -309
- package/templates/playable-3d/files/scripts/GameRules.ts +0 -290
- package/templates/playable-3d/files/scripts/ScoreHud.ts +0 -113
- package/templates/playable-3d/template.yaml +0 -9
- package/templates/recipe-arena-2d/files/README.md +0 -48
- package/templates/recipe-arena-2d/files/design/recipe.md +0 -127
- package/templates/recipe-arena-2d/files/design/tests/reachability.json +0 -4
- package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +0 -23
- package/templates/recipe-arena-2d/files/scenes/main.pix3scene +0 -292
- package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +0 -100
- package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
- package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +0 -45
- package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +0 -85
- package/templates/recipe-arena-2d/files/scripts/GameRules.ts +0 -368
- package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +0 -106
- package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +0 -151
- package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +0 -94
- package/templates/recipe-arena-2d/files/scripts/Spawner.ts +0 -214
- package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +0 -174
- package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
- package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
- package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
- package/templates/recipe-arena-2d/template.yaml +0 -10
- package/templates/recipe-blank-2d/files/README.md +0 -61
- package/templates/recipe-blank-2d/files/design/recipe.md +0 -148
- package/templates/recipe-blank-2d/files/design/tests/reachability.json +0 -4
- package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +0 -27
- package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +0 -23
- package/templates/recipe-blank-2d/files/scenes/main.pix3scene +0 -224
- package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +0 -85
- package/templates/recipe-blank-2d/files/scripts/GameRules.ts +0 -350
- package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +0 -110
- package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
- package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
- package/templates/recipe-blank-2d/template.yaml +0 -10
- package/templates/recipe-bouncer-2d/files/README.md +0 -81
- package/templates/recipe-bouncer-2d/files/design/recipe.md +0 -146
- package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +0 -4
- package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +0 -23
- package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +0 -615
- package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +0 -191
- package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +0 -87
- package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +0 -298
- package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +0 -377
- package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +0 -109
- package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +0 -112
- package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +0 -94
- package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +0 -263
- package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +0 -307
- package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
- package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
- package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
- package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
- package/templates/recipe-bouncer-2d/template.yaml +0 -10
- package/templates/recipe-grid-3d/files/README.md +0 -22
- package/templates/recipe-grid-3d/files/design/recipe.md +0 -110
- package/templates/recipe-grid-3d/files/scenes/main.pix3scene +0 -169
- package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +0 -100
- package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +0 -105
- package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +0 -319
- package/templates/recipe-grid-3d/files/scripts/GridRules.ts +0 -278
- package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +0 -106
- package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +0 -94
- package/templates/recipe-grid-3d/template.yaml +0 -10
- package/templates/recipe-tapper-2d/files/README.md +0 -39
- package/templates/recipe-tapper-2d/files/design/recipe.md +0 -132
- package/templates/recipe-tapper-2d/files/design/tests/reachability.json +0 -4
- package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +0 -23
- package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +0 -269
- package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +0 -100
- package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
- package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +0 -45
- package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +0 -85
- package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +0 -368
- package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +0 -106
- package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +0 -94
- package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +0 -214
- package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +0 -192
- package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
- package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
- package/templates/recipe-tapper-2d/template.yaml +0 -10
|
@@ -1,263 +0,0 @@
|
|
|
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,307 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ball-collision — swept (continuous) circle physics. Pure math, no engine
|
|
3
|
-
* imports, so it is unit-testable and cheap to reason about.
|
|
4
|
-
*
|
|
5
|
-
* Why this exists: the engine has NO rigidbody solver, and `core:Hitbox2D` is an
|
|
6
|
-
* axis-aligned overlap test (rotation ignored). A ball that moves 900 px in a
|
|
7
|
-
* frame would tunnel straight through a wall under any discrete overlap test,
|
|
8
|
-
* and a rotated paddle/flipper cannot be described by an AABB at all. So the
|
|
9
|
-
* recipe carries its own: every collider is a SEGMENT (four per oriented box) or
|
|
10
|
-
* a CIRCLE, and each substep sweeps the ball's whole displacement against them,
|
|
11
|
-
* takes the earliest time of impact, reflects, and continues with what is left
|
|
12
|
-
* of the step. No amount of speed can skip a collider.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export interface SegmentCollider {
|
|
16
|
-
ax: number;
|
|
17
|
-
ay: number;
|
|
18
|
-
bx: number;
|
|
19
|
-
by: number;
|
|
20
|
-
/** 1 = perfect bounce, <1 absorbs, >1 kicks (bumpers). */
|
|
21
|
-
restitution: number;
|
|
22
|
-
/** Caller-defined tag, handed back on every hit. */
|
|
23
|
-
id: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface CircleCollider {
|
|
27
|
-
cx: number;
|
|
28
|
-
cy: number;
|
|
29
|
-
r: number;
|
|
30
|
-
restitution: number;
|
|
31
|
-
id: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface BallState {
|
|
35
|
-
x: number;
|
|
36
|
-
y: number;
|
|
37
|
-
vx: number;
|
|
38
|
-
vy: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface BallOptions {
|
|
42
|
-
radius: number;
|
|
43
|
-
/** Vertical acceleration in px/s² (negative pulls down — Y is up). */
|
|
44
|
-
gravity: number;
|
|
45
|
-
maxSpeed: number;
|
|
46
|
-
/** Re-energise the ball if it drops below this (0 = off). */
|
|
47
|
-
minSpeed: number;
|
|
48
|
-
/** Fraction of speed kept per second (1 = frictionless). */
|
|
49
|
-
damping: number;
|
|
50
|
-
/** Collision substeps per frame. More = more stable, never needed for tunneling. */
|
|
51
|
-
substeps: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface BallHit {
|
|
55
|
-
id: string;
|
|
56
|
-
x: number;
|
|
57
|
-
y: number;
|
|
58
|
-
/** Impact speed along the contact normal (always positive). */
|
|
59
|
-
speed: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface SweepHit {
|
|
63
|
-
t: number;
|
|
64
|
-
nx: number;
|
|
65
|
-
ny: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const EPS = 1e-9;
|
|
69
|
-
/** Pushed out this far along the normal after a hit so it cannot re-hit at t=0. */
|
|
70
|
-
const SKIN = 0.01;
|
|
71
|
-
const MAX_ITERATIONS = 6;
|
|
72
|
-
|
|
73
|
-
function closestPointOnSegment(
|
|
74
|
-
px: number,
|
|
75
|
-
py: number,
|
|
76
|
-
ax: number,
|
|
77
|
-
ay: number,
|
|
78
|
-
bx: number,
|
|
79
|
-
by: number
|
|
80
|
-
): { x: number; y: number } {
|
|
81
|
-
const ex = bx - ax;
|
|
82
|
-
const ey = by - ay;
|
|
83
|
-
const len2 = ex * ex + ey * ey;
|
|
84
|
-
if (len2 < EPS) {
|
|
85
|
-
return { x: ax, y: ay };
|
|
86
|
-
}
|
|
87
|
-
const s = Math.min(1, Math.max(0, ((px - ax) * ex + (py - ay) * ey) / len2));
|
|
88
|
-
return { x: ax + ex * s, y: ay + ey * s };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Sweep a moving circle against a static point (the endpoint caps of a segment). */
|
|
92
|
-
function sweepCirclePoint(
|
|
93
|
-
px: number,
|
|
94
|
-
py: number,
|
|
95
|
-
vx: number,
|
|
96
|
-
vy: number,
|
|
97
|
-
r: number,
|
|
98
|
-
cx: number,
|
|
99
|
-
cy: number
|
|
100
|
-
): SweepHit | null {
|
|
101
|
-
const mx = px - cx;
|
|
102
|
-
const my = py - cy;
|
|
103
|
-
const a = vx * vx + vy * vy;
|
|
104
|
-
if (a < EPS) {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
const b = 2 * (mx * vx + my * vy);
|
|
108
|
-
const c = mx * mx + my * my - r * r;
|
|
109
|
-
const disc = b * b - 4 * a * c;
|
|
110
|
-
if (disc < 0) {
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
const root = Math.sqrt(disc);
|
|
114
|
-
const t = (-b - root) / (2 * a);
|
|
115
|
-
if (t < 0 || t > 1) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
const hx = px + vx * t - cx;
|
|
119
|
-
const hy = py + vy * t - cy;
|
|
120
|
-
const len = Math.hypot(hx, hy) || 1;
|
|
121
|
-
return { t, nx: hx / len, ny: hy / len };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Sweep a moving circle against a static segment. Returns the approaching hit only. */
|
|
125
|
-
export function sweepCircleSegment(
|
|
126
|
-
px: number,
|
|
127
|
-
py: number,
|
|
128
|
-
vx: number,
|
|
129
|
-
vy: number,
|
|
130
|
-
r: number,
|
|
131
|
-
ax: number,
|
|
132
|
-
ay: number,
|
|
133
|
-
bx: number,
|
|
134
|
-
by: number
|
|
135
|
-
): SweepHit | null {
|
|
136
|
-
const ex = bx - ax;
|
|
137
|
-
const ey = by - ay;
|
|
138
|
-
const len2 = ex * ex + ey * ey;
|
|
139
|
-
if (len2 < EPS) {
|
|
140
|
-
return approaching(sweepCirclePoint(px, py, vx, vy, r, ax, ay), vx, vy);
|
|
141
|
-
}
|
|
142
|
-
const len = Math.sqrt(len2);
|
|
143
|
-
const nx = -ey / len;
|
|
144
|
-
const ny = ex / len;
|
|
145
|
-
const d0 = (px - ax) * nx + (py - ay) * ny;
|
|
146
|
-
const dv = vx * nx + vy * ny;
|
|
147
|
-
const side = d0 >= 0 ? 1 : -1;
|
|
148
|
-
|
|
149
|
-
// Face hit: the moment the centre reaches distance r from the infinite line,
|
|
150
|
-
// provided the contact point lies within the segment span.
|
|
151
|
-
if (dv * side < -EPS) {
|
|
152
|
-
const t = (side * r - d0) / dv;
|
|
153
|
-
if (t >= 0 && t <= 1) {
|
|
154
|
-
const hx = px + vx * t;
|
|
155
|
-
const hy = py + vy * t;
|
|
156
|
-
const s = ((hx - ax) * ex + (hy - ay) * ey) / len2;
|
|
157
|
-
if (s >= 0 && s <= 1) {
|
|
158
|
-
return approaching({ t, nx: nx * side, ny: ny * side }, vx, vy);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Otherwise the only possible contacts are the rounded endpoints.
|
|
164
|
-
const ha = sweepCirclePoint(px, py, vx, vy, r, ax, ay);
|
|
165
|
-
const hb = sweepCirclePoint(px, py, vx, vy, r, bx, by);
|
|
166
|
-
const best = ha && hb ? (ha.t <= hb.t ? ha : hb) : (ha ?? hb);
|
|
167
|
-
return approaching(best, vx, vy);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Sweep a moving circle against a static circle. */
|
|
171
|
-
export function sweepCircleCircle(
|
|
172
|
-
px: number,
|
|
173
|
-
py: number,
|
|
174
|
-
vx: number,
|
|
175
|
-
vy: number,
|
|
176
|
-
r: number,
|
|
177
|
-
cx: number,
|
|
178
|
-
cy: number,
|
|
179
|
-
cr: number
|
|
180
|
-
): SweepHit | null {
|
|
181
|
-
return approaching(sweepCirclePoint(px, py, vx, vy, r + cr, cx, cy), vx, vy);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Drop hits whose normal points along the motion (already separating). */
|
|
185
|
-
function approaching(hit: SweepHit | null, vx: number, vy: number): SweepHit | null {
|
|
186
|
-
if (!hit) {
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
return vx * hit.nx + vy * hit.ny < -EPS ? hit : null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Push the ball out of anything it already overlaps (authored overlaps, rounding). */
|
|
193
|
-
function depenetrate(
|
|
194
|
-
ball: BallState,
|
|
195
|
-
radius: number,
|
|
196
|
-
segments: readonly SegmentCollider[],
|
|
197
|
-
circles: readonly CircleCollider[]
|
|
198
|
-
): void {
|
|
199
|
-
for (const seg of segments) {
|
|
200
|
-
const p = closestPointOnSegment(ball.x, ball.y, seg.ax, seg.ay, seg.bx, seg.by);
|
|
201
|
-
const dx = ball.x - p.x;
|
|
202
|
-
const dy = ball.y - p.y;
|
|
203
|
-
const dist = Math.hypot(dx, dy);
|
|
204
|
-
if (dist < radius && dist > EPS) {
|
|
205
|
-
ball.x = p.x + (dx / dist) * (radius + SKIN);
|
|
206
|
-
ball.y = p.y + (dy / dist) * (radius + SKIN);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
for (const circle of circles) {
|
|
210
|
-
const dx = ball.x - circle.cx;
|
|
211
|
-
const dy = ball.y - circle.cy;
|
|
212
|
-
const dist = Math.hypot(dx, dy);
|
|
213
|
-
const min = radius + circle.r;
|
|
214
|
-
if (dist < min && dist > EPS) {
|
|
215
|
-
ball.x = circle.cx + (dx / dist) * (min + SKIN);
|
|
216
|
-
ball.y = circle.cy + (dy / dist) * (min + SKIN);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Advance the ball by `dt` seconds, resolving every collider continuously.
|
|
223
|
-
* Mutates `ball`; returns one entry per contact resolved this frame.
|
|
224
|
-
*/
|
|
225
|
-
export function stepBall(
|
|
226
|
-
ball: BallState,
|
|
227
|
-
options: BallOptions,
|
|
228
|
-
segments: readonly SegmentCollider[],
|
|
229
|
-
circles: readonly CircleCollider[],
|
|
230
|
-
dt: number
|
|
231
|
-
): BallHit[] {
|
|
232
|
-
const hits: BallHit[] = [];
|
|
233
|
-
if (dt <= 0) {
|
|
234
|
-
return hits;
|
|
235
|
-
}
|
|
236
|
-
const substeps = Math.max(1, Math.min(16, Math.round(options.substeps) || 1));
|
|
237
|
-
const radius = Math.max(0.001, options.radius);
|
|
238
|
-
const subDt = dt / substeps;
|
|
239
|
-
|
|
240
|
-
for (let step = 0; step < substeps; step++) {
|
|
241
|
-
ball.vy += options.gravity * subDt;
|
|
242
|
-
if (options.damping < 1) {
|
|
243
|
-
const keep = Math.pow(Math.max(0, options.damping), subDt);
|
|
244
|
-
ball.vx *= keep;
|
|
245
|
-
ball.vy *= keep;
|
|
246
|
-
}
|
|
247
|
-
clampSpeed(ball, options);
|
|
248
|
-
depenetrate(ball, radius, segments, circles);
|
|
249
|
-
|
|
250
|
-
let remaining = subDt;
|
|
251
|
-
for (let iteration = 0; iteration < MAX_ITERATIONS && remaining > EPS; iteration++) {
|
|
252
|
-
const dx = ball.vx * remaining;
|
|
253
|
-
const dy = ball.vy * remaining;
|
|
254
|
-
|
|
255
|
-
let best: SweepHit | null = null;
|
|
256
|
-
let bestId = '';
|
|
257
|
-
let bestRestitution = 1;
|
|
258
|
-
for (const seg of segments) {
|
|
259
|
-
const hit = sweepCircleSegment(ball.x, ball.y, dx, dy, radius, seg.ax, seg.ay, seg.bx, seg.by);
|
|
260
|
-
if (hit && (!best || hit.t < best.t)) {
|
|
261
|
-
best = hit;
|
|
262
|
-
bestId = seg.id;
|
|
263
|
-
bestRestitution = seg.restitution;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
for (const circle of circles) {
|
|
267
|
-
const hit = sweepCircleCircle(ball.x, ball.y, dx, dy, radius, circle.cx, circle.cy, circle.r);
|
|
268
|
-
if (hit && (!best || hit.t < best.t)) {
|
|
269
|
-
best = hit;
|
|
270
|
-
bestId = circle.id;
|
|
271
|
-
bestRestitution = circle.restitution;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (!best) {
|
|
276
|
-
ball.x += dx;
|
|
277
|
-
ball.y += dy;
|
|
278
|
-
remaining = 0;
|
|
279
|
-
break;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
ball.x += dx * best.t + best.nx * SKIN;
|
|
283
|
-
ball.y += dy * best.t + best.ny * SKIN;
|
|
284
|
-
|
|
285
|
-
const vn = ball.vx * best.nx + ball.vy * best.ny;
|
|
286
|
-
const impact = Math.abs(vn);
|
|
287
|
-
ball.vx -= (1 + bestRestitution) * vn * best.nx;
|
|
288
|
-
ball.vy -= (1 + bestRestitution) * vn * best.ny;
|
|
289
|
-
clampSpeed(ball, options);
|
|
290
|
-
|
|
291
|
-
hits.push({ id: bestId, x: ball.x, y: ball.y, speed: impact });
|
|
292
|
-
remaining *= 1 - best.t;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return hits;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function clampSpeed(ball: BallState, options: BallOptions): void {
|
|
299
|
-
const speed = Math.hypot(ball.vx, ball.vy);
|
|
300
|
-
if (options.maxSpeed > 0 && speed > options.maxSpeed) {
|
|
301
|
-
ball.vx = (ball.vx / speed) * options.maxSpeed;
|
|
302
|
-
ball.vy = (ball.vy / speed) * options.maxSpeed;
|
|
303
|
-
} else if (options.minSpeed > 0 && speed > EPS && speed < options.minSpeed) {
|
|
304
|
-
ball.vx = (ball.vx / speed) * options.minSpeed;
|
|
305
|
-
ball.vy = (ball.vy / speed) * options.minSpeed;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
id: recipe-bouncer-2d
|
|
2
|
-
title: Bouncer 2D
|
|
3
|
-
description: A ball under gravity bounces off walls, bumpers and a paddle you steer; hits score, a bottom drain costs a life. Continuous (swept) collision, so nothing tunnels at speed — arkanoid, pong, plinko, pinball.
|
|
4
|
-
projectType: 2d
|
|
5
|
-
targetPlatform: universal
|
|
6
|
-
viewport: { width: 1080, height: 1920 }
|
|
7
|
-
# The build/full-run entry scene (Project Settings → Default Export Scene Path).
|
|
8
|
-
# The menu boots first; PLAY transitions to main.pix3scene (the game).
|
|
9
|
-
entryScene: scenes/menu.pix3scene
|
|
10
|
-
order: 61
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# Grid 3D
|
|
2
|
-
|
|
3
|
-
A playable skeleton for tap-to-carve 3D puzzles: a solid block of cubes, a
|
|
4
|
-
perspective camera looking at it, and a tap that removes one cube. Some cubes
|
|
5
|
-
are core — hitting one costs a life. Clear every non-core cube to win.
|
|
6
|
-
|
|
7
|
-
It plays as shipped — press play in `scenes/main.pix3scene` before changing
|
|
8
|
-
anything, so you know what "working" looked like.
|
|
9
|
-
|
|
10
|
-
- `scenes/menu.pix3scene` — entry scene; PLAY transitions to the board.
|
|
11
|
-
- `scenes/main.pix3scene` — the board: camera, lights, `board-anchor`, HUD.
|
|
12
|
-
- `scenes/ui/result.pix3scene` — the end screen (RETRY / MENU), instanced into
|
|
13
|
-
main hidden (`visible: false` is editor-only; `GridRules` shows it at run time).
|
|
14
|
-
- `scripts/GridBoard.ts` — builds the cubes and turns a tap into a removal.
|
|
15
|
-
- `scripts/GridRules.ts` — score, lives, win/lose, the debug provider.
|
|
16
|
-
- `scripts/ScoreHud.ts`, `scripts/MenuFlow.ts` — display and menu, shared with
|
|
17
|
-
the other recipes.
|
|
18
|
-
|
|
19
|
-
The cubes are generated, not authored: board size lives in `GridBoard`'s config
|
|
20
|
-
(`sizeX/sizeY/sizeZ`), so a bigger block is one number, not a scene edit.
|
|
21
|
-
|
|
22
|
-
Read `design/recipe.md` for the node map, the tunables and what not to touch.
|