feat: overhaul Bloxorz rendering, mechanics, and level design; update Total Annihilation visuals

Bloxorz:
- Rewrite renderer with axonometric projection, rigid 3D box rolling, and continuous face shading
- Introduce new tile types: heavy switches (standing-only), soft switches, hold pads, fragile ground, split cubes, and teleporters
- Redesign all 36 levels using hand-authored ASCII maps in the generator, organized into six progressive acts
- Add design gates to verify solvability, mechanic necessity, and difficulty progression across acts
- Improve input handling with a directional compass, WASD/arrow key resolution, and smooth teleport/fall animations
- Update tutorial to document new mechanics and tile behaviors

Total Annihilation:
- Extend unit/building sight range to cover weapon max range + 2 tiles for improved fog visibility
- Implement crossfade between wireframe and finished sprites for building construction
- Switch to full-color assets by removing army tinting, and update artwork JSON paths/sizes
- Adjust weapon stats (damage, reload times) in rules
- Update sprites documentation to reflect the new visual pipeline
This commit is contained in:
Brian Fertig 2026-07-25 14:50:23 -06:00
parent 00f6dd6e19
commit b479b6a21f
18 changed files with 11436 additions and 4762 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -31,8 +31,8 @@
"sheets": {
"arm-units": {
"key": "ta-arm-units", "path": null, "kind": "unit",
"frameWidth": 64, "frameHeight": 64, "cols": 8
"key": "ta-arm-units", "path": "assets/images/ta/arm-units.png", "kind": "unit",
"frameWidth": 128, "frameHeight": 128, "cols": 8
},
"arm-structures": {
"key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure",

View File

@ -29,6 +29,7 @@
"separationStiffness": 0.55,
"shoveAfterSec": 0.6,
"stuckGiveUpSec": 3.0,
"stuckRepathSec": 1.0,
"arriveSlackPx": 12,
"friendlyFire": false,
"buildPowerNominal": 100,
@ -222,7 +223,7 @@
"id": "rifle",
"name": "Assault Rifle",
"kind": "hitscan",
"damage": 6,
"damage": 9,
"reload": 0.5,
"burst": 3,
"burstDelay": 0.06,
@ -251,7 +252,7 @@
"name": "Marksman Rifle",
"kind": "hitscan",
"damage": 55,
"reload": 3.0,
"reload": 2.0,
"burst": 1,
"range": 460,
"spread": 0.0,
@ -308,7 +309,7 @@
"id": "rocketpod",
"name": "Rocket Pod",
"kind": "guided",
"damage": 80,
"damage": 40,
"reload": 4.0,
"burst": 2,
"burstDelay": 0.25,

View File

@ -6,17 +6,16 @@ import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import {
loadLevel, newState, cloneState, applyMove, solve, tileRenderInfo,
blockCells, previewRoll, isWallAt,
} from './BloxorzLogic.js';
import {
EX, EY, TILE_DEPTH, project, depthOf, boardBounds,
boxesForBlock, boxCorners, visibleFaces, faceShade, rollPoints, translatePoints,
} from './BloxorzIso.js';
// ── Isometric projection ──────────────────────────────────────────────────────
// 2:1 diamond projection. Board size varies a lot across the 36 levels (a 6x5
// intro vs. a 22x5 capstone), so everything scales per-level to fit the same
// screen area rather than using fixed pixel constants.
const TW = 96;
const TH = 48;
const TILE_DEPTH = 26;
const BLOCK_HEIGHT = 84;
const SPLIT_HEIGHT = 44;
// Board size varies a lot across the 36 levels (a 6x5 intro vs. a 28x5
// capstone), so everything scales per-level to fit the same screen area rather
// than using fixed pixel constants. The projection itself lives in BloxorzIso.
const VOID_BG = 0x05070a;
const FLOOR_TOP = 0xd8b98a;
@ -29,9 +28,14 @@ const WALL_TOP = 0x5b6472;
const WALL_CLIFF_S = 0x363b44;
const WALL_CLIFF_E = 0x454c57;
const GOAL_TOP = 0x0a0c10;
const BLOCK_TOP = 0x4a90d9;
const BLOCK_SIDE_S = 0x2f5f8f;
const BLOCK_SIDE_E = 0x3a74ad;
// Block faces are shaded continuously between these two, by their normal, so a
// rolling face darkens as it turns away from the light instead of popping.
const BLOCK_DARK = { r: 0x24, g: 0x48, b: 0x6b };
const BLOCK_LIT = { r: 0x4a, g: 0x90, b: 0xd9 };
const BLOCK_EDGE = 0x1d3a57;
const ROLL_MS = 170;
const FALL_MS = 460;
const D = { board: 0, block: 10, ui: 30, overlay: 60, overlayUI: 62 };
@ -40,26 +44,26 @@ const DIR_KEYS = {
KeyA: 'left', KeyD: 'right', KeyW: 'up', KeyS: 'down',
};
function shapeFromBlock(block) {
if (block.mode === 'split') {
return [
{ gx0: block.a.x, gy0: block.a.y, gx1: block.a.x + 1, gy1: block.a.y + 1, height: SPLIT_HEIGHT },
{ gx0: block.b.x, gy0: block.b.y, gx1: block.b.x + 1, gy1: block.b.y + 1, height: SPLIT_HEIGHT },
];
}
if (block.orient === 'up') return [{ gx0: block.x, gy0: block.y, gx1: block.x + 1, gy1: block.y + 1, height: BLOCK_HEIGHT }];
if (block.orient === 'x') return [{ gx0: block.x, gy0: block.y, gx1: block.x + 2, gy1: block.y + 1, height: BLOCK_HEIGHT }];
return [{ gx0: block.x, gy0: block.y, gx1: block.x + 1, gy1: block.y + 2, height: BLOCK_HEIGHT }];
const DIR_STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
// Screen-space direction each arrow key rolls the block, for the HUD compass
// and for resolving swipes.
const DIR_SCREEN = {
right: { x: EX.x, y: EX.y },
left: { x: -EX.x, y: -EX.y },
down: { x: EY.x, y: EY.y },
up: { x: -EY.x, y: -EY.y },
};
function cellsKey(cells) {
return cells.map(([x, y]) => `${x},${y}`).sort().join('|');
}
function lerpPart(a, b, t) {
return {
gx0: a.gx0 + (b.gx0 - a.gx0) * t,
gy0: a.gy0 + (b.gy0 - a.gy0) * t,
gx1: a.gx1 + (b.gx1 - a.gx1) * t,
gy1: a.gy1 + (b.gy1 - a.gy1) * t,
height: a.height + (b.height - a.height) * t,
};
function shadeToColor(t) {
const r = Math.round(BLOCK_DARK.r + (BLOCK_LIT.r - BLOCK_DARK.r) * t);
const g = Math.round(BLOCK_DARK.g + (BLOCK_LIT.g - BLOCK_DARK.g) * t);
const b = Math.round(BLOCK_DARK.b + (BLOCK_LIT.b - BLOCK_DARK.b) * t);
return (r << 16) | (g << 8) | b;
}
export default class BloxorzGame extends Phaser.Scene {
@ -83,8 +87,7 @@ export default class BloxorzGame extends Phaser.Scene {
this.overlayUp = false;
this.scaleF = 1;
this.originX = 0;
this.originY = 0;
this.proj = { originX: 0, originY: 0, scaleF: 1 };
}
async create() {
@ -136,7 +139,14 @@ export default class BloxorzGame extends Phaser.Scene {
const dy = p.y - touchStart.y;
touchStart = null;
if (Math.max(Math.abs(dx), Math.abs(dy)) < 30) return;
const dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
// The grid axes aren't screen axes, so pick the direction whose projected
// screen vector the swipe points most closely along.
let dir = 'right';
let best = -Infinity;
for (const [d, v] of Object.entries(DIR_SCREEN)) {
const dot = (dx * v.x + dy * v.y) / (Math.hypot(v.x, v.y) || 1);
if (dot > best) { best = dot; dir = d; }
}
this.tryMove(dir);
});
}
@ -279,31 +289,40 @@ export default class BloxorzGame extends Phaser.Scene {
this.blockGfx = this.add.graphics().setDepth(D.block);
this.layer.add([this.tileGfx, this.blockGfx]);
this.redrawTiles();
this.redrawBlock(shapeFromBlock(this.state.block));
this.redrawBlockState(this.state.block);
}
_computeProjection() {
const { cols, rows } = this.compiled;
const span = cols + rows;
const boardW = span * (TW / 2);
const boardH = span * (TH / 2);
const availW = 1300;
const availH = 560;
this.scaleF = Math.min(1, availW / boardW, availH / (boardH + 220));
this.originX = GAME_WIDTH / 2 - (cols - rows) * (TW / 2) * this.scaleF / 2;
this.originY = 300;
// Play area: the HUD column sits left of x=240 and the far right is empty,
// so the board is centred right of screen centre rather than on it.
const availW = 1560;
const availH = 760;
const centreX = 1060;
const centreY = 590;
const b = boardBounds(cols, rows, 2);
this.scaleF = Math.min(1, availW / b.w, availH / b.h);
// Cached because project() is called per box corner, per frame.
// (`this.proj`, not `this.view` — that one holds 'select' | 'play'.)
this.proj = {
originX: centreX - ((b.minX + b.maxX) / 2) * this.scaleF,
originY: centreY - ((b.minY + b.maxY) / 2) * this.scaleF,
scaleF: this.scaleF,
};
}
isoX(x, y) { return this.originX + (x - y) * (TW / 2) * this.scaleF; }
isoY(x, y) { return this.originY + (x + y) * (TH / 2) * this.scaleF; }
project3(p) {
const { sx, sy } = project(p, this.proj);
return [sx, sy];
}
footprintCorners(gx0, gy0, gx1, gy1) {
return [
[this.isoX(gx0, gy0), this.isoY(gx0, gy0)],
[this.isoX(gx1, gy0), this.isoY(gx1, gy0)],
[this.isoX(gx1, gy1), this.isoY(gx1, gy1)],
[this.isoX(gx0, gy1), this.isoY(gx0, gy1)],
this.project3({ x: gx0, y: gy0, z: 0 }),
this.project3({ x: gx1, y: gy0, z: 0 }),
this.project3({ x: gx1, y: gy1, z: 0 }),
this.project3({ x: gx0, y: gy1, z: 0 }),
];
}
@ -326,14 +345,13 @@ export default class BloxorzGame extends Phaser.Scene {
redrawTiles() {
this.tileGfx.clear();
const cells = [...this.compiled.tiles.keys()].map((k) => k.split(',').map(Number));
cells.sort((a, b) => (a[0] + a[1]) - (b[0] + b[1]));
cells.sort((a, b) => depthOf(a[0], a[1]) - depthOf(b[0], b[1]));
for (const [x, y] of cells) this._drawTile(x, y);
}
_drawTile(x, y) {
const info = tileRenderInfo(this.compiled, this.state, x, y);
if (!info) return;
if (info.type === 'fragile' && info.broken) return; // broken = void, nothing to draw
const c = this.footprintCorners(x, y, x + 1, y + 1);
const hasSouth = this.compiled.tiles.has(`${x},${y + 1}`);
@ -364,10 +382,22 @@ export default class BloxorzGame extends Phaser.Scene {
const mid = [(c[0][0] + c[2][0]) / 2, (c[0][1] + c[2][1]) / 2];
const r = 10 * this.scaleF;
if (info.type === 'switch') {
const isSoft = info.requireOrientation === 'lying';
this.tileGfx.fillStyle(isSoft ? 0xf2ead8 : 0xd4602a, 0.95);
if (isSoft) this.tileGfx.fillCircle(mid[0], mid[1], r);
else this.tileGfx.fillRect(mid[0] - r, mid[1] - r, r * 2, r * 2);
// Three distinct marks, following the original: a heavy X you must land
// on upright, a round soft switch any contact trips, and a hold pad that
// only counts while a lying block covers it.
if (info.requireOrientation === 'standing') {
this.tileGfx.lineStyle(Math.max(3, 5 * this.scaleF), 0xd4602a, 1);
this.tileGfx.lineBetween(mid[0] - r, mid[1] - r * 0.62, mid[0] + r, mid[1] + r * 0.62);
this.tileGfx.lineBetween(mid[0] - r, mid[1] + r * 0.62, mid[0] + r, mid[1] - r * 0.62);
} else if (info.requireOrientation === 'lying') {
this.tileGfx.fillStyle(0xf2ead8, 0.95);
this.tileGfx.fillRect(mid[0] - r, mid[1] - r * 0.38, r * 2, r * 0.76);
} else {
this.tileGfx.fillStyle(0xd4602a, 0.95);
this.tileGfx.fillCircle(mid[0], mid[1], r * 0.85);
this.tileGfx.lineStyle(Math.max(1, 2 * this.scaleF), 0xf2ead8, 0.9);
this.tileGfx.strokeCircle(mid[0], mid[1], r * 0.85);
}
} else if (info.type === 'teleport') {
const hue = (hashStr(info.teleportId) % 360) / 360;
const color = Phaser.Display.Color.HSVToRGB(hue, 0.65, 0.95).color;
@ -381,18 +411,30 @@ export default class BloxorzGame extends Phaser.Scene {
}
}
redrawBlock(parts) {
// `pieces` is a list of 8-corner world-space point arrays — the block mid-roll
// is just the same rigid corners rotated, so every draw path shares this.
redrawBlock(pieces) {
this.blockGfx.clear();
for (const part of parts) this._drawBlockPart(part);
for (const pts of pieces) this._drawBox(pts);
}
_drawBlockPart({ gx0, gy0, gx1, gy1, height }) {
const h = height * this.scaleF;
const c = this.footprintCorners(gx0, gy0, gx1, gy1);
this.fillFace(this.blockGfx, c[3], c[2], -h, BLOCK_SIDE_S);
this.fillFace(this.blockGfx, c[1], c[2], -h, BLOCK_SIDE_E);
const top = c.map(([px, py]) => [px, py - h]);
this.fillPoly(this.blockGfx, top, BLOCK_TOP, 1);
redrawBlockState(block, zOff = 0) {
this.redrawBlock(boxesForBlock(block)
.map((b) => translatePoints(boxCorners(b), 0, 0, zOff)));
}
_drawBox(pts) {
const stroke = Math.max(1, 2 * this.scaleF);
for (const face of visibleFaces(pts)) {
const corners = face.pts.map((p) => this.project3(p));
this.fillPoly(this.blockGfx, corners, shadeToColor(faceShade(face.normal)), 1);
this.blockGfx.lineStyle(stroke, BLOCK_EDGE, 0.9);
this.blockGfx.beginPath();
this.blockGfx.moveTo(corners[0][0], corners[0][1]);
for (let i = 1; i < corners.length; i++) this.blockGfx.lineTo(corners[i][0], corners[i][1]);
this.blockGfx.closePath();
this.blockGfx.strokePath();
}
}
// ── HUD ──────────────────────────────────────────────────────────────────────
@ -429,9 +471,39 @@ export default class BloxorzGame extends Phaser.Scene {
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
this.undoBtn = undo;
this.layer.add([undo, reset, hint, levels]);
this._drawCompass(BTN_X, btnY + 150);
this.updateMoves();
}
// The board is yawed, not screen-aligned, so spell the key mapping out: each
// arrow is drawn along the direction that key actually rolls the block.
_drawCompass(cx, cy) {
const g = this.add.graphics().setDepth(D.ui);
const LEN = 34;
g.lineStyle(3, COLORS.gold, 0.75);
for (const v of Object.values(DIR_SCREEN)) {
const m = Math.hypot(v.x, v.y) || 1;
g.lineBetween(cx, cy, cx + (v.x / m) * LEN, cy + (v.y / m) * LEN);
}
g.fillStyle(COLORS.gold, 0.9);
g.fillCircle(cx, cy, 4);
this.layer.add(g);
const GLYPH = { up: '▲', down: '▼', left: '◀', right: '▶' };
for (const [dir, v] of Object.entries(DIR_SCREEN)) {
const m = Math.hypot(v.x, v.y) || 1;
const label = this.add.text(cx + (v.x / m) * (LEN + 16), cy + (v.y / m) * (LEN + 16), GLYPH[dir], {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.ui);
this.layer.add(label);
}
const caption = this.add.text(cx, cy + LEN + 46, 'Arrow keys / WASD', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.layer.add(caption);
}
updateMoves() {
if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`);
if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0);
@ -444,50 +516,136 @@ export default class BloxorzGame extends Phaser.Scene {
const prevBlock = this.state.block;
const snapshot = cloneState(this.state);
const res = applyMove(this.compiled, this.state, dir);
if (!res.moved) { this._shakeBlock(); return; }
if (!res.moved) { this._shakeBlock(dir); return; }
this.undoStack.push(snapshot);
this.moves++;
this.updateMoves();
this.busy = true;
playSound(this, SFX.PIECE_CLICK);
this._animateMove(prevBlock, this.state.block, () => {
this.busy = false;
const rolls = this._buildRolls(prevBlock, dir);
this._playRoll(rolls, () => {
if (this.state.status === 'dead') { this._tumbleOff(rolls); return; }
this.redrawTiles();
if (this.state.status === 'dead') this._onFall();
else if (this.state.status === 'won') this.onSolved();
// A landing that doesn't match where the roll physically put the block
// means the engine relocated it — i.e. a teleport pad.
const teleported = cellsKey(this._rollCells(rolls)) !== cellsKey(blockCells(this.state.block));
const finish = () => {
if (this.state.status === 'won') this._dropInHole();
else this.busy = false;
};
if (teleported) this._teleportIn(finish);
else { this.redrawBlockState(this.state.block); finish(); }
});
}
_animateMove(prevBlock, nextBlock, onDone) {
const prevParts = shapeFromBlock(prevBlock);
const nextParts = shapeFromBlock(nextBlock);
if (prevParts.length !== nextParts.length) {
this.redrawBlock(nextParts);
this.blockGfx.setAlpha(0.35);
this.tweens.add({ targets: this.blockGfx, alpha: 1, duration: 160, onComplete: onDone });
return;
// One entry per rigid piece: its pre-move box, its corners, and the direction
// it tips (null = stayed put, which only happens to a split cube stopped by a
// wall — walls block a single unit without killing it).
_buildRolls(block, dir) {
const [dx, dy] = DIR_STEP[dir];
return boxesForBlock(block).map((box, i) => {
let dest;
if (block.mode === 'split') {
const u = i === 0 ? block.a : block.b;
dest = isWallAt(this.compiled, u.x + dx, u.y + dy) ? null : [[u.x + dx, u.y + dy]];
} else {
dest = blockCells(previewRoll(block, dir));
}
return { box, pts: boxCorners(box), dir: dest ? dir : null, cells: dest ?? this._boxCells(box) };
});
}
_boxCells(box) {
const cells = [];
for (let x = Math.round(box.x0); x < Math.round(box.x1); x++) {
for (let y = Math.round(box.y0); y < Math.round(box.y1); y++) cells.push([x, y]);
}
return cells;
}
_rollCells(rolls) {
return rolls.flatMap((r) => r.cells);
}
_rolledPoints(rolls, t, dz = 0) {
return rolls.map((r) => {
const pts = r.dir ? rollPoints(r.pts, r.dir, r.box, t) : r.pts;
return dz ? translatePoints(pts, 0, 0, dz) : pts;
});
}
// A topple accelerates into the ground, so ease in rather than in-out.
_playRoll(rolls, onDone) {
const tw = { t: 0 };
this.tweens.add({
targets: tw,
t: 1,
duration: 170,
ease: 'Sine.easeInOut',
onUpdate: () => {
const parts = prevParts.map((p, i) => lerpPart(p, nextParts[i], tw.t));
this.redrawBlock(parts);
},
duration: ROLL_MS,
ease: 'Sine.easeIn',
onUpdate: () => this.redrawBlock(this._rolledPoints(rolls, tw.t)),
onComplete: onDone,
});
}
_shakeBlock() {
_shakeBlock(dir) {
if (!this.blockGfx) return;
const x0 = this.blockGfx.x;
const v = DIR_SCREEN[dir] ?? DIR_SCREEN.right;
const m = Math.hypot(v.x, v.y) || 1;
// The graphics object always draws in absolute coordinates, so its
// transform is only ever this nudge — restart it from zero so mashing a
// blocked direction can't accumulate an offset.
this.tweens.killTweensOf(this.blockGfx);
this.blockGfx.setPosition(0, 0);
this.tweens.add({
targets: this.blockGfx, x: x0 + 10, duration: 60, yoyo: true, repeat: 1,
onComplete: () => { this.blockGfx.x = x0; },
targets: this.blockGfx,
x: (v.x / m) * 10,
y: (v.y / m) * 10,
duration: 60,
yoyo: true,
repeat: 1,
onComplete: () => this.blockGfx.setPosition(0, 0),
});
}
_teleportIn(onDone) {
this.tweens.add({
targets: this.blockGfx,
alpha: 0,
duration: 110,
onComplete: () => {
const tw = { t: 0 };
this.blockGfx.setAlpha(1);
this.tweens.add({
targets: tw,
t: 1,
duration: 150,
ease: 'Quad.easeOut',
onUpdate: () => {
this.blockGfx.setAlpha(tw.t);
this.redrawBlockState(this.state.block, (1 - tw.t) * 0.7);
},
onComplete: () => { this.blockGfx.setAlpha(1); this.redrawBlockState(this.state.block); onDone(); },
});
},
});
}
_dropInHole() {
playSound(this, SFX.GEM_BIG_DROP);
const tw = { t: 0 };
this.tweens.add({
targets: tw,
t: 1,
duration: 380,
ease: 'Quad.easeIn',
onUpdate: () => {
this.blockGfx.setAlpha(1 - tw.t * 0.9);
this.redrawBlockState(this.state.block, -2.4 * tw.t);
},
onComplete: () => { this.busy = false; this.onSolved(); },
});
}
@ -497,7 +655,7 @@ export default class BloxorzGame extends Phaser.Scene {
this.moves++;
this.updateMoves();
this.redrawTiles();
this.redrawBlock(shapeFromBlock(this.state.block));
this.redrawBlockState(this.state.block);
playSound(this, SFX.PIECE_CLICK);
}
@ -509,7 +667,7 @@ export default class BloxorzGame extends Phaser.Scene {
this.updateMoves();
this.blockGfx.setAlpha(1);
this.redrawTiles();
this.redrawBlock(shapeFromBlock(this.state.block));
this.redrawBlockState(this.state.block);
playSound(this, SFX.CARD_SHUFFLE);
}
@ -530,12 +688,24 @@ export default class BloxorzGame extends Phaser.Scene {
// ── Fall / solve flow ─────────────────────────────────────────────────────────
_onFall() {
// Picks up where the roll left off (t = 1, i.e. 90 degrees) and keeps turning
// while the block accelerates into the void.
_tumbleOff(rolls) {
this.busy = false;
this.overlayUp = true;
playSound(this, SFX.GEM_BIG_DROP);
const tw = { t: 1 };
this.tweens.add({
targets: this.blockGfx, y: '+=160', alpha: 0, duration: 420, ease: 'Quad.easeIn',
onComplete: () => this._showFallOverlay(),
targets: tw,
t: 2.1,
duration: FALL_MS,
ease: 'Quad.easeIn',
onUpdate: () => {
const fallen = tw.t - 1;
this.blockGfx.setAlpha(Math.max(0, 1 - fallen * 0.9));
this.redrawBlock(this._rolledPoints(rolls, tw.t, -6 * fallen * fallen));
},
onComplete: () => { this.blockGfx.clear(); this._showFallOverlay(); },
});
}

View File

@ -0,0 +1,178 @@
// Bloxorz — axonometric projection + rigid box geometry. Pure math, no Phaser,
// so tools/verifyBloxorz.js can unit-test it under Node alongside the engine.
//
// World space: x = grid column (right), y = grid row (into the screen),
// z = height in CELL units (a cell is 1x1x1, the block is 1x1x2). Right-handed.
//
// The projection is a general axonometric built from a yaw and a pitch rather
// than the symmetric 2:1 diamond: at YAW 45 / PITCH 0.5 these basis vectors
// reproduce a classic isometric diamond exactly, but that made the arrow keys
// ambiguous (up moved the block up-LEFT). A shallow yaw keeps a real 3D read of
// the block while making Right read as screen-right and Up as screen-up.
const UNIT = 96; // world unit -> px at scaleF 1
export const YAW = 18 * Math.PI / 180;
export const PITCH = 0.58; // sin(camera pitch): ground-plane squash
export const EX = { x: Math.cos(YAW) * UNIT, y: Math.sin(YAW) * UNIT * PITCH };
export const EY = { x: -Math.sin(YAW) * UNIT, y: Math.cos(YAW) * UNIT * PITCH };
export const EZ = Math.sqrt(1 - PITCH * PITCH) * UNIT;
export const TILE_DEPTH = 26; // px of cliff wall drawn at void-adjacent edges
// Direction the camera lies in, i.e. the null vector of the projection matrix
// (the ray that projects to a single point), pointing from the scene toward the
// viewer. A face is visible exactly when its outward normal has a positive dot
// product with this.
export const VIEW = normalize({
x: Math.sin(YAW),
y: Math.cos(YAW),
z: PITCH / Math.sqrt(1 - PITCH * PITCH),
});
// Key light: up, in front of, and to the +x side of the board, so the +x face
// stays brighter than the +y face (matching the old hand-picked side colours).
export const LIGHT = normalize({ x: 0.55, y: -0.35, z: 0.76 });
function normalize(v) {
const m = Math.hypot(v.x, v.y, v.z) || 1;
return { x: v.x / m, y: v.y / m, z: v.z / m };
}
// ── Projection ───────────────────────────────────────────────────────────────
// view = { originX, originY, scaleF }
export function project(p, view) {
const z = p.z ?? 0;
return {
sx: view.originX + (p.x * EX.x + p.y * EY.x) * view.scaleF,
sy: view.originY + (p.x * EX.y + p.y * EY.y - z * EZ) * view.scaleF,
};
}
// Painter's-algorithm key for ground tiles: both basis vectors point down-screen
// at this yaw, so ascending order draws far cells before near ones. NOT x + y —
// the axes are foreshortened by different amounts once the yaw isn't 45.
export function depthOf(x, y) {
return EX.y * x + EY.y * y;
}
// Unscaled px bounding box of a cols x rows board, with room above for a block
// `headroomZ` cells tall and below for the cliff faces.
export function boardBounds(cols, rows, headroomZ = 2) {
const corners = [[0, 0], [cols, 0], [0, rows], [cols, rows]]
.map(([x, y]) => ({ sx: x * EX.x + y * EY.x, sy: x * EX.y + y * EY.y }));
const minX = Math.min(...corners.map((c) => c.sx));
const maxX = Math.max(...corners.map((c) => c.sx));
const minY = Math.min(...corners.map((c) => c.sy)) - headroomZ * EZ;
const maxY = Math.max(...corners.map((c) => c.sy)) + TILE_DEPTH;
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
}
// ── Boxes ────────────────────────────────────────────────────────────────────
const SPLIT_INSET = 0.06;
// Every pose the block can hold, as world-space boxes. The whole point of the
// z-in-cell-units model: a lying block is ONE cell tall, not two, which is what
// makes a roll look like a roll instead of a stretch.
export function boxesForBlock(block) {
if (block.mode === 'split') {
return [block.a, block.b].map((u) => ({
x0: u.x + SPLIT_INSET, y0: u.y + SPLIT_INSET, z0: 0,
x1: u.x + 1 - SPLIT_INSET, y1: u.y + 1 - SPLIT_INSET, z1: 1,
}));
}
if (block.orient === 'up') return [{ x0: block.x, y0: block.y, z0: 0, x1: block.x + 1, y1: block.y + 1, z1: 2 }];
if (block.orient === 'x') return [{ x0: block.x, y0: block.y, z0: 0, x1: block.x + 2, y1: block.y + 1, z1: 1 }];
return [{ x0: block.x, y0: block.y, z0: 0, x1: block.x + 1, y1: block.y + 2, z1: 1 }];
}
// Corner order is fixed: bit 1 = x1, bit 2 = y1, bit 4 = z1.
export function boxCorners(b) {
const pts = [];
for (let i = 0; i < 8; i++) {
pts.push({
x: (i & 1) ? b.x1 : b.x0,
y: (i & 2) ? b.y1 : b.y0,
z: (i & 4) ? b.z1 : b.z0,
});
}
return pts;
}
// Faces wound counter-clockwise seen from outside, so the cross product of the
// first two edges is the outward normal even after the box has been rotated.
const FACE_QUADS = [
[4, 5, 7, 6], // +z (top)
[0, 2, 3, 1], // -z (bottom)
[1, 3, 7, 5], // +x
[0, 4, 6, 2], // -x
[2, 6, 7, 3], // +y
[0, 1, 5, 4], // -y
];
function faceNormal(p0, p1, p2) {
const ax = p1.x - p0.x, ay = p1.y - p0.y, az = p1.z - p0.z;
const bx = p2.x - p0.x, by = p2.y - p0.y, bz = p2.z - p0.z;
return normalize({
x: ay * bz - az * by,
y: az * bx - ax * bz,
z: ax * by - ay * bx,
});
}
// Front-facing quads only. A box is convex and the projection is orthographic,
// so the surviving faces tile the silhouette without overlapping each other —
// culling alone is enough, no per-face depth sort needed.
export function visibleFaces(pts) {
const out = [];
for (const quad of FACE_QUADS) {
const [a, b, c] = quad;
const n = faceNormal(pts[a], pts[b], pts[c]);
if (n.x * VIEW.x + n.y * VIEW.y + n.z * VIEW.z <= 1e-9) continue;
out.push({ pts: quad.map((i) => pts[i]), normal: n });
}
return out;
}
// 0 (fully shadowed) .. 1 (facing the key light straight on).
export function faceShade(normal) {
const d = normal.x * LIGHT.x + normal.y * LIGHT.y + normal.z * LIGHT.z;
return Math.min(1, Math.max(0, (d + 1) / 1.76));
}
// ── Rolling ──────────────────────────────────────────────────────────────────
// Which ground edge of the box the roll pivots about.
const PIVOTS = {
right: { axis: 'x', sign: 1 },
left: { axis: 'x', sign: -1 },
down: { axis: 'y', sign: 1 },
up: { axis: 'y', sign: -1 },
};
// Rigid rotation of all 8 corners by t * 90 degrees about the leading ground
// edge. t is deliberately unclamped: the fall animation keeps turning past 1.
export function rollPoints(pts, dir, box, t) {
const p = PIVOTS[dir];
if (!p || t === 0) return pts;
const th = t * Math.PI / 2;
const c = Math.cos(th);
const s = Math.sin(th);
const onX = p.axis === 'x';
const pv = onX
? (p.sign > 0 ? box.x1 : box.x0)
: (p.sign > 0 ? box.y1 : box.y0);
return pts.map((q) => {
const d = (onX ? q.x : q.y) - pv;
const a = pv + d * c + p.sign * q.z * s;
const z = -p.sign * d * s + q.z * c;
return onX ? { x: a, y: q.y, z } : { x: q.x, y: a, z };
});
}
export function translatePoints(pts, dx, dy, dz) {
return pts.map((q) => ({ x: q.x + dx, y: q.y + dy, z: q.z + dz }));
}

View File

@ -19,14 +19,23 @@
// wall — a permanent, non-lethal obstacle: a move into it is simply
// illegal (no-op), never fatal. Used to give split cubes
// something to independently route around.
// switch — requireOrientation decides what counts as pressing it:
// 'standing' -> heavy X switch, block must come to rest upright
// 'any' -> soft round switch, any contact incl. a split cube
// 'lying' -> hold pad, only a lying block counts
// bridge — impassable unless its linked switch(es) say otherwise:
// mode:'toggle' -> persistent flip on any contact (hard switch)
// mode:'momentary' -> open only while a LYING block currently
// covers the switch cell (soft switch)
// fragile — supports exactly one standing landing, then breaks permanently
// mode:'toggle' -> persistent flip on contact
// mode:'momentary' -> open only while the switch is currently
// covered (a hold pad)
// fragile — orange ground. It takes a lying block's weight fine, but the
// whole block standing upright on one cell goes straight through
// it (as in the original). A lone split cube is half the weight
// and is safe standing on it.
// teleport — a standing (single-cell) landing is relocated to its pair
// goal — only a standing joined block ('up') is supported here; any
// other landing shape falls through like void
// goal — the exit hole. A block lying across it bridges it harmlessly, as
// in the original game; only a joined block that ends its move
// STANDING exactly on the hole drops in and wins. A landing here
// is never fatal.
export const DIR_LIST = ['up', 'down', 'left', 'right'];
const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
@ -83,6 +92,9 @@ function isPressed(state, sw) {
const onIt = cells.some(([x, y]) => x === sw.x && y === sw.y);
if (!onIt) return false;
if (sw.requireOrientation === 'any') return true;
// 'standing' is the original game's heavy X switch: the whole block has to
// come to rest upright on the single cell, which is a real routing problem.
if (sw.requireOrientation === 'standing') return state.block.mode === 'joined' && state.block.orient === 'up';
return state.block.mode === 'joined' && state.block.orient !== 'up';
}
@ -97,23 +109,24 @@ function tileAt(level, state, x, y) {
const t = level.tiles.get(key(x, y));
if (!t) return null;
if (t.type === 'bridge' && !bridgeOpen(level, state, t.bridgeId)) return null;
if (t.type === 'fragile' && state.broken.has(key(x, y))) return null;
return t;
}
function tileSupports(tile, mode, orient) {
if (!tile) return false;
if (tile.type === 'goal') return mode === 'joined' && orient === 'up';
return true;
}
// 'ok' (safe to land), 'blocked' (wall — illegal move, not fatal), or 'fatal'
// (void / closed bridge / broken fragile / lying-or-split onto a goal).
function cellStatus(level, state, x, y, mode, orient) {
// (void / closed bridge / upright on fragile ground). `standing` is whether the
// whole block would come to rest upright on this one cell.
//
// The goal is deliberately NOT special-cased here: as in the original game, a
// block lying across the hole simply bridges it. Only a block that ends its
// move standing exactly on the hole drops in, and settle() handles that as the
// win — the landing itself is never fatal.
function cellStatus(level, state, x, y, standing = false) {
const raw = level.tiles.get(key(x, y));
if (raw?.type === 'wall') return 'blocked';
const tile = tileAt(level, state, x, y);
return tileSupports(tile, mode, orient) ? 'ok' : 'fatal';
if (!tile) return 'fatal';
if (tile.type === 'fragile' && standing) return 'fatal';
return 'ok';
}
// Renderer-facing view of a tile including its current dynamic state.
@ -121,7 +134,6 @@ export function tileRenderInfo(level, state, x, y) {
const t = level.tiles.get(key(x, y));
if (!t) return null;
if (t.type === 'bridge') return { ...t, open: bridgeOpen(level, state, t.bridgeId) };
if (t.type === 'fragile') return { ...t, broken: state.broken.has(key(x, y)) };
return t;
}
@ -162,27 +174,31 @@ function rollJoined(block, dir) {
: { mode: 'joined', orient: 'y', x: block.x + dx, y: block.y };
}
// The pose a joined block WOULD land in, ignoring whether that landing is
// legal. The renderer needs this to animate a fatal move: applyMove leaves
// state.block untouched when the block dies, so the roll it died performing has
// to be reconstructed from the pre-move block.
export function previewRoll(block, dir) {
return rollJoined(block, dir);
}
// Walls stop a single split cube without killing it (see stepUnit), so the
// renderer asks about them to know which unit stayed put during a move.
export function isWallAt(level, x, y) {
return level.tiles.get(key(x, y))?.type === 'wall';
}
function tryMerge(a, b) {
if (a.x === b.x && Math.abs(a.y - b.y) === 1) return { mode: 'joined', orient: 'y', x: a.x, y: Math.min(a.y, b.y) };
if (a.y === b.y && Math.abs(a.x - b.x) === 1) return { mode: 'joined', orient: 'x', x: Math.min(a.x, b.x), y: a.y };
return null;
}
// Shared tail after a successful (non-dead) landing: fragile breakage under a
// standing footprint, goal check (joined+up only), then switch evaluation.
// Shared tail after a successful (non-dead) landing: teleport relocation, goal
// check (joined+up only), then switch evaluation.
function settle(level, state) {
const standingCells = state.block.mode === 'split'
? [state.block.a, state.block.b]
: (state.block.orient === 'up' ? [{ x: state.block.x, y: state.block.y }] : []);
if (state.block.mode === 'joined' && state.block.orient === 'up') {
state.block = { ...state.block, ...maybeTeleportPoint(level, { x: state.block.x, y: state.block.y }) };
standingCells[0] = { x: state.block.x, y: state.block.y };
}
for (const { x, y } of standingCells) {
const tile = level.tiles.get(key(x, y));
if (tile?.type === 'fragile') state.broken.add(key(x, y));
}
if (state.block.mode === 'joined' && state.block.orient === 'up') {
@ -199,7 +215,7 @@ function jointStep(level, state, dir) {
const cells = blockCells(candidate);
for (const [x, y] of cells) {
const status = cellStatus(level, state, x, y, candidate.mode, candidate.orient);
const status = cellStatus(level, state, x, y, candidate.orient === 'up');
if (status === 'blocked') return { moved: false };
if (status === 'fatal') { state.status = 'dead'; return { moved: true, dead: true }; }
}
@ -218,7 +234,7 @@ function jointStep(level, state, dir) {
function stepUnit(level, state, pt, dx, dy) {
const nx = pt.x + dx;
const ny = pt.y + dy;
const status = cellStatus(level, state, nx, ny, 'joined', 'up');
const status = cellStatus(level, state, nx, ny);
if (status === 'blocked') return { pt, dead: false };
if (status === 'fatal') return { pt, dead: true };
return { pt: maybeTeleportPoint(level, { x: nx, y: ny }), dead: false };
@ -230,6 +246,13 @@ function splitStep(level, state, dir) {
const rb = stepUnit(level, state, state.block.b, dx, dy);
if (ra.dead || rb.dead) { state.status = 'dead'; return { moved: true, dead: true }; }
// Two cubes can never share a cell: if one is held up by a wall, the other
// can't roll into the space it is still filling, so the pair simply doesn't
// move — same no-op a wall gives a joined block.
const samePt = (p, q) => p.x === q.x && p.y === q.y;
if (samePt(ra.pt, rb.pt)) return { moved: false };
if (samePt(ra.pt, state.block.a) && samePt(rb.pt, state.block.b)) return { moved: false };
const merged = tryMerge(ra.pt, rb.pt);
state.block = merged ?? { mode: 'split', a: ra.pt, b: rb.pt };
return settle(level, state);
@ -254,7 +277,6 @@ export function newState(level) {
const state = {
block: { mode: 'joined', orient: level.start.orient, x: level.start.x, y: level.start.y },
toggleBridges,
broken: new Set(),
status: 'playing',
};
evaluateSwitches(level, state);
@ -267,7 +289,6 @@ export function cloneState(state) {
? { ...state.block }
: { mode: 'split', a: { ...state.block.a }, b: { ...state.block.b } },
toggleBridges: new Map(state.toggleBridges),
broken: new Set(state.broken),
status: state.status,
};
}
@ -285,8 +306,7 @@ export function stateKey(state) {
.sort((p, q) => (p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : 0))
.map(([id, o]) => `${id}:${o ? 1 : 0}`)
.join(',');
const brokenPart = [...state.broken].sort().join(',');
return `${blockPart}#${bridgePart}#${brokenPart}`;
return `${blockPart}#${bridgePart}`;
}
export function isSolved(state) {

View File

@ -9,24 +9,31 @@ goal hole. Fall off the edge and you're back at the start of the level.
a time.
- Standing upright, it takes up one tile. Tip it over and it lies flat across
two tiles — tip it again along its length and it stands back up.
- The goal only accepts the block **standing upright**. Landing on it lying
down just falls through.
- The goal only accepts the block **standing upright**. Rolling over the hole
lying flat is harmless — the block just bridges it.
## Bridges & Switches
- **Bridges** are gaps in the platform that only become solid once their
linked switch is thrown.
- An **orange square switch** opens its bridge permanently the instant the
block touches it, in any orientation.
- A **pale circle switch** only holds its bridge open while the block is
lying down directly on top of it — time your crossing so you're still
covering the switch as you tip onto the bridge.
- An **orange X switch** is heavy: it only throws when the block comes to rest
**standing upright** on it, which is a routing problem in itself.
- An **orange round switch** is soft — any contact throws it, lying down or
even a single split cube. It toggles, so trip it twice and you're back where
you started.
- A **pale bar pad** only holds its bridge open while the block is lying down
directly on top of it — time your crossing so you're still covering the pad
as you tip onto the bridge.
## Fragile Tiles
- Orange, cracked tiles support the block's weight **once**. Stand on one and
it's fine — but come back to that exact tile a second time and it gives
way.
- Orange, cracked tiles take the block's weight spread over two tiles, but not
concentrated on one: stand **upright** on a cracked tile and you go straight
through it.
- So you cross cracked ground lying down — which means shuffling sideways,
since tipping along your own length stands you back up. You can only change
which way you're lying while on solid ground.
- A single split cube is light enough to stand on cracked ground safely.
## Teleporters

View File

@ -152,7 +152,7 @@ function baseEntity(state, army, def) {
orders: [], path: null, pathIdx: 0,
destX: 0, destY: 0, slotX: 0, slotY: 0,
wantPath: false, noPath: false,
stuckTicks: 0, blockedTicks: 0,
stuckTicks: 0, blockedTicks: 0, stuckCheckTick: null, stuckCheckX: 0, stuckCheckY: 0,
// Far enough in the past that a fresh unit is eligible to regenerate immediately.
// A finite sentinel rather than -Infinity, which JSON.stringify turns into null.
lastDamagedTick: -1e9,
@ -822,6 +822,7 @@ function stepMovement(state, rules) {
for (const e of state.entities) {
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot;
e._movingThisTick = false;
if (e.dead || e.isBuilding || e.site) { e.vx = 0; e.vy = 0; continue; }
const def = defOf(rules, e);
@ -870,8 +871,44 @@ function stepMovement(state, rules) {
e.vy = Math.sin(e.heading) * step / dt;
e.x += Math.cos(e.heading) * step;
e.y += Math.sin(e.heading) * step;
e._movingThisTick = true;
}
}
if (step < speed * dt * 0.2) e.stuckTicks++; else e.stuckTicks = 0;
/**
* Stuck detection, run after stepSeparation so it sees where units actually ended up rather
* than where stepMovement merely aimed them. A unit whose intended step this tick was fine on
* paper can still be shoved straight back by the building-clearance clamp in stepSeparation.
*
* This measures NET displacement over a whole `stuckRepathSec` window rather than per-tick
* displacement. A unit wedged against the corner of a wall doesn't sit still it gets nudged
* one way by the clearance clamp, drives back toward its blocked waypoint, gets nudged again
* and every one of those individual ticks moves it plenty. Only the position at the start and
* end of the window tells you it never actually got anywhere.
*
* A path that stops making real progress gets thrown away and re-requested against the
* CURRENT nav grid, which is what makes a unit route around an obstacle it didn't know about
* when the path was first computed (e.g. a building that went up mid-transit) instead of
* grinding against the same blocked waypoint forever.
*/
function finalizeStuck(state, rules) {
const windowTicks = Math.max(1, Math.round((rules.constants.stuckRepathSec ?? 1.0) * rules.constants.tickHz));
for (const e of state.entities) {
if (!e._movingThisTick) { e.stuckCheckTick = null; continue; }
if (e.stuckCheckTick == null) {
e.stuckCheckTick = state.tick; e.stuckCheckX = e.x; e.stuckCheckY = e.y;
continue;
}
if (state.tick - e.stuckCheckTick < windowTicks) continue;
const def = defOf(rules, e);
const moved = Math.hypot(e.x - e.stuckCheckX, e.y - e.stuckCheckY);
const expected = def.speed * (windowTicks / rules.constants.tickHz);
e.stuckCheckTick = state.tick; e.stuckCheckX = e.x; e.stuckCheckY = e.y;
if (moved >= expected * 0.15) { e.stuckTicks = 0; continue; }
e.stuckTicks += windowTicks;
if (e.path) { e.path = null; requestPath(state, e, e.destX, e.destY); }
}
}
@ -1459,6 +1496,7 @@ export function tick(state, rules) {
servicePathQueue(state, rules);
stepMovement(state, rules);
stepSeparation(state, rules);
finalizeStuck(state, rules);
stepCombat(state, rules);
stepProjectiles(state, rules);
stepRegen(state, rules);

View File

@ -13,6 +13,10 @@ export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']);
export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']);
export const TARGET_DOMAINS = new Set(['ground', 'air']);
// A unit/building must always be able to see a bit past its own guns, or it ends up firing
// into fog it hasn't revealed. Applied as a floor over whatever `sight` the def declares.
const SIGHT_RANGE_MARGIN_TILES = 2;
// Procedural painter shapes. A def may only name a shape TAArt knows how to draw;
// this set is duplicated there and the verify script asserts the two agree.
export const PROC_SHAPES = new Set([
@ -204,10 +208,11 @@ export function compileRules(json) {
u.radius = u.radius ?? sc.radius;
u.massClass = sc.mass;
u.footprintTiles = sc.footprint;
u.sightSq = u.sight * u.sight;
u.isBuilding = false;
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
u.maxRange = autoRangeOf(u.weaponDefs);
if (u.maxRange > 0) u.sight = Math.max(u.sight, u.maxRange + SIGHT_RANGE_MARGIN_TILES * c.tileSize);
u.sightSq = u.sight * u.sight;
// Regeneration is per-def data, so any unit can be given it later without code. Rates
// are converted to per-tick here so the sim never divides in its hot loop.
if (u.selfHeal) {
@ -252,9 +257,10 @@ export function compileRules(json) {
b.halfW = (b.footprint.w * c.tileSize) / 2;
b.halfH = (b.footprint.h * c.tileSize) / 2;
b.sight = b.sight ?? 0;
b.sightSq = b.sight * b.sight;
b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]);
b.maxRange = autoRangeOf(b.weaponDefs);
if (b.maxRange > 0) b.sight = Math.max(b.sight, b.maxRange + SIGHT_RANGE_MARGIN_TILES * c.tileSize);
b.sightSq = b.sight * b.sight;
buildable[b.id] = b;
}
const buildingById = indexById(buildings);

View File

@ -324,7 +324,6 @@ export default class TAWorldView {
const frameSize = def.sheetSlot === 'unitSheet' ? sheets.unitFrame : sheets.structureFrame;
const img = this.scene.add.image(e.x, e.y, key, def.frame);
img.setTint(sheets.color);
if (def.isBuilding) {
img.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
} else {
@ -332,14 +331,24 @@ export default class TAWorldView {
}
this._addWorld(img);
// Buildings get a second, initially-invisible image showing the FINISHED sprite,
// drawn just behind `img` (which carries the wireframe while under construction) so
// the two can crossfade as the site progresses. See the site/progress block in render().
let final = null;
if (def.isBuilding) {
final = this.scene.add.image(e.x, e.y, key, def.frame);
final.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
final.setAlpha(0);
this._addWorld(final);
}
let turret = null;
if (!def.isBuilding && def.turretFrame != null) {
turret = this.scene.add.image(e.x, e.y, key, def.turretFrame);
turret.setTint(sheets.color);
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
this._addWorld(turret);
}
s = { img, turret, defId: e.defId, site: e.site };
s = { img, turret, final, defId: e.defId };
this.sprites.set(e.id, s);
return s;
}
@ -349,6 +358,7 @@ export default class TAWorldView {
if (!s) return;
s.img.destroy();
s.turret?.destroy();
s.final?.destroy();
this.sprites.delete(id);
}
@ -378,6 +388,7 @@ export default class TAWorldView {
const s = this._ensureSprite(e);
s.img.setVisible(shown);
if (s.turret) s.turret.setVisible(shown);
if (s.final) s.final.setVisible(shown);
if (!shown) continue;
const x = e.px + (e.x - e.px) * alpha;
@ -389,12 +400,21 @@ export default class TAWorldView {
// Y-sorted actor band; buildings sit just under units sharing a row.
s.img.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
// Build sites show the wireframe frame and fade in as they complete.
if (e.site !== s.site) {
s.img.setFrame(e.site ? (def.buildFrame ?? def.frame) : def.frame);
s.site = e.site;
// Build sites show the wireframe frame. Queued (progress still 0) sits at 50%
// opacity; once work starts the wireframe fades 100%->0% over construction while
// the finished sprite crossfades in underneath, starting at 20% complete.
s.img.setFrame(e.site ? (def.buildFrame ?? def.frame) : def.frame);
s.img.setAlpha(e.site ? (e.progress <= 0 ? 0.5 : Math.max(0, 1 - e.progress)) : 1);
if (s.final) {
if (e.site) {
s.final.setPosition(x, y);
s.final.setDepth(DEPTHS.actor + (y / state.worldH) * 10 - 0.001);
s.final.setAlpha(Math.max(0, Math.min(1, (e.progress - 0.2) / 0.8)));
} else {
s.final.setAlpha(0);
}
}
s.img.setAlpha(e.site ? 0.35 + e.progress * 0.55 : 1);
if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);

View File

@ -16,12 +16,10 @@ asset manifest resolves every entry in the `sheets` map automatically
1. **Units are drawn once, facing right.** `0 rad` is Phaser's `+x` axis, and the renderer
calls `setRotation(heading)` — so a unit needs exactly **one** frame, not eight facings.
Draw it pointing **right**, dead centre in its cell.
2. **Units are drawn in neutral grey.** The renderer applies `setTint(armyColour)` per army.
Paint in greys with the detail carried by *value* (light/dark), not hue; any hue you paint
is multiplied by the team colour and will read as muddy. Keep the brightest highlight near
white so the tint stays vivid.
2. **Units are drawn in full colour.** The renderer no longer applies any army tint — paint
each army's sheet in its finished, final colours (ARM blue, CORE red/orange, etc.).
Terrain is exempt from both: tiles are drawn in full colour and are never rotated or tinted.
Terrain follows the same rule: tiles are drawn in full colour and are never rotated or tinted.
---
@ -82,8 +80,10 @@ at 128px and a 3×3 at 192px — draw the 3×3 buildings to fill the cell and ac
scaled up 1.5×, or supply a larger cell size in the artwork JSON.
Each building has **two** frames: the finished structure, and a **build frame** shown while it
is under construction (the renderer also fades it in by build progress). Draw the build frame
as a skeleton/scaffold version — girders, no panels, no lights.
is under construction. Draw the build frame as a skeleton/scaffold version — girders, no
panels, no lights. The renderer shows it at 50% opacity while a site is queued (placed but no
builder working it yet), then at 100% opacity once work begins, fading it out to 0% as the
build completes; the finished frame crossfades in underneath starting at 20% progress.
| Frame | Building | Footprint | Notes |
|---|---|---|---|

157
tools/bloxorzMap.js Normal file
View File

@ -0,0 +1,157 @@
// Bloxorz ASCII map parser + level design gates. Shared by genBloxorz.js (which
// builds the bank) and by scratch/tuning scripts, so a level can be audited on
// its own without running the whole generator. Pure Node, no Phaser.
//
// See genBloxorz.js for the map legend.
import { loadLevel, solve } from '../src/games/bloxorz/BloxorzLogic.js';
// ── Map parser ───────────────────────────────────────────────────────────────
const BRIDGE_CHARS = '123';
const HEAVY_CHARS = 'ABC';
const SOFT_CHARS = 'abc';
const HOLD_CHARS = 'pqr';
const TELEPORT_CHARS = 'tuv';
export function parseMap(n, name, art, opts = {}) {
const raw = art.replace(/^\n/, '').replace(/\s+$/, '').split('\n');
const indent = Math.min(...raw.filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length));
const lines = raw.map((l) => l.slice(indent));
const rows = lines.length;
const cols = Math.max(...lines.map((l) => l.length));
const openGroups = new Set(opts.open ?? []);
const tiles = [];
let start = null;
let goal = null;
let switchSeq = 0;
const linksFor = (ch, fallback) => (opts.links?.[ch] ?? fallback).map((g) => `b${g}`);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < lines[y].length; x++) {
const ch = lines[y][x];
if (ch === ' ') continue;
if (ch === 'S') { start = { x, y, orient: 'up' }; tiles.push({ x, y, type: 'floor' }); continue; }
if (ch === 'G') { goal = { x, y }; tiles.push({ x, y, type: 'goal' }); continue; }
if (ch === '.') { tiles.push({ x, y, type: 'floor' }); continue; }
if (ch === ':') { tiles.push({ x, y, type: 'floor', split: true }); continue; }
if (ch === '#') { tiles.push({ x, y, type: 'wall' }); continue; }
if (ch === '~') { tiles.push({ x, y, type: 'fragile' }); continue; }
if (BRIDGE_CHARS.includes(ch)) {
const group = BRIDGE_CHARS.indexOf(ch) + 1;
tiles.push({ x, y, type: 'bridge', bridgeId: `b${group}`, initiallyOpen: openGroups.has(group) });
continue;
}
if (TELEPORT_CHARS.includes(ch)) { tiles.push({ x, y, type: 'teleport', teleportId: ch }); continue; }
const asSwitch = (chars, requireOrientation, mode) => {
const group = chars.indexOf(ch) + 1;
switchSeq += 1;
tiles.push({
x, y, type: 'switch', switchId: `s${switchSeq}`,
linkedBridgeIds: linksFor(ch, [group]), requireOrientation, mode,
});
};
if (HEAVY_CHARS.includes(ch)) { asSwitch(HEAVY_CHARS, 'standing', 'toggle'); continue; }
if (SOFT_CHARS.includes(ch)) { asSwitch(SOFT_CHARS, 'any', 'toggle'); continue; }
if (HOLD_CHARS.includes(ch)) { asSwitch(HOLD_CHARS, 'lying', 'momentary'); continue; }
throw new Error(`L${n} "${name}": unknown map character '${ch}' at ${x},${y}`);
}
}
if (!start) throw new Error(`L${n} "${name}": no start (S)`);
if (!goal) throw new Error(`L${n} "${name}": no goal (G)`);
const teleports = new Map();
for (const t of tiles) {
if (t.type === 'teleport') teleports.set(t.teleportId, (teleports.get(t.teleportId) ?? 0) + 1);
}
for (const [id, count] of teleports) {
if (count !== 2) throw new Error(`L${n} "${name}": teleport '${id}' appears ${count}x, must be exactly 2`);
}
const splitCount = tiles.filter((t) => t.split).length;
if (splitCount % 2 !== 0) throw new Error(`L${n} "${name}": odd number of split tiles (${splitCount})`);
const bridgeGroups = new Set(tiles.filter((t) => t.type === 'bridge').map((t) => t.bridgeId));
const linked = new Map();
for (const t of tiles) {
if (t.type !== 'switch') continue;
for (const bid of t.linkedBridgeIds) {
if (!linked.has(bid)) linked.set(bid, new Set());
linked.get(bid).add(t.mode);
}
}
for (const bid of bridgeGroups) {
if (!linked.has(bid)) throw new Error(`L${n} "${name}": bridge ${bid} has no switch`);
if (linked.get(bid).size > 1) throw new Error(`L${n} "${name}": bridge ${bid} mixes toggle and momentary switches`);
}
for (const bid of linked.keys()) {
if (!bridgeGroups.has(bid)) throw new Error(`L${n} "${name}": switch wired to ${bid}, which has no bridge tiles`);
}
return { level: n, name, cols, rows, start, goal, tiles };
}
// ── Design gates ─────────────────────────────────────────────────────────────
const SOLVE_OPTS = { maxStates: 400000 };
const clone = (def) => JSON.parse(JSON.stringify(def));
export function parOf(def) {
return solve(loadLevel(def), SOLVE_OPTS).moves;
}
// A mechanic that the level can be finished without is just decoration. Each
// gate strips one mechanic and demands the level become unsolvable.
function withoutTiles(def, pred) {
const out = clone(def);
out.tiles = out.tiles.filter((t) => !pred(t));
return out;
}
function mapTiles(def, fn) {
const out = clone(def);
out.tiles = out.tiles.map(fn);
return out;
}
export function auditLevel(def) {
const problems = [];
const notes = [];
const has = (pred) => def.tiles.some(pred);
const par = parOf(def);
if (par < 0) { problems.push('unsolvable'); return { par, problems, notes }; }
if (has((t) => t.type === 'bridge')) {
// Bridges gone: the far side must be genuinely cut off.
if (parOf(withoutTiles(def, (t) => t.type === 'bridge')) >= 0) problems.push('bridges are optional');
}
if (has((t) => t.type === 'teleport')) {
if (parOf(mapTiles(def, (t) => (t.type === 'teleport' ? { x: t.x, y: t.y, type: 'floor' } : t))) >= 0) {
problems.push('teleporters are optional');
}
}
if (has((t) => t.split)) {
if (parOf(mapTiles(def, (t) => (t.split ? { ...t, split: false } : t))) >= 0) problems.push('split is optional');
}
if (has((t) => t.type === 'fragile')) {
// Fragile ground has to be load-bearing (you must cross it) AND has to
// actually constrain the route, or it is just orange-coloured floor.
if (parOf(withoutTiles(def, (t) => t.type === 'fragile')) >= 0) problems.push('fragile ground is optional');
const solid = parOf(mapTiles(def, (t) => (t.type === 'fragile' ? { x: t.x, y: t.y, type: 'floor' } : t)));
if (solid === par) notes.push('fragile ground does not change the optimal route');
}
if (has((t) => t.type === 'wall')) {
const noWalls = parOf(withoutTiles(def, (t) => t.type === 'wall'));
if (noWalls === par) notes.push('walls do not change the optimal route');
}
return { par, problems, notes };
}

View File

@ -1,297 +1,477 @@
// Offline generator for the Bloxorz level bank.
//
// Levels are hand-authored (switch/bridge/teleporter/split puzzles need
// designed relational intent that random generation doesn't produce — see
// Rush Hour/Dot Link for where randomize-then-filter DOES work, and note this
// is deliberately NOT that). Each level is built from small composable tile
// helpers below, then solved with BloxorzLogic's BFS solver to compute `par`
// and hard-reject the whole build if any level turns out to be unsolvable.
// Levels are hand-authored as ASCII maps (switch/bridge/teleporter/split
// puzzles need designed relational intent that random generation doesn't
// produce — see Rush Hour/Dot Link for where randomize-then-filter DOES work,
// and note this is deliberately NOT that). Every map is then run through
// BloxorzLogic's BFS solver to compute `par`, and through a set of design gates
// that hard-reject the build if a level is unsolvable, if a mechanic on the
// board turns out to be decorative, or if the difficulty curve regresses.
//
// Legend
// (space) void . floor S start (standing) G goal hole
// # wall ~ fragile : split-flagged floor
// 1 2 3 bridge tiles for groups 1-3
// A B C heavy switch — must come to rest STANDING on it (toggles 1-3)
// a b c soft switch — any contact, incl. a split cube (toggles 1-3)
// p q r hold pad — bridge open only while a LYING block covers it
// t u v teleport pads — each letter must appear exactly twice
//
// Per-level options: { open: [1] } starts those bridge groups open,
// { links: { A: [1, 2] } } wires a switch char to extra bridge groups.
//
// Usage: node tools/genBloxorz.js [outFile]
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadLevel, solve } from '../src/games/bloxorz/BloxorzLogic.js';
import { parseMap, auditLevel } from './bloxorzMap.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[2]
? path.resolve(process.argv[2])
: path.join(__dirname, '../data/bloxorz.json');
// ── Tile helpers ─────────────────────────────────────────────────────────────
const LEVELS = [];
const lvl = (n, name, art, opts) => { LEVELS.push(parseMap(n, name, art, opts)); };
const floor = (x, y, extra = {}) => ({ x, y, type: 'floor', ...extra });
const splitFloor = (x, y) => floor(x, y, { split: true });
const wallTile = (x, y) => ({ x, y, type: 'wall' });
const goalTile = (x, y) => ({ x, y, type: 'goal' });
const fragile = (x, y) => ({ x, y, type: 'fragile' });
const bridge = (x, y, bridgeId, initiallyOpen = false) => ({ x, y, type: 'bridge', bridgeId, initiallyOpen });
const hardSwitch = (x, y, switchId, bridgeIds) => ({
x, y, type: 'switch', switchId, linkedBridgeIds: bridgeIds, requireOrientation: 'any', mode: 'toggle',
});
const softSwitch = (x, y, switchId, bridgeIds) => ({
x, y, type: 'switch', switchId, linkedBridgeIds: bridgeIds, requireOrientation: 'lying', mode: 'momentary',
});
const teleport = (x, y, teleportId) => ({ x, y, type: 'teleport', teleportId });
// ── Act I (1-6): rolling. Shape, parity and the 1x2 footprint. ───────────────
function rect(x0, y0, w, h, extra = {}) {
const out = [];
for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) out.push(floor(x, y, extra));
return out;
}
lvl(1, 'First Roll', `
.......
.S.....
.......
.....G.
.......
`);
// Later entries win on coordinate collision — lets a base rect/fill be
// selectively overridden with bridges/switches/fragile/goal/etc.
function compile(tileList) {
const map = new Map();
for (const t of tileList) map.set(`${t.x},${t.y}`, t);
return [...map.values()];
}
lvl(2, 'Mind the Gap', `
.........
.S.......
.... ...
.... ...
.........
.......G.
`);
function level(n, name, cols, rows, start, goal, tileList) {
return { level: n, name, cols, rows, start, goal, tiles: compile(tileList) };
}
lvl(3, 'Right Angle', `
.....
.S...
.....
..
..
.....
...G.
.....
`);
// A single straight horizontal corridor at row `y`, columns 0..length. Rolling
// right from a standing start at column 0 lands STANDING on columns 0,3,6,9…
// and LYING on pairs (1,2),(4,5),(7,8)… — every level below places switches,
// bridges, fragile tiles, teleporters and split-flags using that fixed
// arithmetic, and always ends `length` on a multiple of 3 so the final
// approach lands standing exactly on the goal. `extraRows` widens the board
// so branch tiles (e.g. a switch reached by branching off the row) still fit.
function corridorLevel(n, name, { length, y = 2, rows = 5, overrides = [] }) {
const cols = length + 1;
const tiles = [];
for (let x = 0; x <= length; x++) tiles.push(floor(x, y));
tiles.push(...overrides);
const hasGoal = overrides.some((o) => o.type === 'goal');
const goalPos = hasGoal ? overrides.find((o) => o.type === 'goal') : { x: length, y };
if (!hasGoal) tiles.push(goalTile(length, y));
return level(n, name, cols, rows, { x: 0, y, orient: 'up' }, { x: goalPos.x, y: goalPos.y }, tiles);
}
lvl(4, 'Narrow Pass', `
.... .....
.S.. .....
............
.... .....
..G..
`);
// ── Levels 1-6: plain rolling ────────────────────────────────────────────────
lvl(5, 'The Ring', `
.........
.S.......
... ...
... ...
... ...
.......G.
.........
`);
const L1 = level(1, 'Warm Up', 6, 5, { x: 1, y: 1, orient: 'up' }, { x: 4, y: 3 }, [
...rect(0, 0, 6, 5), goalTile(4, 3),
]);
const L2 = level(2, 'Stretch Out', 7, 5, { x: 1, y: 1, orient: 'up' }, { x: 5, y: 3 }, [
...rect(0, 0, 7, 5), goalTile(5, 3),
]);
const L3 = level(3, 'Open Floor', 8, 6, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 4 }, [
...rect(0, 0, 8, 6), goalTile(6, 4),
]);
const L4 = level(4, 'The Corner', 9, 7, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 5 }, [
...rect(0, 0, 5, 7), ...rect(0, 4, 9, 3), goalTile(7, 5),
]);
const L5 = level(5, 'Crossroads', 9, 9, { x: 4, y: 1, orient: 'up' }, { x: 1, y: 4 }, [
...rect(3, 0, 3, 9), ...rect(0, 3, 9, 3), goalTile(1, 4),
]);
const L6 = level(6, 'Staircase', 10, 9, { x: 1, y: 1, orient: 'up' }, { x: 8, y: 7 }, [
...rect(0, 0, 4, 3), ...rect(2, 1, 4, 4), ...rect(4, 3, 4, 4), ...rect(6, 5, 4, 4), goalTile(8, 7),
]);
lvl(6, 'Zigzag', `
.....
.S...
.....
......
......
......
......
......
....G.
......
`);
// ── Levels 7-12: holes, ledges, static causeways ─────────────────────────────
// ── Act II (7-12): switches and bridges. ─────────────────────────────────────
const L7 = level(7, 'Mind the Gap', 8, 6, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 4 },
compile(rect(0, 0, 8, 6)).filter((t) => !(t.x === 4 && t.y === 2)).concat(goalTile(6, 4)));
const L8 = level(8, 'Pinwheel', 9, 7, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 5 },
compile(rect(0, 0, 9, 7))
.filter((t) => !([[4, 2], [4, 3], [4, 4], [3, 3], [5, 3]].some(([hx, hy]) => t.x === hx && t.y === hy)))
.concat(goalTile(7, 5)));
const L9 = level(9, 'Causeway', 9, 3, { x: 1, y: 1, orient: 'up' }, { x: 7, y: 1 }, [
...rect(0, 0, 3, 3), ...rect(3, 1, 3, 1), ...rect(6, 0, 3, 3), goalTile(7, 1),
]);
const L10 = level(10, 'Bent Causeway', 8, 8, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 6 }, [
...rect(0, 0, 3, 3), ...rect(3, 1, 3, 2), ...rect(4, 1, 2, 5), ...rect(3, 5, 5, 3), goalTile(6, 6),
]);
const L11 = level(11, 'Scattered', 10, 8, { x: 1, y: 1, orient: 'up' }, { x: 8, y: 6 },
compile(rect(0, 0, 10, 8))
.filter((t) => !([[3, 3], [6, 2], [4, 6], [7, 5], [2, 6]].some(([hx, hy]) => t.x === hx && t.y === hy)))
.concat(goalTile(8, 6)));
const L12 = level(12, 'Tightrope', 8, 8, { x: 1, y: 1, orient: 'up' }, { x: 6, y: 6 }, [
...rect(0, 0, 3, 3), ...rect(1, 1, 3, 3), ...rect(2, 2, 3, 3),
...rect(3, 3, 3, 3), ...rect(4, 4, 3, 3), ...rect(5, 5, 3, 3),
goalTile(6, 6),
]);
// The heavy X switch only counts if the block comes to rest upright on it.
lvl(7, 'Heavy Switch', `
..... .....
.S... .....
.....11.G...
..... .....
..A.. .....
`);
// ── Levels 13-18: switches + bridges ─────────────────────────────────────────
// Soft switches trip on any contact — including in passing. Trip both and you
// have toggled the bridge shut again.
lvl(8, 'Light Touch', `
.........
.S.......
..a...a..
.........
1
1
.........
....G....
.........
`);
const L13 = corridorLevel(13, 'Flip the Switch', {
length: 9,
overrides: [hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false)],
});
const L14 = corridorLevel(14, 'Weight and See', {
length: 6,
overrides: [softSwitch(2, 2, 's1', ['b1']), bridge(3, 2, 'b1', false)],
});
const L15 = corridorLevel(15, 'Double Gate', {
length: 12,
overrides: [hardSwitch(2, 2, 's1', ['b1', 'b2']), bridge(4, 2, 'b1', false), bridge(8, 2, 'b2', false)],
});
const L16 = corridorLevel(16, 'Two Keys', {
length: 15,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
hardSwitch(7, 2, 's2', ['b2']), bridge(10, 2, 'b2', false),
],
});
const L17 = corridorLevel(17, 'Soft Landing', {
length: 15,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
softSwitch(8, 2, 's2', ['b2']), bridge(9, 2, 'b2', false),
],
});
const L18 = corridorLevel(18, 'Three Gates', {
length: 18,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
softSwitch(8, 2, 's2', ['b2']), bridge(9, 2, 'b2', false),
hardSwitch(13, 2, 's3', ['b3']), bridge(16, 2, 'b3', false),
],
});
// A hold pad only counts while a LYING block is on it, so the bridge is open
// for exactly the one move that rolls off the pad onto it.
lvl(9, 'Hold the Pad', `
....
.S..
....
...p1.....
.....
..G..
`);
// ── Levels 19-24: fragile tiles ───────────────────────────────────────────────
lvl(10, 'The Long Way', `
...... ...
.S.... ...
...... ...
... ...
... ...
......111...
...... ...
..A... .G.
...... ...
`);
const L19 = corridorLevel(19, 'Cracked Floor', { length: 9, overrides: [fragile(3, 2)] });
const L20 = corridorLevel(20, 'Eggshells', { length: 12, overrides: [fragile(3, 2), fragile(7, 2)] });
const L21 = corridorLevel(21, 'No Turning Back', {
length: 15,
overrides: [fragile(3, 2), fragile(6, 2), fragile(10, 2)],
});
const L22 = corridorLevel(22, 'Fragile Gate', {
length: 12,
overrides: [hardSwitch(2, 2, 's1', ['b1']), bridge(5, 2, 'b1', false), fragile(8, 2)],
});
const L23 = corridorLevel(23, 'Soft and Thin', {
length: 15,
overrides: [fragile(3, 2), softSwitch(7, 2, 's1', ['b1']), bridge(9, 2, 'b1', false), fragile(11, 2)],
});
const L24 = corridorLevel(24, 'Every Step Counts', {
length: 18,
overrides: [
fragile(3, 2), hardSwitch(6, 2, 's1', ['b1']), bridge(8, 2, 'b1', false),
fragile(11, 2), softSwitch(14, 2, 's2', ['b2']), bridge(15, 2, 'b2', false),
],
});
lvl(11, 'Two Gates', `
.... ..... ....
.S.. ..B.. ....
....11.....22.G..
..A. ..... ....
`);
// ── Levels 25-30: teleporters ─────────────────────────────────────────────────
// Bridge 1 starts open and bridge 2 shut; the switch in the middle room swaps
// them, so the way in is also the way you give up.
lvl(12, 'Shuttle', `
.... .... ....
.S.. .A.. ....
....11......22..G...
.... .... ....
`, { open: [1], links: { A: [1, 2] } });
const L25 = corridorLevel(25, 'Shortcut', { length: 13, overrides: [teleport(3, 2, 't1'), teleport(10, 2, 't1')] });
const L26 = corridorLevel(26, 'Two Hops', {
length: 21,
overrides: [teleport(3, 2, 't1'), teleport(9, 2, 't1'), teleport(12, 2, 't2'), teleport(18, 2, 't2')],
});
const L27 = corridorLevel(27, 'Triple Hop', {
length: 27,
overrides: [
teleport(3, 2, 't1'), teleport(9, 2, 't1'),
teleport(12, 2, 't2'), teleport(18, 2, 't2'),
teleport(21, 2, 't3'), teleport(24, 2, 't3'),
],
});
const L28 = corridorLevel(28, 'Gate and Go', {
length: 15,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
teleport(6, 2, 't1'), teleport(12, 2, 't1'),
],
});
const L29 = corridorLevel(29, 'Brittle Hop', {
length: 12,
overrides: [fragile(3, 2), teleport(6, 2, 't1'), teleport(9, 2, 't1')],
});
const L30 = corridorLevel(30, 'All at Once', {
length: 18,
overrides: [
softSwitch(2, 2, 's1', ['b1']), bridge(3, 2, 'b1', false),
fragile(6, 2), teleport(9, 2, 't1'), teleport(15, 2, 't1'),
],
});
// ── Act III (13-18): fragile ground. Standing upright on orange puts the block
// straight through it, so these are orientation puzzles: you cross lying, which
// means shuffling sideways, and you can only change axis on solid ground. ────
// ── Levels 31-36: split tiles ─────────────────────────────────────────────────
lvl(13, 'Two at a Time', `
....
.S..
....
~~
~~
....
..G.
....
`);
const L31 = corridorLevel(31, 'Split Decision', {
length: 10,
overrides: [splitFloor(4, 2), splitFloor(5, 2)],
});
// NOTE: a split-trigger-then-immediate-remerge costs one extra column versus
// a plain tip (the remerge lands one column further right than a same-shape
// non-split roll would have) — lengths below are chosen with that in mind.
const L32 = corridorLevel(32, 'Split and Gate', {
length: 19,
overrides: [
hardSwitch(2, 2, 's1', ['b1']), bridge(4, 2, 'b1', false),
splitFloor(7, 2), splitFloor(8, 2),
],
});
const L33 = corridorLevel(33, 'Split and Crack', {
length: 15,
overrides: [fragile(3, 2), splitFloor(6, 2), splitFloor(7, 2)],
});
const L34 = corridorLevel(34, 'Split and Hop', {
length: 16,
overrides: [splitFloor(4, 2), splitFloor(5, 2), teleport(7, 2, 't1'), teleport(13, 2, 't1')],
});
lvl(14, 'Cracked Gate', `
..... ..~~~..
.S... ..~~~..
.....11..~~~G.
..... ..~~~..
..A.. ..~~~..
`);
// A wide open room around the split point (not a tight corridor) so the two
// independent units have plenty of room to sidestep the wall pillars and
// reconverge — the BFS solver (not hand-traced choreography) is the actual
// proof this works.
const L35 = level(35, 'Around the Pillar', 16, 7, { x: 1, y: 3, orient: 'up' }, { x: 14, y: 3 }, [
...rect(0, 2, 5, 3), // entry platform, rows 2-4
splitFloor(5, 3), splitFloor(6, 3), // split trigger, row 3
...rect(5, 0, 7, 7), // big open room, rows 0-6, cols 5-11
wallTile(8, 2), wallTile(8, 3), wallTile(8, 4), // pillar splitting the room
...rect(12, 2, 4, 3), // exit platform, rows 2-4
goalTile(14, 3),
]);
lvl(15, 'Corner Ice', `
....~~~~~~
.S..~~~~~~
....~~~~~~
~~~~~~
~~~~~~
......
...G..
`);
// Open rooms throughout (not a tight single-row corridor) so switch/fragile/
// split placement doesn't depend on exact tip arithmetic — only reachability
// within each room, which the BFS solver confirms directly.
const L36 = level(36, 'Grand Finale', 22, 5, { x: 1, y: 1, orient: 'up' }, { x: 19, y: 2 }, [
...rect(0, 0, 5, 4), // room A: start + switch
hardSwitch(2, 2, 's1', ['b1']),
bridge(5, 1, 'b1', false), // gate into room B
...rect(6, 0, 5, 4), // room B: fragile + split trigger
fragile(7, 2),
splitFloor(9, 1), splitFloor(10, 1),
...rect(11, 0, 7, 5), // room C: pillar maze
wallTile(14, 1), wallTile(14, 2), wallTile(14, 3),
...rect(18, 0, 4, 4), // room D: goal
goalTile(19, 2),
]);
lvl(16, 'Thin Ice', `
....~~~~....
.S..~~~~..G.
....~~~~....
`);
const LEVELS = [
L1, L2, L3, L4, L5, L6,
L7, L8, L9, L10, L11, L12,
L13, L14, L15, L16, L17, L18,
L19, L20, L21, L22, L23, L24,
L25, L26, L27, L28, L29, L30,
L31, L32, L33, L34, L35, L36,
];
lvl(17, 'Collapse', `
..... .~~~~~..
.S... .~~~~~..
.....11.~~~~~G.
..... .~~~~~..
..A.. .~~~~~..
`);
// ── Solve-gate + write ────────────────────────────────────────────────────────
lvl(18, 'Stepping Stones', `
....~~....~~....
.S..~~....~~..G.
....~~....~~....
`);
console.log(`[bloxorz] solving ${LEVELS.length} hand-authored levels…`);
// ── Act IV (19-24): teleport pads. Only a STANDING landing warps. ────────────
lvl(19, 'Warp', `
.......
.S.....
...t...
.......
.......
...t...
.......
..G....
`);
lvl(20, 'Brittle Warp', `
......
.S....
...t..
......
~~~~~~~
~~t~~~~
~~~~~~~
~~~~G..
`);
lvl(21, 'Crossed Wires', `
...... ......
.S..t. ..u...
...... ......
...t..
......
..u...
...G..
......
`);
lvl(22, 'Round Trip', `
...... ......
.S.... ..t...
...t.. ......
....u.
......
..u...
......
...G..
......
`);
lvl(23, 'Warp Gate', `
..... ......
.S...11...t..
..... ......
..A.. ......
......
..t...
....G.
......
`);
lvl(24, 'Warp Maze', `
..... ......
.S... ..t...
.....11......
..A.. ......
..... ......
...... .....
..t... ..u..
...... .....
...u.. ..G..
...... .....
`);
// ── Act V (25-30): splitting. Two cubes, one input between them. The ground is
// narrow on purpose — an open room lets a joined block fix its parity by
// walking around, which would make the split optional. ──────────────────────
// The pair rejoins on the very next move, one cell further along than a plain
// tip would have landed: splitting is a parity shift, and this corridor's goal
// sits on the one parity a rolling block can never reach.
lvl(25, 'Break Apart', `
....
.S..
....
.
.
....::....G
`);
lvl(26, 'Split Gate', `
....
.S..
....
.
.
....::....A
1
1
.....
..G..
.....
`);
lvl(27, 'Split Warp', `
....
.S..
....
.
.
....::..t..
......
..t...
....G.
......
`);
// Here the wall catches one cube while the other rolls on, so the two travel
// the board separately and have to be lined back up to fuse.
lvl(28, 'Held Back', `
S::........
.#..##...#.
.......#...
........#..
...........
........#G.
`);
lvl(29, 'Split Ice', `
....
.S..
....
~~
~~
~~
......::....G
`);
lvl(30, 'Reassembly', `
S::........
..#.....#..
.......#...
...#....#..
...........
........#G.
`);
// ── Act VI (31-36): everything at once, on bigger boards. ────────────────────
lvl(31, 'Warp and Crack', `
..... ..~~~..
.S...11..~~~..
..... ..~~~..
..A.. ..~~~..
..... ..~t~..
.......
...t...
.......
..G....
.......
`);
lvl(32, 'Gatehouse', `
..... ..~~~.. .....
.S... ..~~~.. .....
.....11..~~~..22..G..
..... ..~~~.. .....
..A.. ..B~~.. .....
`);
lvl(33, 'Pillars', `
S::..........
..#.......#..
.#...........
.............
.............
...#.#.......
.........#.#.
........#..G.
`);
lvl(34, 'The Gauntlet', `
..... ..~~~~..
.S...11..~~~~..
..... ..~~~~..
..A.. ..~~~~..
..... ..~~~~..
.
.
....::....G
`);
lvl(35, 'Two Wings', `
..A..
.....
.
..... .......... .....
.S...11..........22..G..
..... .......... .....
.
.....
..B..
`);
lvl(36, 'Grand Finale', `
..... .......
.S...11...t...
..... .......
..A.. .......
..~~~~..
..~~~~..
t.~~~~..
..~~~~..
..~~~~..
.
.
....::....G
`);
// ── Solve-gate + write ───────────────────────────────────────────────────────
console.log(`[bloxorz] auditing ${LEVELS.length} hand-authored levels…`);
let failed = 0;
const levels = LEVELS.map((def) => {
const compiled = loadLevel(def);
const { moves } = solve(compiled, { maxStates: 300000 });
if (moves < 0) {
console.error(` ✗ level ${def.level} "${def.name}" is UNSOLVABLE`);
const { par, problems, notes } = auditLevel(def);
if (problems.length) {
console.error(` ✗ level ${def.level} "${def.name}" — ${problems.join(', ')}`);
failed++;
return { ...def, par: -1 };
} else {
const tail = notes.length ? ` (note: ${notes.join('; ')})` : '';
console.log(` ✓ level ${def.level} "${def.name}" — ${def.cols}x${def.rows}, par ${par}${tail}`);
}
console.log(` ✓ level ${def.level} "${def.name}" — par ${moves}`);
return { ...def, par: moves };
return { ...def, par };
});
// The whole point of the overhaul: difficulty has to actually climb. Compare
// six-level acts rather than adjacent levels, which leaves room for a breather
// after a hard one without letting a whole act flatten out.
const acts = [];
for (let i = 0; i < levels.length; i += 6) {
const slice = levels.slice(i, i + 6);
acts.push(slice.reduce((s, l) => s + l.par, 0) / slice.length);
}
acts.forEach((avg, i) => console.log(`[bloxorz] act ${i + 1} average par ${avg.toFixed(1)}`));
for (let i = 1; i < acts.length; i++) {
if (acts[i] <= acts[i - 1]) {
console.error(` ✗ act ${i + 1} (avg par ${acts[i].toFixed(1)}) is no harder than act ${i} (${acts[i - 1].toFixed(1)})`);
failed++;
}
}
if (failed > 0) {
console.error(`[bloxorz] ${failed} level(s) unsolvable — refusing to write ${OUT_FILE}`);
console.error(`[bloxorz] ${failed} problem(s) — refusing to write ${OUT_FILE}`);
process.exit(1);
}

View File

@ -13,8 +13,11 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
loadLevel, newState, applyMove, solve,
loadLevel, newState, applyMove, solve, previewRoll, DIR_LIST,
} from '../src/games/bloxorz/BloxorzLogic.js';
import {
EX, EY, depthOf, boxesForBlock, boxCorners, rollPoints, visibleFaces,
} from '../src/games/bloxorz/BloxorzIso.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FILE = path.join(__dirname, '../data/bloxorz.json');
@ -35,7 +38,7 @@ console.log(`[verify] ${levels.length} levels`);
check('bank has 36 levels', levels.length === 36, `found ${levels.length}`);
let prevPar = 0;
const actPars = [];
for (const def of levels) {
const inBounds = (x, y) => x >= 0 && x < def.cols && y >= 0 && y < def.rows;
@ -54,6 +57,7 @@ for (const def of levels) {
switchLinks.get(bid).add(t.mode);
}
}
if (t.type === 'switch' && !['standing', 'any', 'lying'].includes(t.requireOrientation)) boundsOk = false;
if (t.type === 'teleport') teleportCounts.set(t.teleportId, (teleportCounts.get(t.teleportId) ?? 0) + 1);
if (t.split) splitCount++;
}
@ -79,9 +83,22 @@ for (const def of levels) {
check(`L${def.level} "${def.name}": solvable`, moves >= 0, `solve() returned moves=${moves}`);
check(`L${def.level}: par matches fresh solve`, moves === def.par, `par=${def.par} solve=${moves}`);
prevPar = def.par;
actPars.push(def.par);
}
void prevPar;
// The bank is built as six six-level acts that have to get harder. Compare act
// averages rather than adjacent levels, so an act can still open with a
// breather without the whole curve flattening out.
const actAvg = [];
for (let i = 0; i < actPars.length; i += 6) {
const slice = actPars.slice(i, i + 6);
actAvg.push(slice.reduce((a, b) => a + b, 0) / slice.length);
}
for (let i = 1; i < actAvg.length; i++) {
check(`act ${i + 1} is harder than act ${i}`, actAvg[i] > actAvg[i - 1],
`${actAvg[i - 1].toFixed(1)} -> ${actAvg[i].toFixed(1)}`);
}
check('no two levels share a name', new Set(levels.map((l) => l.name)).size === levels.length);
// ── Engine unit tests ────────────────────────────────────────────────────────
@ -93,8 +110,9 @@ const wall = (x, y) => ({ x, y, type: 'wall' });
const goal = (x, y) => ({ x, y, type: 'goal' });
const fragileT = (x, y) => ({ x, y, type: 'fragile' });
const bridgeT = (x, y, id, open = false) => ({ x, y, type: 'bridge', bridgeId: id, initiallyOpen: open });
const hardSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'any', mode: 'toggle' });
const softSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'lying', mode: 'momentary' });
const softSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'any', mode: 'toggle' });
const heavySw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'standing', mode: 'toggle' });
const holdPad = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'lying', mode: 'momentary' });
const tp = (x, y, id) => ({ x, y, type: 'teleport', teleportId: id });
// -- Tip-over physics: standing -> lying --
@ -134,12 +152,18 @@ const tp = (x, y, id) => ({ x, y, type: 'teleport', teleportId: id });
check('rolling off the platform edge is fatal', res.dead === true && s.status === 'dead');
}
// -- Goal only supports a standing landing --
// -- Only a standing landing drops into the goal; lying just bridges it --
{
const lvl = synthLevel([floor(0, 0), goal(1, 0)], { x: 0, y: 0, orient: 'up' });
const lvl = synthLevel([floor(0, 0), goal(1, 0), floor(2, 0)], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
const res = applyMove(lvl, s, 'right'); // lands lying across [1,2]; 2 is void, 1 is goal (needs 'up')
check('a lying landing on/through the goal falls through (fatal)', res.dead === true);
const res = applyMove(lvl, s, 'right'); // lands lying across the goal [1] and floor [2]
check('a block lying across the goal bridges it instead of falling in',
res.dead !== true && s.status === 'playing', JSON.stringify(s.block));
const lvl1b = synthLevel([floor(0, 0), goal(1, 0)], { x: 0, y: 0, orient: 'up' });
const s1b = newState(lvl1b);
const res1b = applyMove(lvl1b, s1b, 'right'); // lying across the goal [1] and void [2]
check('a lying landing half over the void is still fatal, goal or not', res1b.dead === true);
const lvl2 = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), goal(3, 0)], { x: 0, y: 0, orient: 'up' });
const s2 = newState(lvl2);
@ -148,46 +172,78 @@ const tp = (x, y, id) => ({ x, y, type: 'teleport', teleportId: id });
check('a standing landing exactly on the goal wins', s2.status === 'won');
}
// -- Fragile tiles: safe once, fatal on a second (already-broken) visit --
// -- Fragile ground: safe lying, straight through it standing --
{
const lvl = synthLevel([floor(0, 0), fragileT(1, 0), floor(2, 0)], { x: 0, y: 0, orient: 'up' });
const s = { block: { mode: 'joined', orient: 'up', x: 0, y: 0 }, toggleBridges: new Map(), broken: new Set(), status: 'playing' };
// Simulate having already broken (1,0) on a prior visit, then try to land on it again.
s.broken.add('1,0');
const res = applyMove(lvl, s, 'right'); // lying[1,2] overlaps the broken cell
check('landing on an already-broken fragile tile is fatal', res.dead === true);
const lvl = synthLevel([floor(0, 0), fragileT(1, 0), fragileT(2, 0), floor(3, 0)], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
const res = applyMove(lvl, s, 'right'); // lying across both fragile cells
check('a lying block rests on fragile ground safely', res.dead !== true && s.status === 'playing');
const res2 = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (solid) — fine
check('rolling off fragile onto solid ground is fine', res2.dead !== true && s.status === 'playing');
}
{
const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), fragileT(3, 0), floor(4, 0), floor(5, 0)], { x: 0, y: 0, orient: 'up' });
const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), fragileT(3, 0), floor(4, 0)], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2]
applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (fragile) — first visit, safe
check('first standing visit to a fragile tile survives', s.status === 'playing');
check('fragile tile is marked broken after the first standing visit', s.broken.has('3,0'));
const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, upright on fragile
check('standing upright on fragile ground drops the block', res.dead === true && s.status === 'dead');
}
{
// A lone split cube is half the weight, so it may stand on fragile ground.
const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(1, 1), fragileT(2, 1)], { x: 0, y: 0, orient: 'up' });
const s = {
block: { mode: 'split', a: { x: 0, y: 0 }, b: { x: 1, y: 1 } },
toggleBridges: new Map(), status: 'playing',
};
const res = applyMove(lvl, s, 'right'); // a -> (1,0) solid, b -> (2,1) fragile
check('a split cube is light enough to stand on fragile ground',
res.dead !== true && s.status === 'playing' && s.block.mode === 'split', JSON.stringify(s.block));
}
// -- Hard switch: any orientation, persistent --
// -- Heavy switch: only counts standing upright on it --
{
const lvl = synthLevel([
floor(0, 0), hardSw(1, 0, 's1', ['b1']), floor(2, 0), bridgeT(3, 0, 'b1', false), floor(4, 0),
floor(0, 0), floor(1, 0), heavySw(2, 0, 's1', ['b1']), bridgeT(3, 0, 'b1', false), floor(4, 0),
], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the hard switch
check('hard switch opens its bridge on contact (any orientation)', s.toggleBridges.get('b1') === true);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], LYING across the heavy switch
check('a lying block does not press a heavy switch', s.toggleBridges.get('b1') !== true);
const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, the still-closed bridge
check('the heavy-switch bridge is still shut for a lying press', res.dead === true);
const lvl2 = synthLevel([
floor(0, 0), floor(1, 0), heavySw(2, 0, 's1', ['b1']), bridgeT(3, 0, 'b1', false), floor(4, 0),
floor(2, 1), floor(2, 2), floor(2, 3),
], { x: 2, y: 3, orient: 'up' });
const s2 = newState(lvl2);
applyMove(lvl2, s2, 'up'); // standing (2,3) -> lying (2,1)-(2,2)
applyMove(lvl2, s2, 'up'); // -> standing on (2,0), the heavy switch
check('standing upright on a heavy switch opens its bridge',
s2.block.orient === 'up' && s2.block.y === 0 && s2.toggleBridges.get('b1') === true, JSON.stringify(s2.block));
}
// -- Soft switch: any contact, persistent --
{
const lvl = synthLevel([
floor(0, 0), softSw(1, 0, 's1', ['b1']), floor(2, 0), bridgeT(3, 0, 'b1', false), floor(4, 0),
], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the soft switch
check('soft switch opens its bridge on contact (any orientation)', s.toggleBridges.get('b1') === true);
const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, onto the now-open bridge
check('hard-switch bridge stays open for a later crossing', res.dead !== true && s.status === 'playing');
check('soft-switch bridge stays open for a later crossing', res.dead !== true && s.status === 'playing');
}
// -- Soft (momentary) switch: open only while covered, recloses immediately --
// -- Hold pad (momentary): open only while covered, recloses immediately --
{
const lvl = synthLevel([
floor(0, 0), floor(1, 0), softSw(2, 0, 's1', ['b1']), bridgeT(3, 0, 'b1', false), floor(4, 0), floor(5, 0),
floor(0, 0), floor(1, 0), holdPad(2, 0, 's1', ['b1']), bridgeT(3, 0, 'b1', false), floor(4, 0), floor(5, 0),
], { x: 0, y: 0, orient: 'up' });
const s = newState(lvl);
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the soft switch (col2)
applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the hold pad (col2)
const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, crossing the bridge in the same instant
check('soft-switch bridge is passable while still covered from the prior landing', res.dead !== true && s.status === 'playing');
check('soft-switch bridge recloses once the block leaves', s.toggleBridges.get('b1') !== true);
check('hold-pad bridge is passable while still covered from the prior landing', res.dead !== true && s.status === 'playing');
check('hold-pad bridge recloses once the block leaves', s.toggleBridges.get('b1') !== true);
}
// -- Teleport relocates a standing landing --
@ -231,24 +287,99 @@ const tp = (x, y, id) => ({ x, y, type: 'teleport', teleportId: id });
const lvl = synthLevel([floor(5, 5), wall(6, 5), floor(6, 6), floor(7, 5)], { x: 5, y: 5, orient: 'up' });
const s = {
block: { mode: 'split', a: { x: 5, y: 5 }, b: { x: 6, y: 5 } },
toggleBridges: new Map(), broken: new Set(), status: 'playing',
toggleBridges: new Map(), status: 'playing',
};
applyMove(lvl, s, 'right');
const { a, b } = s.block;
check('a wall-blocked split unit stays put while its partner moves on', a.x === 5 && a.y === 5 && b.x === 7 && b.y === 5, JSON.stringify(s.block));
}
// -- Two cubes can never end up stacked in one cell --
{
const lvl = synthLevel([floor(0, 0), floor(1, 0), wall(2, 0)], { x: 0, y: 0, orient: 'up' });
const s = {
block: { mode: 'split', a: { x: 0, y: 0 }, b: { x: 1, y: 0 } },
toggleBridges: new Map(), status: 'playing',
};
const res = applyMove(lvl, s, 'right'); // b is wall-blocked, so a can't take its cell
check('a cube cannot roll into the cell its wall-blocked partner still fills',
res.moved === false && s.block.a.x === 0 && s.block.b.x === 1, JSON.stringify(s.block));
}
// -- Either split unit falling is fatal for the whole run --
{
const lvl = synthLevel([floor(0, 0), floor(1, 0)], { x: 0, y: 0, orient: 'up' });
const s = {
block: { mode: 'split', a: { x: 0, y: 0 }, b: { x: 1, y: 0 } },
toggleBridges: new Map(), broken: new Set(), status: 'playing',
toggleBridges: new Map(), status: 'playing',
};
const res = applyMove(lvl, s, 'down'); // both units roll off into the void
check('either split unit falling ends the run', res.dead === true && s.status === 'dead');
}
// ── Renderer geometry (BloxorzIso is Phaser-free, so it runs here) ───────────
// The rolling animation is only faithful if a 90-degree tip about the leading
// ground edge lands the block exactly where the engine says it goes — this is
// what ties the renderer to the single source of truth.
{
const canon = (pts) => pts
.map((p) => [p.x, p.y, p.z].map((v) => v.toFixed(6)).join(','))
.sort()
.join(' | ');
for (const orient of ['up', 'x', 'y']) {
for (const dir of DIR_LIST) {
const block = { mode: 'joined', orient, x: 5, y: 4 };
const box = boxesForBlock(block)[0];
const rolled = canon(rollPoints(boxCorners(box), dir, box, 1));
const target = canon(boxCorners(boxesForBlock(previewRoll(block, dir))[0]));
check(`rolling a '${orient}' block ${dir} lands on the engine's pose`, rolled === target);
}
}
// Rigid body: every edge length is preserved through the whole sweep,
// including past 90 degrees where the fall animation keeps turning.
const box = boxesForBlock({ mode: 'joined', orient: 'up', x: 0, y: 0 })[0];
const start = boxCorners(box);
const EDGES = [[0, 1], [0, 2], [0, 4], [3, 1], [3, 2], [3, 7], [5, 1], [5, 4], [5, 7], [6, 2], [6, 4], [6, 7]];
const len = (p, q) => Math.hypot(p.x - q.x, p.y - q.y, p.z - q.z);
let rigid = true;
for (const t of [0.13, 0.4, 0.77, 1, 1.6, 2.1]) {
const pts = rollPoints(start, 'right', box, t);
for (const [a, b] of EDGES) {
if (Math.abs(len(start[a], start[b]) - len(pts[a], pts[b])) > 1e-9) rigid = false;
}
}
check('the block stays rigid through the entire roll and tumble', rigid);
check('a lying block is one cell tall, a standing block two', (() => {
const up = boxesForBlock({ mode: 'joined', orient: 'up', x: 0, y: 0 })[0];
const lying = boxesForBlock({ mode: 'joined', orient: 'x', x: 0, y: 0 })[0];
return up.z1 === 2 && lying.z1 === 1 && lying.x1 - lying.x0 === 2;
})());
check('split cubes render as two separate inset 1x1x1 pieces', (() => {
const cubes = boxesForBlock({ mode: 'split', a: { x: 1, y: 1 }, b: { x: 4, y: 1 } });
return cubes.length === 2 && cubes.every((c) => c.z1 === 1 && c.x1 - c.x0 < 1 && c.x1 - c.x0 > 0.8);
})());
// Painter's algorithm precondition: both grid axes must recede down-screen,
// otherwise ascending depthOf() no longer draws far tiles first.
check('projection is non-degenerate', Math.abs(EX.x * EY.y - EX.y * EY.x) > 1e-6);
check('both grid axes point down-screen (painter order holds)', EX.y > 0 && EY.y > 0);
check('depthOf increases along both axes', depthOf(1, 0) > depthOf(0, 0) && depthOf(0, 1) > depthOf(0, 0));
check('the block is not viewed at 45 degrees', Math.abs(EX.y / EX.x) < 0.35);
// Convex box: culling alone must leave a non-overlapping set of front faces.
let faceCountOk = visibleFaces(boxCorners(box)).length === 3;
for (let t = 0; t <= 2.2; t += 0.05) {
const n = visibleFaces(rollPoints(start, 'right', box, t)).length;
if (n < 2 || n > 3) faceCountOk = false;
}
check('only front faces are drawn, at every roll angle', faceCountOk);
}
// ── Summary ──────────────────────────────────────────────────────────────────
console.log(`[verify] ${passes} passed, ${failures} failed`);