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:
parent
00f6dd6e19
commit
b479b6a21f
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.
14400
data/bloxorz.json
14400
data/bloxorz.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(); },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|---|---|
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue