blockyard 0.0.1 → 0.0.9
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/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
// BLOCKANOID, the rules (operator, 2026-09-12: "Take blockout, and make rip off of Arkanoid using
|
|
2
|
+
// our engine, and make it a new Diversion called 'Blockanoid'").
|
|
3
|
+
//
|
|
4
|
+
// Like breakout.js and tetris.js, this file is the RULES and nothing else: no DOM, no canvas, no
|
|
5
|
+
// clock, and NO Math.random. Arkanoid lives or dies on its capsules, and a capsule that fell at
|
|
6
|
+
// random could not be tested at all -- so which brick carries one is a pure hash of that brick's
|
|
7
|
+
// id and the level. The same wall always drops the same capsules, and a test can say which.
|
|
8
|
+
//
|
|
9
|
+
// Coordinates follow the engine's board: x runs left to right, y runs UP from row 0 on the floor.
|
|
10
|
+
// Vaus sits near the floor, the wall is at the top, so the ball travels toward +y to break
|
|
11
|
+
// something -- the renderer's language, as in breakout.js.
|
|
12
|
+
//
|
|
13
|
+
// What this takes from Arkanoid that Breakout has not got:
|
|
14
|
+
// - a different WALL every level, not the same six rows again
|
|
15
|
+
// - SILVER bricks that take two hits (and more as the levels climb) and GOLD that never breaks
|
|
16
|
+
// - CAPSULES that fall out of broken bricks: laser, enlarge, catch, slow, disrupt, player, break
|
|
17
|
+
// - three balls at once, a bat that shoots, and a bat that catches
|
|
18
|
+
// - the DOH minions drifting down the court to spoil your aim
|
|
19
|
+
|
|
20
|
+
export const COLS = 13;
|
|
21
|
+
export const ROWS = 26;
|
|
22
|
+
|
|
23
|
+
export const PADDLE_Y = 1;
|
|
24
|
+
export const PADDLE_D = 1; // the bat's DEPTH across the board (see breakout.js: not its height)
|
|
25
|
+
export const PADDLE_H = 0.55; // how tall the bat's stones STAND
|
|
26
|
+
export const PADDLE_W = 3; // Vaus is narrower than a Breakout bat
|
|
27
|
+
export const PADDLE_W_WIDE = 5; // ... until the E capsule
|
|
28
|
+
export const BALL_S = 0.8;
|
|
29
|
+
export const BALL_R = BALL_S / 2;
|
|
30
|
+
export const LIVES = 3;
|
|
31
|
+
|
|
32
|
+
export const BRICK_TOP = ROWS - 4;
|
|
33
|
+
|
|
34
|
+
// ------------------------------------------------------------------ the bricks
|
|
35
|
+
// A colour is worth what it is worth, as in the arcade: the dearer the colour the higher it sits.
|
|
36
|
+
export const BRICK = Object.freeze({
|
|
37
|
+
w: Object.freeze({ color: '#e8eefc', points: 50 }),
|
|
38
|
+
o: Object.freeze({ color: '#f7931a', points: 60 }),
|
|
39
|
+
c: Object.freeze({ color: '#3ec9ff', points: 70 }),
|
|
40
|
+
g: Object.freeze({ color: '#2ecc8f', points: 80 }),
|
|
41
|
+
r: Object.freeze({ color: '#ef5a5a', points: 90 }),
|
|
42
|
+
b: Object.freeze({ color: '#4d7cff', points: 100 }),
|
|
43
|
+
p: Object.freeze({ color: '#b07cff', points: 110 }),
|
|
44
|
+
y: Object.freeze({ color: '#f5d142', points: 120 }),
|
|
45
|
+
});
|
|
46
|
+
export const SILVER = Object.freeze({ color: '#b9c4d4', points: 50 });
|
|
47
|
+
export const GOLD = Object.freeze({ color: '#d9a441', points: 0 });
|
|
48
|
+
|
|
49
|
+
// Six walls. '.' is empty, a letter is that colour, S is silver, G is gold. Written TOP ROW FIRST,
|
|
50
|
+
// the way you look at it; layout() flips them onto the engine's y-up board.
|
|
51
|
+
export const LEVELS = Object.freeze([
|
|
52
|
+
Object.freeze(['wwwwwwwwwwwww', 'ooooooooooooo', 'ccccccccccccc', 'ggggggggggggg', 'rrrrrrrrrrrrr', 'bbbbbbbbbbbbb']),
|
|
53
|
+
Object.freeze(['.SSSSSSSSSSS.', '.G.........G.', '.rrrrrrrrrrr.', '.ooooooooooo.', '.yyyyyyyyyyy.', '.ccccccccccc.']),
|
|
54
|
+
Object.freeze(['r.r.r.r.r.r.r', '.o.o.o.o.o.o.', 'y.y.y.y.y.y.y', '.g.g.g.g.g.g.', 'S.S.S.S.S.S.S', '.b.b.b.b.b.b.']),
|
|
55
|
+
Object.freeze(['......G......', '.....yyy.....', '....ooooo....', '...rrrrrrr...', '..bbbbbbbbb..', '.SSSSSSSSSSS.']),
|
|
56
|
+
Object.freeze(['GGGGGGGGGGGGG', 'G...........G', 'G.SSSSSSSSS.G', 'G.S.ppppp.S.G', 'G.S.pyyyp.S.G', 'G.SSSSSSSSS.G']),
|
|
57
|
+
Object.freeze(['S.G.S.G.S.G.S', '.wwwwwwwwwww.', '.w.c.c.c.c.w.', '.w.ccccccc.w.', '.wwwwwwwwwww.', 'S.G.S.G.S.G.S']),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// A BRICK THAT SURVIVES A HIT SHOWS IT (operator, 2026-09-12: "The blocks that take more than 1
|
|
61
|
+
// hit to break, need to start darker in color. Every successive hit ... makes it lighter colored,
|
|
62
|
+
// signalling it's closer to breaking. When it's the lightest color it can be, it will signal the
|
|
63
|
+
// next impact will break that block").
|
|
64
|
+
//
|
|
65
|
+
// The ramp is anchored at both ends so the LAST step is unmistakable: fresh is DAMAGE_DARK, and
|
|
66
|
+
// one-hit-from-breaking is DAMAGE_LIGHT, whatever the durability happens to be. A brick with three
|
|
67
|
+
// lives and a brick with five both end on the same bright colour, so "next one does it" is a
|
|
68
|
+
// colour you learn once rather than a shade you have to compare against its neighbours.
|
|
69
|
+
export const DAMAGE_DARK = '#4a5361';
|
|
70
|
+
export const DAMAGE_LIGHT = '#f2f7ff';
|
|
71
|
+
|
|
72
|
+
const hex2 = (h, i) => parseInt(h.slice(i, i + 2), 16);
|
|
73
|
+
/** Mix two six-digit hexes, t = 0 gives `a`, t = 1 gives `b`. Pure, so the ramp is assertable. */
|
|
74
|
+
export function mixHex(a, b, t) {
|
|
75
|
+
const k = Math.max(0, Math.min(1, t));
|
|
76
|
+
const ch = (i) => Math.round(hex2(a.replace('#', ''), i) + (hex2(b.replace('#', ''), i) - hex2(a.replace('#', ''), i)) * k);
|
|
77
|
+
return `#${[ch(0), ch(2), ch(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The colour of a brick with `left` hits remaining out of `max`. Dark when fresh, lightest when one
|
|
82
|
+
* hit remains. A single-hit brick keeps its own colour -- there is no wear to show.
|
|
83
|
+
*/
|
|
84
|
+
export function damageShade(left, max, base) {
|
|
85
|
+
if (!Number.isFinite(max) || max <= 1) return base;
|
|
86
|
+
const p = (max - Math.max(1, left)) / (max - 1); // 0 fresh .. 1 about to break
|
|
87
|
+
return mixHex(DAMAGE_DARK, DAMAGE_LIGHT, p);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How many hits a silver brick takes at this level: two, and one more every four levels. */
|
|
91
|
+
export function silverHits(level) { return 2 + Math.floor(Math.max(0, level - 1) / 4); }
|
|
92
|
+
|
|
93
|
+
// ------------------------------------------------------------------ the capsules
|
|
94
|
+
// One capsule at a time is the arcade's rule and it matters: it is what makes "do I take it?" a
|
|
95
|
+
// decision rather than a reflex.
|
|
96
|
+
// `break` (skip the wall) was removed on the operator's word, 2026-09-12. It was the one capsule
|
|
97
|
+
// that took the game away from you: clearing a wall you had not cleared is a reward for catching a
|
|
98
|
+
// pill rather than for playing, and it made the level you were in the middle of pointless.
|
|
99
|
+
export const CAPSULES = Object.freeze(['laser', 'enlarge', 'catch', 'slow', 'disrupt', 'player']);
|
|
100
|
+
export const CAPSULE_COLOR = Object.freeze({
|
|
101
|
+
laser: '#ef5a5a', enlarge: '#4d7cff', catch: '#2ecc8f', slow: '#f7931a',
|
|
102
|
+
disrupt: '#3ec9ff', player: '#b07cff',
|
|
103
|
+
});
|
|
104
|
+
// EACH CAPSULE HAS ITS OWN SILHOUETTE, not just its own colour (operator, 2026-09-12: "Blockanoid
|
|
105
|
+
// needs unique block graphics and styling for each of the different powerups that drop down").
|
|
106
|
+
// Colour alone is a poor signal here: the capsule is small, it is moving, and roughly one player in
|
|
107
|
+
// twelve cannot separate the red from the green at all. So SHAPE carries the meaning and colour
|
|
108
|
+
// only reinforces it -- the seven differ in part count, height and profile, and stay readable in
|
|
109
|
+
// monochrome. `trim` is the glyph sitting on the body.
|
|
110
|
+
export const CAPSULE_TRIM = Object.freeze({
|
|
111
|
+
laser: '#ffd0c4', enlarge: '#cfe0ff', catch: '#d8ffe9', slow: '#ffe0b0',
|
|
112
|
+
disrupt: '#d6f4ff', player: '#ecdcff',
|
|
113
|
+
});
|
|
114
|
+
export const CAPSULE_LETTER = Object.freeze({
|
|
115
|
+
laser: 'L', enlarge: 'E', catch: 'C', slow: 'S', disrupt: 'D', player: 'P',
|
|
116
|
+
});
|
|
117
|
+
export const CAPSULE_POINTS = 1000;
|
|
118
|
+
const CAPSULE_FALL = 5; // grid units a second
|
|
119
|
+
// DRAWN SIZE AND HIT BOX ARE TWO DIFFERENT MEASUREMENTS (operator, 2026-09-12: "the blockanoid
|
|
120
|
+
// minions and pills need to be AT LEAST 4 times larger ... shrink the bounding box if necessary to
|
|
121
|
+
// compensate for visual"). They used to be one number, which is why they could not both be right: a
|
|
122
|
+
// capsule big enough to read across a room is a capsule you cannot miss, and a minion that size
|
|
123
|
+
// could never thread a gap in the wall. So the sprite is large and the box it collides with is not.
|
|
124
|
+
export const CAPSULE_S = 2.16; // DRAWN: 2.4x the old 0.9 (4x was a bit large -- operator)
|
|
125
|
+
export const CAPSULE_BOX = 1.9; // CAUGHT: generous, but you can still miss it
|
|
126
|
+
const CAPSULE_ODDS = 0.28; // of a broken brick
|
|
127
|
+
|
|
128
|
+
const BASE_SPEED = 12;
|
|
129
|
+
const LEVEL_SPEEDUP = 1.06;
|
|
130
|
+
const SLOW_FACTOR = 0.68;
|
|
131
|
+
const MAX_BOUNCE = 1.05;
|
|
132
|
+
const MAX_STEP = 0.3;
|
|
133
|
+
const LASER_SPEED = 26;
|
|
134
|
+
const LASER_COOLDOWN = 260; // ms between shots
|
|
135
|
+
// CATCH RUNS OUT (operator, 2026-09-12: "The catch shouldnt last forever. Just 30 seconds max").
|
|
136
|
+
// Held for ever it stops being a power-up and becomes a different game -- you can park the ball and
|
|
137
|
+
// aim every single shot, which removes the rally. Thirty seconds is long enough to set up the shots
|
|
138
|
+
// you caught it for. Counted in ms off `step`'s own dtMs, like the laser cooldown and the minion
|
|
139
|
+
// timer: the rules still have no clock of their own, so a run stays reproducible.
|
|
140
|
+
// NOTHING LASTS FOR EVER (operator, 2026-09-12: "All the powerups need 30 second timers max for
|
|
141
|
+
// each. they are not infinite"). Four of the six are STATES and so can expire; disrupt and the
|
|
142
|
+
// extra life are one-shot and have nothing to run down. One table and one expiry loop, because
|
|
143
|
+
// four hand-written countdowns are four chances to drift apart -- the same reason loseLife() exists.
|
|
144
|
+
const POWER_MS = 30_000;
|
|
145
|
+
export const TIMED_POWERS = Object.freeze(['laser', 'catch', 'slow', 'wide']);
|
|
146
|
+
// Slower than before: a minion is now looking for a way through rather than falling past you.
|
|
147
|
+
const ENEMY_SPEED = 1.25; // descent, grid units a second
|
|
148
|
+
const ENEMY_SIDE = 2.4; // sideways, while feeling along the wall for a gap
|
|
149
|
+
const ENEMY_EVERY = 9000; // ms between arrivals while a level runs
|
|
150
|
+
const ENEMY_POINTS = 200;
|
|
151
|
+
export const ENEMY_S = 2.06; // DRAWN: 2.4x the old 0.86 (4x was a bit large -- operator, 2026-09-12)
|
|
152
|
+
// The hit box must fit the gaps it has to navigate. Measured on the shipped walls: levels 3 and 6
|
|
153
|
+
// leave one-unit gaps, so anything wider than a unit could never pass and would sit at the top
|
|
154
|
+
// for ever. This is also the box the ball and the laser must hit, and the box that costs a life.
|
|
155
|
+
export const ENEMY_BOX = 0.92;
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------- the minions
|
|
158
|
+
// Arkanoid's minions were solids that tumbled down the court -- a cone, a cube, a sphere, a
|
|
159
|
+
// molecule -- so these are shapes first and behaviours second. Each is an outline in UNIT SPACE
|
|
160
|
+
// (-0.5..0.5, scaled by the tile), a colour, a pair of eye positions, and a DRIFT: how it moves
|
|
161
|
+
// sideways as it falls. They differ in silhouette as well as colour, for the same reason the
|
|
162
|
+
// capsules do -- a small moving object is read by its shape.
|
|
163
|
+
export const MINIONS = Object.freeze({
|
|
164
|
+
// a squat cone, swinging widely: the one that gets in the way
|
|
165
|
+
cone: Object.freeze({
|
|
166
|
+
color: '#7de3c8', spin: 1.7, drift: 'swing',
|
|
167
|
+
poly: Object.freeze([[0, -0.46], [0.40, 0.34], [0.22, 0.46], [-0.22, 0.46], [-0.40, 0.34]]),
|
|
168
|
+
eyes: Object.freeze([[-0.13, 0.02, 0.075], [0.13, 0.02, 0.075]]),
|
|
169
|
+
}),
|
|
170
|
+
// a tumbling cube, turning fast and falling nearly straight
|
|
171
|
+
cube: Object.freeze({
|
|
172
|
+
color: '#ffb066', spin: 3.1, drift: 'straight',
|
|
173
|
+
poly: Object.freeze([[-0.40, -0.40], [0.40, -0.40], [0.40, 0.40], [-0.40, 0.40]]),
|
|
174
|
+
eyes: Object.freeze([[-0.14, -0.04, 0.07], [0.14, -0.04, 0.07]]),
|
|
175
|
+
}),
|
|
176
|
+
// a many-sided ball that wobbles as it sinks
|
|
177
|
+
orb: Object.freeze({
|
|
178
|
+
color: '#c79bff', spin: 0.9, drift: 'wobble',
|
|
179
|
+
poly: Object.freeze([[-0.20, -0.44], [0.20, -0.44], [0.44, -0.20], [0.44, 0.20],
|
|
180
|
+
[0.20, 0.44], [-0.20, 0.44], [-0.44, 0.20], [-0.44, -0.20]]),
|
|
181
|
+
eyes: Object.freeze([[-0.15, -0.05, 0.08], [0.15, -0.05, 0.08]]),
|
|
182
|
+
}),
|
|
183
|
+
// a four-armed molecule, drifting to one side and back
|
|
184
|
+
molecule: Object.freeze({
|
|
185
|
+
color: '#ff8fa3', spin: 2.2, drift: 'zigzag',
|
|
186
|
+
poly: Object.freeze([[0, -0.48], [0.17, -0.17], [0.48, 0], [0.17, 0.17],
|
|
187
|
+
[0, 0.48], [-0.17, 0.17], [-0.48, 0], [-0.17, -0.17]]),
|
|
188
|
+
eyes: Object.freeze([[-0.11, 0, 0.065], [0.11, 0, 0.065]]),
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
export const MINION_KINDS = Object.freeze(Object.keys(MINIONS));
|
|
192
|
+
|
|
193
|
+
/** How far sideways a minion of this kind has drifted, in grid units a second. Pure. */
|
|
194
|
+
export function minionDrift(kind, phase, t) {
|
|
195
|
+
switch (MINIONS[kind]?.drift) {
|
|
196
|
+
case 'swing': return Math.sin(phase + t * 2.2) * 3.0;
|
|
197
|
+
case 'zigzag': return (((phase + t * 0.9) % 1) < 0.5 ? 1 : -1) * 1.8;
|
|
198
|
+
case 'wobble': return Math.sin(phase + t * 5.0) * 1.1;
|
|
199
|
+
default: return Math.sin(phase + t * 0.7) * 0.35;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const PADDLE_COLOR = '#3ec9ff';
|
|
204
|
+
export const PADDLE_LASER_COLOR = '#ef5a5a';
|
|
205
|
+
export const BALL_COLOR = '#f2f7ff';
|
|
206
|
+
// A LASER EMITS; IT DOES NOT REFLECT (operator, 2026-09-12: "The lasers need to be much brighter.
|
|
207
|
+
// They are too dark to be visible"). A bolt used to be an ordinary cube, so the engine lit it like
|
|
208
|
+
// masonry: measured, its darkest side came out at luminance 70 and its unlit walls at 84, from a
|
|
209
|
+
// declared colour of 167. And a bolt is a sliver -- under the oblique camera it is almost entirely
|
|
210
|
+
// SIDE face, which is the darkest surface a cube has. It was being shaded into the background.
|
|
211
|
+
//
|
|
212
|
+
// Drawn the way the neon finish draws a lit tube instead: a wide translucent halo and a hot thin
|
|
213
|
+
// core, both `always: true` so they survive the edges switch, with the colour lifted toward white
|
|
214
|
+
// rather than shaded away from it.
|
|
215
|
+
export const BOLT_COLOR = '#fff0a8'; // the beam: hot, near-white yellow
|
|
216
|
+
export const BOLT_GLOW = '#ff5a3c'; // the halo around it
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A stable 0..1 from a string. The same brick on the same level always yields the same number, so
|
|
220
|
+
* "which bricks carry capsules" is a property of the wall rather than of the day you played it.
|
|
221
|
+
* (The mix is the same shape as blockscene3d's fxHash: cheap, and well spread for short keys.)
|
|
222
|
+
*/
|
|
223
|
+
export function hash01(key) {
|
|
224
|
+
let h = 2166136261;
|
|
225
|
+
const s = String(key);
|
|
226
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
227
|
+
h ^= h >>> 13; h = Math.imul(h, 1274126177); h ^= h >>> 16;
|
|
228
|
+
return (h >>> 0) / 4294967296;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The capsule this brick carries, or null. Pure: the wall decides, not the moment. */
|
|
232
|
+
export function capsuleFor(brick, level) {
|
|
233
|
+
const roll = hash01(`cap:${level}:${brick.id}:${brick.x},${brick.y}`);
|
|
234
|
+
if (roll >= CAPSULE_ODDS) return null;
|
|
235
|
+
const pick = hash01(`kind:${level}:${brick.id}`);
|
|
236
|
+
return CAPSULES[Math.min(CAPSULES.length - 1, Math.floor(pick * CAPSULES.length))];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function speed(level) { return BASE_SPEED * Math.pow(LEVEL_SPEEDUP, Math.max(0, level - 1)); }
|
|
240
|
+
|
|
241
|
+
/** The wall for a level; levels past the last one cycle round, harder silver each time. */
|
|
242
|
+
export function layoutFor(level) { return LEVELS[(Math.max(1, level) - 1) % LEVELS.length]; }
|
|
243
|
+
|
|
244
|
+
function layout(g) {
|
|
245
|
+
g.bricks = [];
|
|
246
|
+
const rows = layoutFor(g.level);
|
|
247
|
+
const hits = silverHits(g.level);
|
|
248
|
+
for (let r = 0; r < rows.length; r++) {
|
|
249
|
+
const line = rows[r];
|
|
250
|
+
const y = BRICK_TOP - r; // row 0 of the layout is the TOP row on screen
|
|
251
|
+
for (let x = 0; x < Math.min(COLS, line.length); x++) {
|
|
252
|
+
const ch = line[x];
|
|
253
|
+
if (ch === '.') continue;
|
|
254
|
+
if (ch === 'G') g.bricks.push({ id: g.nextId++, x, y, kind: 'gold', color: GOLD.color, points: 0, hits: Infinity, maxHits: Infinity });
|
|
255
|
+
else if (ch === 'S') g.bricks.push({ id: g.nextId++, x, y, kind: 'silver', color: SILVER.color, points: SILVER.points * g.level, hits, maxHits: hits });
|
|
256
|
+
else {
|
|
257
|
+
const spec = BRICK[ch];
|
|
258
|
+
if (!spec) continue;
|
|
259
|
+
g.bricks.push({ id: g.nextId++, x, y, kind: 'plain', color: spec.color, points: spec.points, hits: 1, maxHits: 1 });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
g.breakableAtStart = breakable(g).length;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The bricks that can actually be destroyed -- gold is scenery, and never blocks a level. */
|
|
267
|
+
export function breakable(g) { return g.bricks.filter((b) => b.kind !== 'gold'); }
|
|
268
|
+
|
|
269
|
+
export function resetBall(g) {
|
|
270
|
+
// `hold` is the grip: how far along the bat the ball sits. A stuck ball is placed FROM it.
|
|
271
|
+
g.balls = [{ x: g.paddle.x + g.paddle.w / 2, y: PADDLE_Y + PADDLE_D + BALL_R, vx: 0, vy: 0, r: BALL_R, stuck: true, hold: g.paddle.w / 2 }];
|
|
272
|
+
return g.balls[0];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Vaus back to stock: normal width, no laser, no catch, no slow. */
|
|
276
|
+
export function resetVaus(g) {
|
|
277
|
+
g.paddle.w = PADDLE_W;
|
|
278
|
+
g.laser = false;
|
|
279
|
+
g.catch = false;
|
|
280
|
+
g.slow = false;
|
|
281
|
+
g.cooldown = 0;
|
|
282
|
+
// a stale countdown must not survive into the next ball
|
|
283
|
+
g.timers = { laser: 0, catch: 0, slow: 0, wide: 0 };
|
|
284
|
+
g.paddle.x = Math.max(0, Math.min(COLS - g.paddle.w, g.paddle.x));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The two switches that change the RULES rather than the look, so they live on the game and not in
|
|
289
|
+
* the renderer's options. A setting nothing reads is a dead control; these are read in capsuleFor's
|
|
290
|
+
* caller and in the minions' arrival, which is the only place they can bite.
|
|
291
|
+
*/
|
|
292
|
+
export function setOptions(g, opts = {}) {
|
|
293
|
+
g.opts = { capsules: opts.capsules !== false, enemies: opts.enemies !== false };
|
|
294
|
+
if (!g.opts.capsules) g.capsules = [];
|
|
295
|
+
if (!g.opts.enemies) g.enemies = [];
|
|
296
|
+
return g.opts;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function newGame(level = 1, opts = {}) {
|
|
300
|
+
const g = {
|
|
301
|
+
cols: COLS, rows: ROWS,
|
|
302
|
+
level, score: 0, lives: LIVES, over: false, cleared: false,
|
|
303
|
+
paddle: { x: (COLS - PADDLE_W) / 2, w: PADDLE_W },
|
|
304
|
+
balls: [], bricks: [], capsules: [], bolts: [], enemies: [],
|
|
305
|
+
laser: false, catch: false, slow: false, cooldown: 0,
|
|
306
|
+
timers: { laser: 0, catch: 0, slow: 0, wide: 0 },
|
|
307
|
+
sinceEnemy: 0, nextId: 1, breakableAtStart: 0,
|
|
308
|
+
opts: { capsules: true, enemies: true },
|
|
309
|
+
};
|
|
310
|
+
setOptions(g, opts);
|
|
311
|
+
layout(g);
|
|
312
|
+
resetBall(g);
|
|
313
|
+
return g;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The next wall: a new layout, a faster ball, Vaus back to stock, the score and lives carried. */
|
|
317
|
+
export function advance(g) {
|
|
318
|
+
g.level++;
|
|
319
|
+
g.cleared = false;
|
|
320
|
+
g.capsules = []; g.bolts = []; g.enemies = []; g.sinceEnemy = 0;
|
|
321
|
+
layout(g);
|
|
322
|
+
resetVaus(g);
|
|
323
|
+
resetBall(g);
|
|
324
|
+
return g;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function movePaddle(g, centreX) {
|
|
328
|
+
if (g.over) return g.paddle.x;
|
|
329
|
+
const half = g.paddle.w / 2;
|
|
330
|
+
g.paddle.x = Math.max(0, Math.min(COLS - g.paddle.w, centreX - half));
|
|
331
|
+
// LOCKED TO THE BAT, not merely kept inside it (operator, 2026-09-12: "the ball needs to stick to
|
|
332
|
+
// the paddle location when it lands ... It should not shift around while the paddle moves beneath
|
|
333
|
+
// it"). This used to CLAMP a stuck ball into the bat's span, so the ball held its absolute place
|
|
334
|
+
// while the bat slid underneath and only moved when an edge caught up with it -- which is exactly
|
|
335
|
+
// the drift that was reported. It is placed from its grip instead.
|
|
336
|
+
for (const b of g.balls) {
|
|
337
|
+
if (!b.stuck) continue;
|
|
338
|
+
if (b.hold == null) b.hold = b.x - g.paddle.x;
|
|
339
|
+
b.hold = Math.max(0, Math.min(g.paddle.w, b.hold));
|
|
340
|
+
b.x = g.paddle.x + b.hold;
|
|
341
|
+
}
|
|
342
|
+
return g.paddle.x;
|
|
343
|
+
}
|
|
344
|
+
export function nudge(g, dx) { return movePaddle(g, g.paddle.x + g.paddle.w / 2 + dx); }
|
|
345
|
+
|
|
346
|
+
/** Send every waiting ball on its way. The angle is the caller's, never a roll. */
|
|
347
|
+
export function launch(g, angle = 0.3) {
|
|
348
|
+
if (g.over) return false;
|
|
349
|
+
const waiting = g.balls.filter((b) => b.stuck);
|
|
350
|
+
if (!waiting.length) return false;
|
|
351
|
+
const s = speed(g.level) * (g.slow ? SLOW_FACTOR : 1);
|
|
352
|
+
const a = Math.max(-MAX_BOUNCE, Math.min(MAX_BOUNCE, angle));
|
|
353
|
+
for (const b of waiting) { b.vx = Math.sin(a) * s; b.vy = Math.cos(a) * s; b.stuck = false; }
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Fire, if the laser is up and it has cooled. Returns the bolts created. */
|
|
358
|
+
export function fire(g) {
|
|
359
|
+
if (g.over || !g.laser || g.cooldown > 0) return [];
|
|
360
|
+
const p = g.paddle;
|
|
361
|
+
const made = [
|
|
362
|
+
{ id: g.nextId++, x: p.x + 0.3, y: PADDLE_Y + PADDLE_D },
|
|
363
|
+
{ id: g.nextId++, x: p.x + p.w - 0.3, y: PADDLE_Y + PADDLE_D },
|
|
364
|
+
];
|
|
365
|
+
g.bolts.push(...made);
|
|
366
|
+
g.cooldown = LASER_COOLDOWN;
|
|
367
|
+
return made;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function offPaddle(g, b) {
|
|
371
|
+
const p = g.paddle;
|
|
372
|
+
const off = Math.max(-1, Math.min(1, (b.x - (p.x + p.w / 2)) / (p.w / 2)));
|
|
373
|
+
const s = Math.hypot(b.vx, b.vy) || speed(g.level);
|
|
374
|
+
const a = off * MAX_BOUNCE;
|
|
375
|
+
b.vx = Math.sin(a) * s;
|
|
376
|
+
b.vy = Math.abs(Math.cos(a) * s);
|
|
377
|
+
b.y = PADDLE_Y + PADDLE_D + b.r;
|
|
378
|
+
// caught: remember WHERE on the bat, so it rides along rather than being shoved by an edge
|
|
379
|
+
if (g.catch) { b.stuck = true; b.vx = 0; b.vy = 0; b.hold = Math.max(0, Math.min(p.w, b.x - p.x)); }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Take a capsule. Each one is a different promise, so each is written out rather than table-driven. */
|
|
383
|
+
export function applyCapsule(g, kind) {
|
|
384
|
+
g.score += CAPSULE_POINTS;
|
|
385
|
+
switch (kind) {
|
|
386
|
+
case 'laser': g.laser = true; g.timers.laser = POWER_MS; g.catch = false; g.timers.catch = 0; break;
|
|
387
|
+
case 'enlarge': {
|
|
388
|
+
const mid = g.paddle.x + g.paddle.w / 2;
|
|
389
|
+
g.paddle.w = PADDLE_W_WIDE;
|
|
390
|
+
g.paddle.x = Math.max(0, Math.min(COLS - g.paddle.w, mid - g.paddle.w / 2));
|
|
391
|
+
g.timers.wide = POWER_MS;
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
case 'catch': g.catch = true; g.timers.catch = POWER_MS; g.laser = false; g.timers.laser = 0; break;
|
|
395
|
+
case 'slow': {
|
|
396
|
+
// only slow what is not already slowed, or two capsules would compound into a crawl
|
|
397
|
+
if (!g.slow) for (const b of g.balls) if (!b.stuck) { b.vx *= SLOW_FACTOR; b.vy *= SLOW_FACTOR; }
|
|
398
|
+
g.slow = true;
|
|
399
|
+
g.timers.slow = POWER_MS;
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
case 'disrupt': {
|
|
403
|
+
// three on the court. The extra two leave at a fixed spread off the first, so a disrupted
|
|
404
|
+
// rally is still a rally a test can follow.
|
|
405
|
+
const live = g.balls.filter((b) => !b.stuck);
|
|
406
|
+
const from = live[0] ?? g.balls[0];
|
|
407
|
+
if (from && !from.stuck) {
|
|
408
|
+
for (const turn of [0.45, -0.45]) {
|
|
409
|
+
const c = Math.cos(turn), s = Math.sin(turn);
|
|
410
|
+
g.balls.push({ x: from.x, y: from.y, vx: from.vx * c - from.vy * s, vy: from.vx * s + from.vy * c, r: BALL_R, stuck: false });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case 'player': g.lives += 1; break;
|
|
416
|
+
default: break;
|
|
417
|
+
}
|
|
418
|
+
return g;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function hitBrick(g, b, hits) {
|
|
422
|
+
for (let i = 0; i < g.bricks.length; i++) {
|
|
423
|
+
const k = g.bricks[i];
|
|
424
|
+
const ox = Math.min(b.x + b.r, k.x + 1) - Math.max(b.x - b.r, k.x);
|
|
425
|
+
const oy = Math.min(b.y + b.r, k.y + 1) - Math.max(b.y - b.r, k.y);
|
|
426
|
+
if (ox <= 0 || oy <= 0) continue;
|
|
427
|
+
if (ox < oy) { b.vx = b.x < k.x + 0.5 ? -Math.abs(b.vx) : Math.abs(b.vx); b.x += b.vx > 0 ? ox : -ox; }
|
|
428
|
+
else { b.vy = b.y < k.y + 0.5 ? -Math.abs(b.vy) : Math.abs(b.vy); b.y += b.vy > 0 ? oy : -oy; }
|
|
429
|
+
damage(g, k, i, hits);
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** One hit on a brick. Gold rings and stands; silver counts down; anything else goes. */
|
|
436
|
+
function damage(g, k, i, hits) {
|
|
437
|
+
if (k.kind === 'gold') { hits.push({ kind: 'gold', x: k.x, y: k.y, color: k.color }); return false; }
|
|
438
|
+
k.hits -= 1;
|
|
439
|
+
if (k.hits > 0) { hits.push({ kind: 'silver', x: k.x, y: k.y, color: k.color }); return false; }
|
|
440
|
+
g.bricks.splice(i, 1);
|
|
441
|
+
g.score += k.points;
|
|
442
|
+
hits.push({ kind: 'brick', x: k.x, y: k.y, color: k.color, points: k.points, brickKind: k.kind });
|
|
443
|
+
// one capsule on the court at a time, exactly as the arcade does it
|
|
444
|
+
const cap = g.opts?.capsules === false ? null : capsuleFor(k, g.level);
|
|
445
|
+
if (cap && !g.capsules.length) {
|
|
446
|
+
g.capsules.push({ id: g.nextId++, kind: cap, x: k.x, y: k.y });
|
|
447
|
+
hits.push({ kind: 'capsuleout', capsule: cap, x: k.x, y: k.y });
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function moveCapsules(g, dt, hits) {
|
|
453
|
+
const p = g.paddle;
|
|
454
|
+
for (let i = g.capsules.length - 1; i >= 0; i--) {
|
|
455
|
+
const c = g.capsules[i];
|
|
456
|
+
c.y -= CAPSULE_FALL * dt;
|
|
457
|
+
const caught = c.y <= PADDLE_Y + PADDLE_D && c.y + CAPSULE_BOX >= PADDLE_Y
|
|
458
|
+
&& c.x + CAPSULE_BOX >= p.x && c.x <= p.x + p.w;
|
|
459
|
+
if (caught) {
|
|
460
|
+
g.capsules.splice(i, 1);
|
|
461
|
+
applyCapsule(g, c.kind);
|
|
462
|
+
hits.push({ kind: 'capsule', capsule: c.kind });
|
|
463
|
+
} else if (c.y + CAPSULE_BOX < 0) g.capsules.splice(i, 1);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function moveBolts(g, dt, hits) {
|
|
468
|
+
for (let i = g.bolts.length - 1; i >= 0; i--) {
|
|
469
|
+
const z = g.bolts[i];
|
|
470
|
+
z.y += LASER_SPEED * dt;
|
|
471
|
+
if (z.y > ROWS) { g.bolts.splice(i, 1); continue; }
|
|
472
|
+
let spent = false;
|
|
473
|
+
for (let e = g.enemies.length - 1; e >= 0 && !spent; e--) {
|
|
474
|
+
const m = g.enemies[e];
|
|
475
|
+
if (z.x >= m.x && z.x <= m.x + ENEMY_BOX && z.y >= m.y && z.y <= m.y + ENEMY_BOX) {
|
|
476
|
+
g.enemies.splice(e, 1); g.score += ENEMY_POINTS;
|
|
477
|
+
hits.push({ kind: 'enemy', x: m.x, y: m.y });
|
|
478
|
+
spent = true;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (let bi = 0; bi < g.bricks.length && !spent; bi++) {
|
|
482
|
+
const k = g.bricks[bi];
|
|
483
|
+
if (z.x >= k.x && z.x <= k.x + 1 && z.y >= k.y && z.y <= k.y + 1) {
|
|
484
|
+
damage(g, k, bi, hits);
|
|
485
|
+
spent = true;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (spent) g.bolts.splice(i, 1);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Does a box of side `s` at (x,y) overlap any brick? A brick is exactly one grid cell. */
|
|
493
|
+
export function boxHitsBrick(g, x, y, s) {
|
|
494
|
+
for (const k of g.bricks) {
|
|
495
|
+
if (x + s > k.x && x < k.x + 1 && y + s > k.y && y < k.y + 1) return k;
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* A life goes. TWO things cost one now -- the last ball through the floor, and a minion reaching
|
|
502
|
+
* Vaus -- so the consequences live in one place rather than being written twice and drifting apart.
|
|
503
|
+
*/
|
|
504
|
+
export function loseLife(g, hits, kind) {
|
|
505
|
+
g.lives -= 1;
|
|
506
|
+
hits.push({ kind });
|
|
507
|
+
resetVaus(g);
|
|
508
|
+
g.capsules = []; g.bolts = []; g.enemies = []; g.sinceEnemy = 0;
|
|
509
|
+
if (g.lives <= 0) { g.over = true; resetBall(g); g.balls[0].stuck = true; g.balls[0].vx = 0; g.balls[0].vy = 0; }
|
|
510
|
+
else resetBall(g);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function moveEnemies(g, dt, dtMs, hits) {
|
|
514
|
+
if (g.opts?.enemies === false) { g.sinceEnemy = 0; return; }
|
|
515
|
+
g.sinceEnemy += dtMs;
|
|
516
|
+
if (g.sinceEnemy >= ENEMY_EVERY && g.enemies.length < 3 && breakable(g).length) {
|
|
517
|
+
g.sinceEnemy = 0;
|
|
518
|
+
const n = g.nextId++;
|
|
519
|
+
// deterministic arrival: where it enters is a property of the level and the count, not a roll
|
|
520
|
+
const lane = Math.floor(hash01(`enemy:${g.level}:${n}`) * (COLS - 1));
|
|
521
|
+
// which minion turns up is a property of the level and the arrival, not a roll
|
|
522
|
+
const kind = MINION_KINDS[Math.floor(hash01(`mk:${g.level}:${n}`) * MINION_KINDS.length)];
|
|
523
|
+
g.enemies.push({ id: n, kind, x: lane, y: ROWS - 1, phase: hash01(`ph:${n}`) * Math.PI * 2, t: 0 });
|
|
524
|
+
hits.push({ kind: 'enemyin', x: lane, minion: kind });
|
|
525
|
+
}
|
|
526
|
+
const p = g.paddle;
|
|
527
|
+
const lim = COLS - ENEMY_BOX;
|
|
528
|
+
for (let i = g.enemies.length - 1; i >= 0; i--) {
|
|
529
|
+
const m = g.enemies[i];
|
|
530
|
+
m.t += dt;
|
|
531
|
+
|
|
532
|
+
// IT LOOKS FOR A WAY THROUGH (operator: "the minions should slowly try to find a path out").
|
|
533
|
+
// A wall-follower, not a pathfinder: descend when the space below is clear; when it is not,
|
|
534
|
+
// feel sideways in one direction for a gap; at a dead end, turn round. On a solid wall that
|
|
535
|
+
// reads as a minion pacing the top until the player opens a hole -- the arcade behaviour, and
|
|
536
|
+
// what makes breaking the wall feel like relief rather than bookkeeping.
|
|
537
|
+
const down = ENEMY_SPEED * dt;
|
|
538
|
+
if (!boxHitsBrick(g, m.x, m.y - down, ENEMY_BOX)) {
|
|
539
|
+
m.y -= down;
|
|
540
|
+
const nx = Math.max(0, Math.min(lim, m.x + minionDrift(m.kind, m.phase, m.t) * dt));
|
|
541
|
+
if (!boxHitsBrick(g, nx, m.y, ENEMY_BOX)) m.x = nx;
|
|
542
|
+
} else {
|
|
543
|
+
if (m.dir == null) m.dir = hash01(`dir:${m.id}`) < 0.5 ? -1 : 1;
|
|
544
|
+
const nx = m.x + m.dir * ENEMY_SIDE * dt;
|
|
545
|
+
if (nx >= 0 && nx <= lim && !boxHitsBrick(g, nx, m.y, ENEMY_BOX)) m.x = nx;
|
|
546
|
+
else m.dir = -m.dir;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// AND IT IS FATAL (operator: "kill the player if the player hits it"). It used to PAY 200 for
|
|
550
|
+
// touching Vaus, which had it backwards: the ball and the laser destroy a minion for points,
|
|
551
|
+
// but a minion that reaches the bat destroys the bat.
|
|
552
|
+
const onBat = m.y <= PADDLE_Y + PADDLE_D && m.y + ENEMY_BOX >= PADDLE_Y
|
|
553
|
+
&& m.x + ENEMY_BOX >= p.x && m.x <= p.x + p.w;
|
|
554
|
+
if (onBat) { hits.push({ kind: 'vaus', x: m.x, y: m.y }); loseLife(g, hits, 'life'); return; }
|
|
555
|
+
if (m.y + ENEMY_BOX < 0) g.enemies.splice(i, 1);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function ballEnemy(g, b, hits) {
|
|
560
|
+
for (let i = g.enemies.length - 1; i >= 0; i--) {
|
|
561
|
+
const m = g.enemies[i];
|
|
562
|
+
const ox = Math.min(b.x + b.r, m.x + ENEMY_BOX) - Math.max(b.x - b.r, m.x);
|
|
563
|
+
const oy = Math.min(b.y + b.r, m.y + ENEMY_BOX) - Math.max(b.y - b.r, m.y);
|
|
564
|
+
if (ox <= 0 || oy <= 0) continue;
|
|
565
|
+
g.enemies.splice(i, 1);
|
|
566
|
+
g.score += ENEMY_POINTS;
|
|
567
|
+
// BOUNCE OFF IT (operator: "If a ball hits a minion from above, it should bounce off it when it
|
|
568
|
+
// dies instead of passing through"). This forced the ball DOWNWARD whatever direction it had
|
|
569
|
+
// arrived from, so a ball descending onto a minion was pushed further down and read as passing
|
|
570
|
+
// straight through. Reflected on the axis it is least buried in, exactly as a brick does it --
|
|
571
|
+
// which gives the side bounces for free too.
|
|
572
|
+
if (ox < oy) b.vx = b.x < m.x + ENEMY_BOX / 2 ? -Math.abs(b.vx) : Math.abs(b.vx);
|
|
573
|
+
else b.vy = b.y < m.y + ENEMY_BOX / 2 ? -Math.abs(b.vy) : Math.abs(b.vy);
|
|
574
|
+
hits.push({ kind: 'enemy', x: m.x, y: m.y });
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function substepBall(g, b, h, hits) {
|
|
581
|
+
b.x += b.vx * h;
|
|
582
|
+
b.y += b.vy * h;
|
|
583
|
+
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx); hits.push({ kind: 'wall' }); }
|
|
584
|
+
else if (b.x + b.r > COLS) { b.x = COLS - b.r; b.vx = -Math.abs(b.vx); hits.push({ kind: 'wall' }); }
|
|
585
|
+
if (b.y + b.r > ROWS) { b.y = ROWS - b.r; b.vy = -Math.abs(b.vy); hits.push({ kind: 'wall' }); }
|
|
586
|
+
|
|
587
|
+
const p = g.paddle;
|
|
588
|
+
if (b.vy < 0 && b.y - b.r <= PADDLE_Y + PADDLE_D && b.y + b.r >= PADDLE_Y
|
|
589
|
+
&& b.x + b.r >= p.x && b.x - b.r <= p.x + p.w) {
|
|
590
|
+
offPaddle(g, b);
|
|
591
|
+
hits.push({ kind: 'paddle' });
|
|
592
|
+
}
|
|
593
|
+
ballEnemy(g, b, hits);
|
|
594
|
+
hitBrick(g, b, hits);
|
|
595
|
+
return b.y + b.r < 0; // through the floor
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Advance the court by dtMs. Substepped for the same reason breakout.js is: a ball at twelve units
|
|
600
|
+
* a second crosses a one-unit brick in 83 ms and would tunnel through the wall on a slow frame.
|
|
601
|
+
*/
|
|
602
|
+
export function step(g, dtMs) {
|
|
603
|
+
const hits = [];
|
|
604
|
+
if (g.over) return { hits, lost: false, cleared: g.cleared };
|
|
605
|
+
const standing = breakable(g).length > 0;
|
|
606
|
+
const dt = Math.min(60, Math.max(0, dtMs)) / 1000;
|
|
607
|
+
const ms = Math.min(60, Math.max(0, dtMs));
|
|
608
|
+
g.cooldown = Math.max(0, g.cooldown - ms);
|
|
609
|
+
for (const power of TIMED_POWERS) {
|
|
610
|
+
if (!(g.timers?.[power] > 0)) continue;
|
|
611
|
+
g.timers[power] = Math.max(0, g.timers[power] - ms);
|
|
612
|
+
if (g.timers[power] > 0) continue;
|
|
613
|
+
hits.push({ kind: 'powerover', power });
|
|
614
|
+
if (power === 'laser') g.laser = false;
|
|
615
|
+
if (power === 'wide') {
|
|
616
|
+
// back to stock about its OWN centre, and a held ball re-gripped into the narrower bat
|
|
617
|
+
const mid = g.paddle.x + g.paddle.w / 2;
|
|
618
|
+
g.paddle.w = PADDLE_W;
|
|
619
|
+
g.paddle.x = Math.max(0, Math.min(COLS - g.paddle.w, mid - g.paddle.w / 2));
|
|
620
|
+
for (const b of g.balls) if (b.stuck) { b.hold = Math.max(0, Math.min(g.paddle.w, b.hold ?? g.paddle.w / 2)); b.x = g.paddle.x + b.hold; }
|
|
621
|
+
}
|
|
622
|
+
if (power === 'slow') {
|
|
623
|
+
// put the pace BACK, or "slow" is permanent by omission
|
|
624
|
+
g.slow = false;
|
|
625
|
+
for (const b of g.balls) if (!b.stuck) { b.vx /= SLOW_FACTOR; b.vy /= SLOW_FACTOR; }
|
|
626
|
+
}
|
|
627
|
+
if (power === 'catch') {
|
|
628
|
+
g.catch = false;
|
|
629
|
+
hits.push({ kind: 'catchover' });
|
|
630
|
+
// A ball still held has to GO, or it sits on the bat with nothing left to explain why.
|
|
631
|
+
// Released at the angle its own position on the bat implies -- the same rule a bounce uses,
|
|
632
|
+
// so this is deterministic and the run stays reproducible.
|
|
633
|
+
for (const b of g.balls) {
|
|
634
|
+
if (!b.stuck) continue;
|
|
635
|
+
b.stuck = false;
|
|
636
|
+
const off = Math.max(-1, Math.min(1, (b.x - (g.paddle.x + g.paddle.w / 2)) / (g.paddle.w / 2)));
|
|
637
|
+
const sp = speed(g.level) * (g.slow ? SLOW_FACTOR : 1);
|
|
638
|
+
b.vx = Math.sin(off * MAX_BOUNCE) * sp;
|
|
639
|
+
b.vy = Math.abs(Math.cos(off * MAX_BOUNCE) * sp);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
moveCapsules(g, dt, hits);
|
|
645
|
+
moveBolts(g, dt, hits);
|
|
646
|
+
moveEnemies(g, dt, Math.min(60, Math.max(0, dtMs)), hits);
|
|
647
|
+
|
|
648
|
+
const moving = g.balls.filter((b) => !b.stuck);
|
|
649
|
+
for (const b of moving) {
|
|
650
|
+
const sp = Math.hypot(b.vx, b.vy) || 1;
|
|
651
|
+
const n = Math.max(1, Math.ceil((sp * dt) / MAX_STEP));
|
|
652
|
+
const h = dt / n;
|
|
653
|
+
let gone = false;
|
|
654
|
+
for (let i = 0; i < n && !gone; i++) gone = substepBall(g, b, h, hits);
|
|
655
|
+
if (gone) b.dead = true;
|
|
656
|
+
}
|
|
657
|
+
if (g.balls.some((b) => b.dead)) g.balls = g.balls.filter((b) => !b.dead);
|
|
658
|
+
|
|
659
|
+
let lost = false;
|
|
660
|
+
if (!g.balls.length) { // every ball gone: a life, and Vaus back to stock
|
|
661
|
+
lost = true;
|
|
662
|
+
// ONE implementation, not two. This was a line-for-line copy of loseLife() until a minion
|
|
663
|
+
// reaching Vaus became the second way to lose one -- and two copies of a rule drift.
|
|
664
|
+
loseLife(g, hits, 'life');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (standing && !breakable(g).length) g.cleared = true;
|
|
668
|
+
if (g.cleared) for (const b of g.balls) { b.stuck = true; b.vx = 0; b.vy = 0; }
|
|
669
|
+
return { hits, lost, cleared: g.cleared };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* ONE CAPSULE, AS SEVERAL TILES -- its own little machine rather than a coloured slab.
|
|
674
|
+
*
|
|
675
|
+
* Pure, and separate from tiles(), so the seven silhouettes can be asserted without a canvas. Each
|
|
676
|
+
* is built from parts offset inside the capsule's own footprint (CAPSULE_S wide), with `floor`
|
|
677
|
+
* lifting a glyph to stand ON the body. Deliberately different part counts and heights:
|
|
678
|
+
*
|
|
679
|
+
* laser two cannons standing tall slow the only HOLLOW one (a wire box)
|
|
680
|
+
* enlarge the flattest, with end caps disrupt three small spheres in a row
|
|
681
|
+
* catch a ball cradled between posts player the tallest: a tower with a bead on top
|
|
682
|
+
* break a doorway: two posts and a lintel, and the only one with no full-width base
|
|
683
|
+
*/
|
|
684
|
+
export function capsuleTiles(c, now = 0) {
|
|
685
|
+
const k = c.kind;
|
|
686
|
+
const body = CAPSULE_COLOR[k] ?? '#e8eefc';
|
|
687
|
+
const trim = CAPSULE_TRIM[k] ?? '#ffffff';
|
|
688
|
+
const spec = CAPSULE_SHAPE[k] ?? CAPSULE_SHAPE.laser;
|
|
689
|
+
const id = (n) => `cap${c.id}${n}`;
|
|
690
|
+
// IT TUMBLES AS IT FALLS. The angle comes from the capsule's own height, not from a clock, so
|
|
691
|
+
// two capsules at the same height look the same and a test can say what a frame contains --
|
|
692
|
+
// the rules stay free of time, exactly as the launch angle is an argument rather than a roll.
|
|
693
|
+
const rot = (c.y * 1.5 + (c.id % 7) * 0.9) % (Math.PI * 2);
|
|
694
|
+
// drawn large, CENTRED on the catch box, so what you aim at is under what you see
|
|
695
|
+
const off = (CAPSULE_S - CAPSULE_BOX) / 2;
|
|
696
|
+
const x = c.x - off, y = c.y - off;
|
|
697
|
+
return [
|
|
698
|
+
// the pill: an elongated stadium, wider than it is tall, turning end over end
|
|
699
|
+
{ txid: id('p'), x, y, s: CAPSULE_S, tall: 0.44, color: body, capsule: true, poly: PILL, rot },
|
|
700
|
+
// the band across it, in the capsule's trim colour, so the letter-colour still reads
|
|
701
|
+
{ txid: id('b'), x, y, s: CAPSULE_S, tall: 0.46, color: trim, capsule: true, poly: spec.band, rot },
|
|
702
|
+
];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// A STADIUM, drawn once. Unit space (-0.5..0.5), so it scales with the tile: a flat-ended
|
|
706
|
+
// rectangle with a fan of points rounding each cap. Long axis across, which is what makes it read
|
|
707
|
+
// as a pill rather than a lozenge.
|
|
708
|
+
const PILL = Object.freeze((() => {
|
|
709
|
+
const pts = [];
|
|
710
|
+
const hw = 0.46, hh = 0.2, r = hh;
|
|
711
|
+
for (let i = 0; i <= 6; i++) { const a = -Math.PI / 2 + (i / 6) * Math.PI; pts.push([hw - r + Math.cos(a) * r, Math.sin(a) * r]); }
|
|
712
|
+
for (let i = 0; i <= 6; i++) { const a = Math.PI / 2 + (i / 6) * Math.PI; pts.push([-hw + r + Math.cos(a) * r, Math.sin(a) * r]); }
|
|
713
|
+
return pts.map(([x, y]) => Object.freeze([Math.round(x * 1000) / 1000, Math.round(y * 1000) / 1000]));
|
|
714
|
+
})());
|
|
715
|
+
|
|
716
|
+
// EACH PILL STILL CARRIES ITS OWN MARK. The band is a different figure per capsule, so the seven
|
|
717
|
+
// remain distinguishable in monochrome -- the property the capsule test asserts -- even though
|
|
718
|
+
// they now share one silhouette. Colour is the third cue, not the only one.
|
|
719
|
+
const CAPSULE_SHAPE = Object.freeze({
|
|
720
|
+
laser: { band: Object.freeze([[-0.30, -0.12], [-0.18, -0.12], [-0.18, 0.12], [-0.30, 0.12]]) }, // one bar, left
|
|
721
|
+
enlarge: { band: Object.freeze([[-0.34, -0.10], [0.34, -0.10], [0.34, 0.10], [-0.34, 0.10]]) }, // a long bar
|
|
722
|
+
catch: { band: Object.freeze([[-0.10, -0.16], [0.14, -0.16], [0.14, 0.16], [-0.10, 0.16]]) }, // a block, right of centre
|
|
723
|
+
slow: { band: Object.freeze([[-0.05, -0.17], [0.05, -0.17], [0.05, 0.17], [-0.05, 0.17]]) }, // a thin upright
|
|
724
|
+
disrupt: { band: Object.freeze([[-0.28, -0.07], [-0.10, -0.07], [-0.10, 0.07], [-0.28, 0.07]]) }, // small, far left
|
|
725
|
+
player: { band: Object.freeze([[-0.07, -0.20], [0.07, -0.20], [0.07, 0.20], [-0.07, 0.20]]) }, // a tall bar
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The board as tiles for the engine. A brick is exactly one grid cell, so a brick IS a tile -- the
|
|
730
|
+
* same reason Blockout needed no translation layer. Vaus turns red while the laser is up, so the
|
|
731
|
+
* bat itself says what it can do rather than only the HUD saying it.
|
|
732
|
+
*/
|
|
733
|
+
export function tiles(g) {
|
|
734
|
+
const out = [];
|
|
735
|
+
for (const k of g.bricks) {
|
|
736
|
+
// Wear is shown TWICE, on purpose: the brick lightens toward white as it nears breaking, and
|
|
737
|
+
// it also sinks. Colour is the signal the operator asked for; height carries it for anyone who
|
|
738
|
+
// cannot separate these shades, and survives a monochrome screenshot.
|
|
739
|
+
const max = k.maxHits ?? (k.kind === 'silver' ? silverHits(g.level) : 1);
|
|
740
|
+
const multi = Number.isFinite(max) && max > 1;
|
|
741
|
+
const wear = multi ? (max - Math.max(1, k.hits)) / (max - 1) : 0; // 0 fresh .. 1 about to break
|
|
742
|
+
out.push({ txid: `b${k.id}`, x: k.x, y: k.y, s: 1,
|
|
743
|
+
tall: multi ? 1 - 0.28 * wear : 1,
|
|
744
|
+
color: damageShade(k.hits, max, k.color) });
|
|
745
|
+
}
|
|
746
|
+
// VAUS MORPHS WHEN IT IS ARMED. Stock it is a row of flat stones. With the laser it grows a
|
|
747
|
+
// cannon at each end and a raised housing between them -- the bat itself says what it can do,
|
|
748
|
+
// rather than only its colour changing.
|
|
749
|
+
const p = g.paddle;
|
|
750
|
+
const padColor = g.laser ? PADDLE_LASER_COLOR : PADDLE_COLOR;
|
|
751
|
+
const wide = Math.round(p.w);
|
|
752
|
+
for (let i = 0; i < wide; i++) {
|
|
753
|
+
const end = g.laser && (i === 0 || i === wide - 1);
|
|
754
|
+
out.push({ txid: `pad${i}`, x: p.x + i, y: PADDLE_Y, s: 1,
|
|
755
|
+
tall: g.laser ? (end ? 0.34 : 0.62) : PADDLE_H, color: padColor, paddle: true });
|
|
756
|
+
}
|
|
757
|
+
if (g.laser) {
|
|
758
|
+
for (const [n, dx] of [['l', 0.12], ['r', wide - 0.52]]) {
|
|
759
|
+
// NOT `paddle: true`. The cannons sit ON the bat; they are not segments OF it, and the flag
|
|
760
|
+
// means "this tile is a unit of Vaus's width" -- which the collision box and the tests both
|
|
761
|
+
// rely on. Flagging them made a 3-wide bat report 5 tiles wide.
|
|
762
|
+
out.push({ txid: `cannon${n}`, x: p.x + dx, y: PADDLE_Y + 0.05, s: 0.4, tall: 0.5,
|
|
763
|
+
color: '#ffd9cf', cannon: true,
|
|
764
|
+
poly: [[-0.16, -0.5], [0.16, -0.5], [0.22, 0.2], [0, 0.5], [-0.22, 0.2]], rot: 0 });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const c of g.capsules) out.push(...capsuleTiles(c));
|
|
768
|
+
for (const z of g.bolts) {
|
|
769
|
+
// the halo: a wider, softer tube around the beam, drawn as a glowing outline
|
|
770
|
+
out.push({ txid: `bolt${z.id}h`, x: z.x - 0.17, y: z.y - 0.05, s: 0.34, tall: 0.9,
|
|
771
|
+
color: BOLT_GLOW, wire: BOLT_GLOW, bolt: true });
|
|
772
|
+
// the beam itself: a hot core, also an outline so the engine never shades it down
|
|
773
|
+
out.push({ txid: `bolt${z.id}`, x: z.x - 0.07, y: z.y, s: 0.14, tall: 0.8,
|
|
774
|
+
color: BOLT_COLOR, wire: BOLT_COLOR, bolt: true });
|
|
775
|
+
}
|
|
776
|
+
for (const m of g.enemies) {
|
|
777
|
+
const spec = MINIONS[m.kind] ?? MINIONS.cone;
|
|
778
|
+
// drawn large, CENTRED on the small box it actually collides with
|
|
779
|
+
const off = (ENEMY_S - ENEMY_BOX) / 2;
|
|
780
|
+
out.push({ txid: `foe${m.id}`, x: m.x - off, y: m.y - off, s: ENEMY_S, tall: ENEMY_S,
|
|
781
|
+
color: spec.color, enemy: true, minion: m.kind,
|
|
782
|
+
poly: spec.poly, eyes: spec.eyes, rot: (m.phase + m.t * spec.spin) % (Math.PI * 2) });
|
|
783
|
+
}
|
|
784
|
+
for (let i = 0; i < g.balls.length; i++) {
|
|
785
|
+
const b = g.balls[i];
|
|
786
|
+
out.push({ txid: `ball${i}`, x: b.x - b.r, y: b.y - b.r, s: BALL_S, tall: BALL_S, color: BALL_COLOR, ball: true, sphere: true });
|
|
787
|
+
}
|
|
788
|
+
return out;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** How much of the breakable wall is left, 0..1. */
|
|
792
|
+
export function remaining(g) {
|
|
793
|
+
return g.breakableAtStart ? breakable(g).length / g.breakableAtStart : 0;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** What Vaus is carrying, for the HUD. */
|
|
797
|
+
export function powers(g) {
|
|
798
|
+
const on = [];
|
|
799
|
+
const left = (k) => Math.ceil((g.timers?.[k] ?? 0) / 1000);
|
|
800
|
+
if (g.laser) on.push(`laser ${left('laser')}s`);
|
|
801
|
+
if (g.catch) on.push(`catch ${left('catch')}s`);
|
|
802
|
+
if (g.slow) on.push(`slow ${left('slow')}s`);
|
|
803
|
+
if (g.paddle.w > PADDLE_W) on.push(`wide ${left('wide')}s`);
|
|
804
|
+
if (g.balls.length > 1) on.push(`${g.balls.length} balls`);
|
|
805
|
+
return on;
|
|
806
|
+
}
|