From 11f9341b462a6b6020080fb1af23ea9b1dd93ec3 Mon Sep 17 00:00:00 2001 From: MeatSlicer Developer Date: Thu, 30 Jul 2026 22:29:04 +1000 Subject: [PATCH] Add variable room shapes and themes --- js/bosses.js | 32 +++++------ js/bullets.js | 15 ++--- js/enemies.js | 29 ++++++---- js/player.js | 4 +- js/rooms.js | 153 +++++++++++++++++++++++++++++++------------------- js/state.js | 1 + test/smoke.js | 33 ++++++++++- 7 files changed, 172 insertions(+), 95 deletions(-) diff --git a/js/bosses.js b/js/bosses.js index b60c454..ca8ed25 100644 --- a/js/bosses.js +++ b/js/bosses.js @@ -23,7 +23,7 @@ function spawnBoss(floor) { const hpMul = (1 + (floor - 1) * 0.30) * (1 + up * 0.05) * pressureHp; const e = { type: 'boss_' + def.kind, bossKind: def.kind, boss: true, name: def.name, - x: W / 2, y: H / 2 - 100, vx: 0, vy: 0, + x: G.arena.cx, y: G.arena.cy - 100, vx: 0, vy: 0, r: def.r, hp: def.hp * hpMul, maxHp: def.hp * hpMul, drawSize: def.drawSize, spd: def.spd * (1 + cycle * 0.1 + up * 0.01) * G.pressure, dmg: 2, @@ -99,7 +99,7 @@ function updateBoneSaw(e, dt, a, d, spdMul, enraged) { if (chance(0.5)) spawnBlood(e.x, e.y, e.chargeAng + Math.PI, 2); // wall slam const nx = e.x + e.vx * dt, ny = e.y + e.vy * dt; - if (nx < WALL + e.r + 2 || nx > W - WALL - e.r - 2 || ny < WALL + e.r + 2 || ny > H - WALL - e.r - 2) { + if (nx < G.arena.x0 + e.r + 2 || nx > G.arena.x1 - e.r - 2 || ny < G.arena.y0 + e.r + 2 || ny > G.arena.y1 - e.r - 2) { e.state = 'idle'; e.stateT = 1; e.atkT = 1.2; addShake(10); Sfx.explode({ x: e.x, y: e.y }); } @@ -175,7 +175,7 @@ function updateKnifeCrawl(e, dt, a, d, spdMul, enraged) { const ty = i === 0 ? p.y : p.y + rand(-130, 130); G.telegraphs.push({ kind: 'knives', - x: clamp(tx, WALL + 20, W - WALL - 20), y: clamp(ty, WALL + 20, H - WALL - 20), + x: clamp(tx, G.arena.x0 + 20, G.arena.x1 - 20), y: clamp(ty, G.arena.y0 + 20, G.arena.y1 - 20), r: 34, t: 0, dur: Math.max(0.7 - e.cycle * 0.05, 0.45), tick: 0, track: e.tier >= 1 && i === 0 ? 0.4 : 0, // tier 2+: one telegraph hunts you }); @@ -205,7 +205,7 @@ function updateKnifeCrawl(e, dt, a, d, spdMul, enraged) { // cycle 2+: knives erupt where the dash ends if (e.tier >= 1) { G.telegraphs.push({ - kind: 'knives', x: clamp(e.x, WALL + 20, W - WALL - 20), y: clamp(e.y, WALL + 20, H - WALL - 20), + kind: 'knives', x: clamp(e.x, G.arena.x0 + 20, G.arena.x1 - 20), y: clamp(e.y, G.arena.y0 + 20, G.arena.y1 - 20), r: 34, t: 0, dur: 0.55, tick: 0, track: 0, }); } @@ -257,8 +257,8 @@ function updateVealMother(e, dt, a, d, spdMul, enraged) { const slots = Math.max(0, MAX_ENEMIES - G.enemies.length); for (let i = 0; i < Math.min(2, slots); i++) { const ang = a + Math.PI + (i ? 0.7 : -0.7); - const m = makeEnemy('splitter', clamp(e.x + Math.cos(ang) * 90, WALL + 30, W - WALL - 30), - clamp(e.y + Math.sin(ang) * 90, WALL + 30, H - WALL - 30), G.floor, true); + const m = makeEnemy('splitter', clamp(e.x + Math.cos(ang) * 90, G.arena.x0 + 30, G.arena.x1 - 30), + clamp(e.y + Math.sin(ang) * 90, G.arena.y0 + 30, G.arena.y1 - 30), G.floor, true); e.escorts.push(m); G.enemies.push(m); Sfx.spawn(m); } if (e.escorts.length) e.dmgTakenMul = 0.35; @@ -314,8 +314,8 @@ function updateFlenser(e, dt, a, d, spdMul, enraged) { if (e.stateT <= 0) { e.state = 'gone'; e.stateT = enraged ? 0.3 : 0.5; e.phased = true; e.alpha = 0; const behind = p.aim + Math.PI, range = rand(90, 140); - e.x = clamp(p.x + Math.cos(behind) * range, WALL + e.r, W - WALL - e.r); - e.y = clamp(p.y + Math.sin(behind) * range, WALL + e.r, H - WALL - e.r); + e.x = clamp(p.x + Math.cos(behind) * range, G.arena.x0 + e.r, G.arena.x1 - e.r); + e.y = clamp(p.y + Math.sin(behind) * range, G.arena.y0 + e.r, G.arena.y1 - e.r); G.telegraphs.push({ kind: 'knives', x: e.x, y: e.y, r: 38, t: 0, dur: e.stateT + 0.22, tick: 0, track: 0 }); if (e.tier >= 1) G.telegraphs.push({ kind: 'knives', x: e.oldX, y: e.oldY, r: 30, t: 0, dur: 0.6, tick: 0, track: 0 }); } @@ -462,7 +462,7 @@ function updateScald(e, dt, a, d, spdMul, enraged) { const tx = i === 0 ? p.x : p.x + rand(-180, 180); const ty = i === 0 ? p.y : p.y + rand(-150, 150); G.telegraphs.push({ kind: 'pool', owner: e, - x: clamp(tx, WALL + radius, W - WALL - radius), y: clamp(ty, WALL + radius, H - WALL - radius), + x: clamp(tx, G.arena.x0 + radius, G.arena.x1 - radius), y: clamp(ty, G.arena.y0 + radius, G.arena.y1 - radius), r: radius, t: 0, dur: 0.6, life: enraged ? 7 : 5 }); } e.attackT = 0.45; e.actionT = 0; Sfx.boss(e, 'vent'); @@ -507,8 +507,8 @@ function updateTelegraphs(dt) { t.track -= dt; const ta = angleTo(t.x, t.y, p.x, p.y); const ts = 140 * dt; - t.x = clamp(t.x + Math.cos(ta) * ts, WALL + 20, W - WALL - 20); - t.y = clamp(t.y + Math.sin(ta) * ts, WALL + 20, H - WALL - 20); + t.x = clamp(t.x + Math.cos(ta) * ts, G.arena.x0 + 20, G.arena.x1 - 20); + t.y = clamp(t.y + Math.sin(ta) * ts, G.arena.y0 + 20, G.arena.y1 - 20); } if (t.kind === 'knives' && t.t >= t.dur) { // erupt @@ -593,13 +593,13 @@ function onBossDeath(e) { addScore(500 + G.floor * 100); addShake(12); // boss reward: item pedestal + a real weapon drop + ammo + stairs down - spawnItemPedestal(W / 2 - 90, H / 2, null, 'boss'); - spawnPickup('ammo', W / 2, H / 2 - 40); + spawnItemPedestal(G.arena.cx - 90, G.arena.cy, null, 'boss'); + spawnPickup('ammo', G.arena.cx, G.arena.cy - 40); // 30% chance of a bonus active pedestal (additive — never replaces the passive) - if (chance(0.30)) spawnActivePedestal(W / 2 - 160, H / 2 + 60); + if (chance(0.30)) spawnActivePedestal(G.arena.cx - 160, G.arena.cy + 60); const bw = rollWeaponDrop(G.floor + 2); // bosses drop the good stuff - spawnPickup('weapon', W / 2 + 90, H / 2, { wid: bw.id }); - spawnPickup('stairs', W / 2, H / 2 + 80); + spawnPickup('weapon', G.arena.cx + 90, G.arena.cy, { wid: bw.id }); + spawnPickup('stairs', G.arena.cx, G.arena.cy + 80); addToast(e.name + ' DESTROYED', 'take the meat, take the stairs'); Music.requestFloorMusic(); } diff --git a/js/bullets.js b/js/bullets.js index 5925c46..5b68b46 100644 --- a/js/bullets.js +++ b/js/bullets.js @@ -61,14 +61,15 @@ function updateBullets(dt) { if (b.behavior === 'bounce' || b.behavior === 'pierce_drag') b.ang += 12 * dt; // walls - const outL = b.x < WALL + 4, outR = b.x > W - WALL - 4, outU = b.y < WALL + 4, outD = b.y > H - WALL - 4; + const outL = b.x < G.arena.x0 + 4, outR = b.x > G.arena.x1 - 4; + const outU = b.y < G.arena.y0 + 4, outD = b.y > G.arena.y1 - 4; if (outL || outR || outU || outD) { const bouncesLeft = (b.bounces || 0) + (b.bounce || 0); if (bouncesLeft > 0) { if (outL || outR) b.vx = -b.vx; if (outU || outD) b.vy = -b.vy; - b.x = clamp(b.x, WALL + 4, W - WALL - 4); - b.y = clamp(b.y, WALL + 4, H - WALL - 4); + b.x = clamp(b.x, G.arena.x0 + 4, G.arena.x1 - 4); + b.y = clamp(b.y, G.arena.y0 + 4, G.arena.y1 - 4); if (b.bounces > 0) b.bounces--; else if (b.bounce > 0) b.bounce--; b.ang = Math.atan2(b.vy, b.vx); spawnSpark(b.x, b.y, b.ang); @@ -196,14 +197,14 @@ function updateBullets(dt) { } let dead = b.t >= b.life; if (b.behavior !== 'orbit') { - const outL = b.x < WALL + 4, outR = b.x > W - WALL - 4; - const outU = b.y < WALL + 4, outD = b.y > H - WALL - 4; + const outL = b.x < G.arena.x0 + 4, outR = b.x > G.arena.x1 - 4; + const outU = b.y < G.arena.y0 + 4, outD = b.y > G.arena.y1 - 4; if (outL || outR || outU || outD) { if (b.bounces > 0) { if (outL || outR) b.vx = -b.vx; if (outU || outD) b.vy = -b.vy; - b.x = clamp(b.x, WALL + 4, W - WALL - 4); - b.y = clamp(b.y, WALL + 4, H - WALL - 4); + b.x = clamp(b.x, G.arena.x0 + 4, G.arena.x1 - 4); + b.y = clamp(b.y, G.arena.y0 + 4, G.arena.y1 - 4); b.bounces--; } else dead = true; } diff --git a/js/enemies.js b/js/enemies.js index 911d353..1a12162 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -59,25 +59,30 @@ function pickEnemyType(floor) { function nearAnyDoor(x, y) { const r = G.cur; if (!r) return false; - const M = 130; // spawn keep-out radius around doors - if (r.doors.n && Math.abs(x - W / 2) < DOOR_HALF + 30 && y < WALL + M) return true; - if (r.doors.s && Math.abs(x - W / 2) < DOOR_HALF + 30 && y > H - WALL - M) return true; - if (r.doors.e && Math.abs(y - H / 2) < DOOR_HALF + 30 && x > W - WALL - M) return true; - if (r.doors.w && Math.abs(y - H / 2) < DOOR_HALF + 30 && x < WALL + M) return true; + const a = G.arena; + const M = Math.min(130, Math.min(a.w, a.h) * 0.32); + if (r.doors.n && Math.abs(x - a.cx) < DOOR_HALF + 30 && y < a.y0 + M) return true; + if (r.doors.s && Math.abs(x - a.cx) < DOOR_HALF + 30 && y > a.y1 - M) return true; + if (r.doors.e && Math.abs(y - a.cy) < DOOR_HALF + 30 && x > a.x1 - M) return true; + if (r.doors.w && Math.abs(y - a.cy) < DOOR_HALF + 30 && x < a.x0 + M) return true; return false; } function spawnPosAwayFromPlayer() { const p = G.player; + const a = G.arena; + const safeDist = Math.min(200, Math.min(a.w, a.h) * 0.42); for (let tries = 0; tries < 30; tries++) { - const x = rand(WALL + 40, W - WALL - 40); - const y = rand(WALL + 40, H - WALL - 40); - if (dist(x, y, p.x, p.y) <= 200) continue; + const x = rand(a.x0 + 40, a.x1 - 40); + const y = rand(a.y0 + 40, a.y1 - 40); + if (dist(x, y, p.x, p.y) <= safeDist) continue; if (nearAnyDoor(x, y)) continue; return { x, y }; } - // fallback: center of the room, away from every door - return { x: rand(W / 2 - 150, W / 2 + 150), y: rand(H / 2 - 120, H / 2 + 120) }; + // fallback: a bounded central region, valid even in the thin hall variants + const rx = Math.max(12, Math.min(150, a.w / 2 - 40)); + const ry = Math.max(12, Math.min(120, a.h / 2 - 40)); + return { x: rand(a.cx - rx, a.cx + rx), y: rand(a.cy - ry, a.cy + ry) }; } function spawnWave(count, floor) { @@ -395,8 +400,8 @@ function updateEnemies(dt) { e.x += px; e.y += py; o.x -= px; o.y -= py; } } - e.x = clamp(e.x, WALL + e.r, W - WALL - e.r); - e.y = clamp(e.y, WALL + e.r, H - WALL - e.r); + e.x = clamp(e.x, G.arena.x0 + e.r, G.arena.x1 - e.r); + e.y = clamp(e.y, G.arena.y0 + e.r, G.arena.y1 - e.r); // contact damage if (e.hitT > 0) e.hitT -= dt; diff --git a/js/player.js b/js/player.js index 6d51b31..cb0493e 100644 --- a/js/player.js +++ b/js/player.js @@ -85,8 +85,8 @@ function updatePlayer(dt) { p.moveBlend = lerp(p.moveBlend, mx || my ? 1 : 0, clamp(dt * 12, 0, 1)); p.recoil = Math.max(0, p.recoil - dt * 7); p.muzzleT = Math.max(0, p.muzzleT - dt); - p.x = clamp(p.x, WALL + p.r, W - WALL - p.r); - p.y = clamp(p.y, WALL + p.r, H - WALL - p.r); + p.x = clamp(p.x, G.arena.x0 + p.r, G.arena.x1 - p.r); + p.y = clamp(p.y, G.arena.y0 + p.r, G.arena.y1 - p.r); // aim p.aim = angleTo(p.x, p.y, Input.mx, Input.my); diff --git a/js/rooms.js b/js/rooms.js index faed00c..5214a03 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -2,6 +2,28 @@ const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] }; const OPP = { n: 's', s: 'n', e: 'w', w: 'e' }; +const ROOM_SHAPES = { + hall: { x0: 48, y0: 48, x1: 912, y1: 592 }, + wide_hall: { x0: 48, y0: 208, x1: 912, y1: 432 }, + tall_hall: { x0: 368, y0: 48, x1: 592, y1: 592 }, + chamber: { x0: 208, y0: 128, x1: 752, y1: 512 }, + pit: { x0: 128, y0: 88, x1: 832, y1: 552 }, +}; +const ROOM_THEMES = { + abattoir: { floor: ['tile_floor1', 'tile_floor1', 'tile_floor4', 'tile_floor6'], wall: ['tile_wall', 'tile_wall2'] }, + plant: { floor: ['tile_floor2', 'tile_floor5', 'tile_floor7'], wall: ['tile_wall', 'tile_wall4'] }, + oxide: { floor: ['tile_floor3', 'tile_floor3', 'tile_floor6'], wall: ['tile_wall3', 'tile_wall'] }, + flesh: { floor: ['tile_floor8', 'tile_floor8', 'tile_floor1'], wall: ['tile_wall4', 'tile_wall2'] }, +}; + +function roomBounds(room) { + const def = ROOM_SHAPES[(room && room.shape) || 'hall'] || ROOM_SHAPES.hall; + return { x0: def.x0, y0: def.y0, x1: def.x1, y1: def.y1, + cx: (def.x0 + def.x1) / 2, cy: (def.y0 + def.y1) / 2, + w: def.x1 - def.x0, h: def.y1 - def.y0 }; +} + +function setArenaForRoom(room) { G.arena = roomBounds(room); } function genFloor(floor) { G.rooms = {}; @@ -24,8 +46,13 @@ function genFloor(floor) { const d = Math.abs(r.gx) + Math.abs(r.gy); if (r.type === 'combat' && d >= 2) { r.type = 'item'; break; } } - // wire doors + const combatShapes = ['hall', 'hall', 'hall', 'pit', 'chamber', 'wide_hall', 'tall_hall']; + const themes = Object.keys(ROOM_THEMES); for (const r of Object.values(G.rooms)) { + r.theme = choice(themes); + if (r.type === 'start' || r.type === 'boss') r.shape = 'hall'; + else if (r.type === 'item') r.shape = 'chamber'; + else r.shape = choice(combatShapes); for (const [dir, [dx, dy]] of Object.entries(DIRS)) { r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)]; } @@ -33,7 +60,8 @@ function genFloor(floor) { } function makeRoom(gx, gy, type) { - return { gx, gy, type, doors: {}, cleared: type === 'start', visited: false, decals: [], wavesLeft: 0, spawnT: 0, bossSpawned: false, pickups: [] }; + return { gx, gy, type, shape: 'hall', theme: 'abattoir', doors: {}, cleared: type === 'start', + visited: false, decals: [], wavesLeft: 0, spawnT: 0, bossSpawned: false, pickups: [] }; } function roomKey(x, y) { return x + ',' + y; } @@ -63,10 +91,16 @@ function enterRoom(gx, gy) { const room = G.rooms[roomKey(gx, gy)]; G.cur = room; + setArenaForRoom(room); G.pickups = room.pickups; + const a = G.arena; // spawn protection: brief invincibility walking into a fresh room - if (G.player) G.player.invT = Math.max(G.player.invT, 1.0); + if (G.player) { + G.player.invT = Math.max(G.player.invT, 1.0); + G.player.x = clamp(G.player.x, a.x0 + G.player.r, a.x1 - G.player.r); + G.player.y = clamp(G.player.y, a.y0 + G.player.r, a.y1 - G.player.r); + } // Iron Lung: re-arm the once-per-room hit block if (G.player && G.player.stats.ironLung > 0) G.player.ironLungReady = true; @@ -83,17 +117,17 @@ function enterRoom(gx, gy) { const boss = spawnBoss(G.floor); room.bossSpawned = true; // boss fights are a pure ammo drain, so stock the room on the way in - spawnPickup('ammo', W / 2, H / 2 + 140); + spawnPickup('ammo', a.cx, a.cy + 140); Music.playBoss(boss.bossKind); } else if (room.type === 'item') { - spawnItemPedestal(W / 2 - 45, H / 2, null, 'room'); + spawnItemPedestal(a.cx - 45, a.cy, null, 'room'); // item rooms sometimes also stock a weapon if (chance(0.35)) { const w = rollWeaponDrop(G.floor); - spawnPickup('weapon', W / 2 + 55, H / 2, { wid: w.id }); + spawnPickup('weapon', a.cx + 55, a.cy, { wid: w.id }); } // and occasionally a bonus active pedestal - if (chance(0.15)) spawnActivePedestal(W / 2 + 130, H / 2 + 55); + if (chance(0.15)) spawnActivePedestal(a.cx + 130, a.cy + 55); room.cleared = true; } } else if (room.type === 'combat' && !room.cleared && room.wavesLeft > 0) { @@ -114,6 +148,7 @@ function roomLocked() { function updateRoom(dt) { const r = G.cur; const p = G.player; + const a = G.arena; // If the current pressure is clearly overwhelming the build, ease it down // smoothly. Spawn-time application means living enemies never jump stats. @@ -140,7 +175,7 @@ function updateRoom(dt) { } else { r.cleared = true; recordRoomClear(r); - spawnRoomReward(W / 2, H / 2, G.floor); + spawnRoomReward(a.cx, a.cy, G.floor); Sfx.roomClear(); addScore(50); } @@ -156,18 +191,21 @@ function updateRoom(dt) { // door transitions if (!roomLocked()) { let dir = null; - if (p.y < WALL + p.r + 6 && Math.abs(p.x - W / 2) < DOOR_HALF && keyDown('w', 'arrowup')) dir = 'n'; - else if (p.y > H - WALL - p.r - 6 && Math.abs(p.x - W / 2) < DOOR_HALF && keyDown('s', 'arrowdown')) dir = 's'; - else if (p.x > W - WALL - p.r - 6 && Math.abs(p.y - H / 2) < DOOR_HALF && keyDown('d', 'arrowright')) dir = 'e'; - else if (p.x < WALL + p.r + 6 && Math.abs(p.y - H / 2) < DOOR_HALF && keyDown('a', 'arrowleft')) dir = 'w'; + if (p.y < a.y0 + p.r + 6 && Math.abs(p.x - a.cx) < DOOR_HALF && keyDown('w', 'arrowup')) dir = 'n'; + else if (p.y > a.y1 - p.r - 6 && Math.abs(p.x - a.cx) < DOOR_HALF && keyDown('s', 'arrowdown')) dir = 's'; + else if (p.x > a.x1 - p.r - 6 && Math.abs(p.y - a.cy) < DOOR_HALF && keyDown('d', 'arrowright')) dir = 'e'; + else if (p.x < a.x0 + p.r + 6 && Math.abs(p.y - a.cy) < DOOR_HALF && keyDown('a', 'arrowleft')) dir = 'w'; if (dir && r.doors[dir]) { const [dx, dy] = DIRS[dir]; enterRoom(r.gx + dx, r.gy + dy); - // place player at opposite door - if (dir === 'n') { p.y = H - WALL - p.r - 8; } - if (dir === 's') { p.y = WALL + p.r + 8; } - if (dir === 'e') { p.x = WALL + p.r + 8; } - if (dir === 'w') { p.x = W - WALL - p.r - 8; } + const next = G.arena; + // place player at the opposite door in the destination's own shape + if (dir === 'n') p.y = next.y1 - p.r - 8; + if (dir === 's') p.y = next.y0 + p.r + 8; + if (dir === 'e') p.x = next.x0 + p.r + 8; + if (dir === 'w') p.x = next.x1 - p.r - 8; + p.x = clamp(p.x, next.x0 + p.r, next.x1 - p.r); + p.y = clamp(p.y, next.y0 + p.r, next.y1 - p.r); G.transition = 0.3; Sfx.door(); } @@ -281,13 +319,22 @@ const FLOOR_TILE_POOL = [ const WALL_TILE_POOL = ['tile_wall', 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4']; function drawFloorTiles(target, r) { - for (let ty = 0; ty < H / TILE; ty++) { - for (let tx = 0; tx < W / TILE; tx++) { + const a = roomBounds(r); + const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir; + target.fillStyle = '#050204'; + target.fillRect(0, 0, W, H); + target.save(); + target.beginPath(); target.rect(a.x0, a.y0, a.w, a.h); target.clip(); + const tx0 = Math.floor(a.x0 / TILE), tx1 = Math.ceil(a.x1 / TILE); + const ty0 = Math.floor(a.y0 / TILE), ty1 = Math.ceil(a.y1 / TILE); + for (let ty = ty0; ty < ty1; ty++) { + for (let tx = tx0; tx < tx1; tx++) { const h = hashTile(tx + r.gx * 31, ty + r.gy * 31); - const sprite = FLOOR_TILE_POOL[h % FLOOR_TILE_POOL.length]; + const sprite = theme.floor[h % theme.floor.length]; Sprites.draw(target, sprite, tx * TILE + TILE / 2, ty * TILE + TILE / 2, 0, TILE); } } + target.restore(); } function drawFloor(ctx, r) { @@ -295,7 +342,7 @@ function drawFloor(ctx, r) { // browser; headless tests keep the simple direct path. const canCache = typeof document !== 'undefined' && document.createElement && G.imagesLoaded; if (!canCache) { drawFloorTiles(ctx, r); return; } - const key = r.gx + ',' + r.gy + ':' + G.floor; + const key = r.gx + ',' + r.gy + ':' + G.floor + ':' + r.shape + ':' + r.theme; if (!G.roomLayer || G.roomLayerKey !== key) { const layer = document.createElement('canvas'); layer.width = W; layer.height = H; @@ -336,89 +383,82 @@ function drawRoom(ctx) { function drawWalls(ctx, r) { const locked = roomLocked(); - // Draw 64px industrial panels into exact 48px collision bands. Door gaps - // are clipping regions, not skipped whole tiles, so they remain precisely - // 88px wide even though the art grid is larger. + const a = roomBounds(r); + const theme = ROOM_THEMES[r.theme] || ROOM_THEMES.abattoir; + const wallPool = theme.wall; + const left = a.x0 - WALL, right = a.x1 + WALL; + const top = a.y0 - WALL, bottom = a.y1 + WALL; + function clipBand(dir) { ctx.beginPath(); if (dir === 'n' || dir === 's') { - const y = dir === 'n' ? 0 : H - WALL; + const y = dir === 'n' ? top : a.y1; if (r.doors[dir]) { - ctx.rect(0, y, W / 2 - DOOR_HALF, WALL); - ctx.rect(W / 2 + DOOR_HALF, y, W / 2 - DOOR_HALF, WALL); - } else ctx.rect(0, y, W, WALL); + ctx.rect(left, y, a.cx - DOOR_HALF - left, WALL); + ctx.rect(a.cx + DOOR_HALF, y, right - a.cx - DOOR_HALF, WALL); + } else ctx.rect(left, y, right - left, WALL); } else { - const x = dir === 'w' ? 0 : W - WALL; + const x = dir === 'w' ? left : a.x1; if (r.doors[dir]) { - ctx.rect(x, 0, WALL, H / 2 - DOOR_HALF); - ctx.rect(x, H / 2 + DOOR_HALF, WALL, H / 2 - DOOR_HALF); - } else ctx.rect(x, 0, WALL, H); + ctx.rect(x, top, WALL, a.cy - DOOR_HALF - top); + ctx.rect(x, a.cy + DOOR_HALF, WALL, bottom - a.cy - DOOR_HALF); + } else ctx.rect(x, top, WALL, bottom - top); } ctx.clip(); } for (const dir of ['n', 's']) { ctx.save(); clipBand(dir); - const y = dir === 'n' ? TILE / 2 : H - TILE / 2; - for (let x = 0; x < W; x += TILE) { - const h = hashTile(x / TILE + r.gx * 17, (dir === 'n' ? -1 : 11) + r.gy * 19); - Sprites.draw(ctx, WALL_TILE_POOL[h % WALL_TILE_POOL.length], x + TILE / 2, y, 0, TILE); + const y = dir === 'n' ? top + TILE / 2 : a.y1 + TILE / 2; + for (let x = left; x < right; x += TILE) { + const h = hashTile(Math.floor(x / TILE) + r.gx * 17, (dir === 'n' ? -1 : 11) + r.gy * 19); + Sprites.draw(ctx, wallPool[h % wallPool.length], x + TILE / 2, y, 0, TILE); } ctx.restore(); } for (const dir of ['w', 'e']) { ctx.save(); clipBand(dir); - const x = dir === 'w' ? TILE / 2 : W - TILE / 2; - for (let y = 0; y < H; y += TILE) { - const h = hashTile((dir === 'w' ? -1 : 16) + r.gx * 17, y / TILE + r.gy * 19); - Sprites.draw(ctx, WALL_TILE_POOL[h % WALL_TILE_POOL.length], x, y + TILE / 2, Math.PI / 2, TILE); + const x = dir === 'w' ? left + TILE / 2 : a.x1 + TILE / 2; + for (let y = top; y < bottom; y += TILE) { + const h = hashTile((dir === 'w' ? -1 : 16) + r.gx * 17, Math.floor(y / TILE) + r.gy * 19); + Sprites.draw(ctx, wallPool[h % wallPool.length], x, y + TILE / 2, Math.PI / 2, TILE); } ctx.restore(); } - // dark inner edge ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 3; - ctx.strokeRect(WALL, WALL, W - WALL * 2, H - WALL * 2); + ctx.strokeRect(a.x0, a.y0, a.w, a.h); - // doors for (const [dir, has] of Object.entries(r.doors)) { if (!has) continue; const sprite = locked ? 'door_locked' : 'door_open'; let x, y, rot; - if (dir === 'n') { x = W / 2; y = WALL / 2; rot = 0; } - else if (dir === 's') { x = W / 2; y = H - WALL / 2; rot = 0; } - else if (dir === 'e') { x = W - WALL / 2; y = H / 2; rot = Math.PI / 2; } - else { x = WALL / 2; y = H / 2; rot = Math.PI / 2; } - // ---- doorway framing (visual only; DOOR_HALF governs function) ---- + if (dir === 'n') { x = a.cx; y = a.y0 - WALL / 2; rot = 0; } + else if (dir === 's') { x = a.cx; y = a.y1 + WALL / 2; rot = 0; } + else if (dir === 'e') { x = a.x1 + WALL / 2; y = a.cy; rot = Math.PI / 2; } + else { x = a.x0 - WALL / 2; y = a.cy; rot = Math.PI / 2; } const horiz = dir === 'n' || dir === 's'; const dirSeed = { n: 0, s: 2.1, e: 4.2, w: 6.3 }[dir]; - // clear dark passage under the art (guaranteed clean opening) ctx.fillStyle = '#050103'; if (horiz) ctx.fillRect(x - DOOR_HALF, y - WALL / 2, DOOR_HALF * 2, WALL); else ctx.fillRect(x - WALL / 2, y - DOOR_HALF, WALL, DOOR_HALF * 2); - // door art (wide, walkable passage for open; sealed jaws for locked) Sprites.draw(ctx, sprite, x, y, rot, 150, false, 1, 1, 0.82); - // accents on top, in wall-aligned local space ctx.save(); if (horiz) ctx.translate(x, y); else { ctx.translate(x, y); ctx.rotate(Math.PI / 2); } - // warm glow spilling from the threshold so the exit reads as inviting ctx.globalAlpha = 0.20 + Math.sin(G.time * 2.0 + dirSeed) * 0.06; ctx.fillStyle = locked ? '#ff2a3c' : '#e8724a'; ctx.fillRect(-DOOR_HALF, WALL / 2 - 8, DOOR_HALF * 2, 8); - // frame rim light on both jamb edges const rimAlpha = 0.30 + Math.sin(G.time * 2.2 + dirSeed) * 0.12; ctx.globalAlpha = rimAlpha; ctx.fillStyle = locked ? '#ff2a3c' : '#c4172a'; ctx.fillRect(-DOOR_HALF - 2, -WALL / 2 - 2, 3, WALL + 4); ctx.fillRect(DOOR_HALF - 1, -WALL / 2 - 2, 3, WALL + 4); ctx.globalAlpha = 1; - // jagged bone teeth ONLY when sealed (locked reads as dangerous, open reads as safe) if (locked) { for (let side = -1; side <= 1; side += 2) { for (let k = 0; k < 7; k++) { const hh = hashTile((r.gx * 3 + 17) * 31 + side * 7 + k, (r.gy * 5 + 11) * 13 + k * 3); const ty = -WALL / 2 + 3 + (hh % 41); - const th = 4 + (hh % 3); - const tw = 3 + (hh % 2); + const th = 4 + (hh % 3), tw = 3 + (hh % 2); ctx.fillStyle = hh % 4 === 0 ? '#e8dcc2' : '#cfc0a4'; ctx.beginPath(); ctx.moveTo(side * DOOR_HALF, ty - tw); @@ -429,7 +469,6 @@ function drawWalls(ctx, r) { } } ctx.restore(); - // outward chevrons past the threshold so the exit reads from across the room const chevOff = dir === 'n' ? -1 : (dir === 's' ? 1 : 0); const chevOffX = dir === 'w' ? -1 : (dir === 'e' ? 1 : 0); ctx.save(); diff --git a/js/state.js b/js/state.js index 8270498..666d112 100644 --- a/js/state.js +++ b/js/state.js @@ -45,6 +45,7 @@ const G = { roomLayer: null, roomLayerKey: '', atmosphereLayer: null, + arena: { x0: WALL, y0: WALL, x1: W - WALL, y1: H - WALL, cx: W / 2, cy: H / 2, w: W - WALL * 2, h: H - WALL * 2 }, }; const PERK_POWER_WEIGHT = 0.5; diff --git a/test/smoke.js b/test/smoke.js index 0dfc30e..f066a0c 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -61,7 +61,7 @@ for (const f of files) { } // const/let top-level bindings live in the context's lexical scope, not on the // global object — expose the ones the harness pokes at directly. -vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN })', sandbox); +vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, ACTIVES, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS, PRESSURE_UNIT, PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX, ROOM_SHAPES, ROOM_THEMES, HELP_PAGES, HELP_RENDERERS, SPAWN_WARN })', sandbox); let simTime = 0; const ctx = sandbox; @@ -248,6 +248,37 @@ console.log('== spawn safety =='); check('no spawns near player', tooClose === 0); } +console.log('== variable room shapes and themes =='); +{ + const generated = Object.values(ctx.G.rooms); + check('generated rooms all have valid shapes and themes', generated.every(r => ctx.ROOM_SHAPES[r.shape] && ctx.ROOM_THEMES[r.theme])); + check('start and boss rooms retain full arenas', generated.filter(r => r.type === 'start' || r.type === 'boss').every(r => r.shape === 'hall')); + check('item rooms use compact chambers', generated.filter(r => r.type === 'item').every(r => r.shape === 'chamber')); + const savedRoom = ctx.G.cur, savedArena = ctx.G.arena; + const px = ctx.G.player.x, py = ctx.G.player.y; + let allBounds = true, allSpawns = true, allDraw = true; + for (const shape of Object.keys(ctx.ROOM_SHAPES)) { + const shaped = ctx.makeRoom(77, 88, 'combat'); + shaped.shape = shape; shaped.theme = 'oxide'; shaped.doors = {}; + ctx.G.cur = shaped; ctx.setArenaForRoom(shaped); + const a = ctx.G.arena; + ctx.G.player.x = -999; ctx.G.player.y = -999; ctx.Input.mdown = false; + ctx.updatePlayer(0.016); + allBounds = allBounds && ctx.G.player.x >= a.x0 + ctx.G.player.r && ctx.G.player.x <= a.x1 - ctx.G.player.r + && ctx.G.player.y >= a.y0 + ctx.G.player.r && ctx.G.player.y <= a.y1 - ctx.G.player.r; + ctx.G.player.x = a.cx; ctx.G.player.y = a.cy; + for (let i = 0; i < 20; i++) { + const pos = ctx.spawnPosAwayFromPlayer(); + allSpawns = allSpawns && pos.x >= a.x0 && pos.x <= a.x1 && pos.y >= a.y0 && pos.y <= a.y1; + } + try { ctx.drawFloorTiles(fakeCtx(), shaped); ctx.drawWalls(fakeCtx(), shaped); } catch (err) { allDraw = false; } + } + check('player clamps inside every room shape', allBounds); + check('enemy spawn positions stay inside every room shape', allSpawns); + check('every room shape and theme renders headlessly', allDraw); + ctx.G.cur = savedRoom; ctx.G.arena = savedArena; ctx.G.player.x = px; ctx.G.player.y = py; +} + console.log('== XP / perks =='); ctx.gainXP(100); step(5, 16);