501 lines
19 KiB
JavaScript
501 lines
19 KiB
JavaScript
// ---- floor generation, room state, doors, hazards ----
|
|
|
|
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 = {};
|
|
const target = Math.min(5 + floor, 9);
|
|
G.rooms['0,0'] = makeRoom(0, 0, 'start');
|
|
let cx = 0, cy = 0, guard = 0;
|
|
while (Object.keys(G.rooms).length < target && guard++ < 500) {
|
|
const [dx, dy] = choice(Object.values(DIRS));
|
|
const nx = cx + dx, ny = cy + dy;
|
|
if (!G.rooms[nx + ',' + ny]) {
|
|
G.rooms[nx + ',' + ny] = makeRoom(nx, ny, 'combat');
|
|
if (chance(0.5)) { cx = nx; cy = ny; }
|
|
} else { cx = nx; cy = ny; }
|
|
}
|
|
// assign boss (farthest) and item (second farthest, dist >= 2)
|
|
const list = Object.values(G.rooms).sort((a, b) =>
|
|
(Math.abs(b.gx) + Math.abs(b.gy)) - (Math.abs(a.gx) + Math.abs(a.gy)));
|
|
list[0].type = 'boss';
|
|
for (const r of list) {
|
|
const d = Math.abs(r.gx) + Math.abs(r.gy);
|
|
if (r.type === 'combat' && d >= 2) { r.type = 'item'; break; }
|
|
}
|
|
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)];
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeRoom(gx, gy, type) {
|
|
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; }
|
|
|
|
function wavesFor(floor) {
|
|
const base = 3 + Math.ceil(floor * 1.4);
|
|
const waves = [base];
|
|
if (floor >= 2) waves.push(Math.ceil(base * 0.7));
|
|
if (floor >= 4) waves.push(Math.ceil(base * 0.5));
|
|
return waves;
|
|
}
|
|
|
|
function enterRoom(gx, gy) {
|
|
// stash the old room's pickups (stairs, pedestals, rewards, gems all persist)
|
|
if (G.cur) G.cur.pickups = G.pickups;
|
|
G.enemies = [];
|
|
G.ebullets = [];
|
|
G.bullets = [];
|
|
G.hazards = [];
|
|
G.telegraphs = [];
|
|
G.corpses = [];
|
|
G.boss = null;
|
|
G.roomLayer = null;
|
|
G.roomLayerKey = '';
|
|
G.roomDamaged = false;
|
|
G.roomEnterT = G.time;
|
|
|
|
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);
|
|
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;
|
|
|
|
if (!room.visited) {
|
|
room.visited = true;
|
|
if (room.type === 'combat') {
|
|
const w = wavesFor(G.floor);
|
|
room.wavesLeft = w.length;
|
|
room.pendingWave = w;
|
|
spawnWave(w[0], G.floor);
|
|
room.wavesLeft--;
|
|
room.spawnT = 0;
|
|
} else if (room.type === 'boss') {
|
|
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', a.cx, a.cy + 140);
|
|
Music.playBoss(boss.bossKind);
|
|
} else if (room.type === 'item') {
|
|
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', a.cx + 55, a.cy, { wid: w.id });
|
|
}
|
|
// and occasionally a bonus active pedestal
|
|
if (chance(0.15)) spawnActivePedestal(a.cx + 130, a.cy + 55);
|
|
room.cleared = true;
|
|
}
|
|
} else if (room.type === 'combat' && !room.cleared && room.wavesLeft > 0) {
|
|
// re-entering an uncleared room: restart waves
|
|
spawnWave(room.pendingWave[room.pendingWave.length - room.wavesLeft], G.floor);
|
|
room.wavesLeft--;
|
|
room.spawnT = 0;
|
|
}
|
|
}
|
|
|
|
function roomLocked() {
|
|
const r = G.cur;
|
|
if (!r) return false;
|
|
if (r.type !== 'combat' && r.type !== 'boss') return false;
|
|
return !r.cleared;
|
|
}
|
|
|
|
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.
|
|
if (G.enemies.length > 0) {
|
|
const lowHealth = p.hp / Math.max(1, p.stats.maxHp) < 0.35;
|
|
const stalled = G.time - G.roomEnterT > 90;
|
|
if (lowHealth || stalled) {
|
|
const decay = lowHealth ? 0.02 : 0.008;
|
|
applyPressureDelta(-decay * dt * pressureDropScale());
|
|
}
|
|
}
|
|
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
|
|
|
|
// wave progression / room clear
|
|
if (r.type === 'combat' && !r.cleared) {
|
|
if (G.enemies.length === 0) {
|
|
if (r.wavesLeft > 0) {
|
|
r.spawnT += dt;
|
|
if (r.spawnT > 0.9) {
|
|
r.spawnT = 0;
|
|
spawnWave(r.pendingWave[r.pendingWave.length - r.wavesLeft], G.floor);
|
|
r.wavesLeft--;
|
|
}
|
|
} else {
|
|
r.cleared = true;
|
|
recordRoomClear(r);
|
|
spawnRoomReward(a.cx, a.cy, G.floor);
|
|
Sfx.roomClear();
|
|
addScore(50);
|
|
}
|
|
}
|
|
} else if (r.type === 'boss' && r.bossSpawned && !r.cleared) {
|
|
if (!G.boss && G.enemies.length === 0) {
|
|
r.cleared = true;
|
|
recordRoomClear(r);
|
|
Sfx.roomClear();
|
|
}
|
|
}
|
|
|
|
// door transitions
|
|
if (!roomLocked()) {
|
|
let dir = null;
|
|
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);
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
function recordRoomClear(room) {
|
|
if (!G.roomDamaged) {
|
|
G.streak++;
|
|
applyPressureDelta(pressureGain());
|
|
} else {
|
|
G.streak = 0;
|
|
}
|
|
// active items charge on room clears: +1 per combat room, +2 per boss
|
|
const p = G.player;
|
|
if (p && p.active) {
|
|
const a = ACTIVES[p.active.iid];
|
|
if (a) {
|
|
const gain = room.type === 'boss' ? 2 : (room.type === 'combat' ? 1 : 0);
|
|
if (gain > 0 && p.active.charges < a.cost) {
|
|
p.active.charges = Math.min(a.cost, p.active.charges + gain);
|
|
if (p.active.charges >= a.cost) spawnText(p.x, p.y - 30, a.name.toUpperCase() + ' READY', '#e2472f');
|
|
}
|
|
}
|
|
}
|
|
if (room.type === 'combat' && G.player.stats.roomHeal > 0 && G.player.hp < G.player.stats.maxHp) {
|
|
const before = G.player.hp;
|
|
healPlayer(G.player.stats.roomHeal);
|
|
if (G.player.hp > before) spawnText(G.player.x, G.player.y - 18, 'ROOM KNIT +' + (G.player.hp - before), '#d98991');
|
|
}
|
|
// Bone Knit perk: small chance of a half-heart mend on each combat clear
|
|
if (room.type === 'combat' && G.player.stats.roomHealChance > 0 && G.player.hp < G.player.stats.maxHp &&
|
|
chance(Math.min(0.9, G.player.stats.roomHealChance))) {
|
|
const before = G.player.hp;
|
|
healPlayer(1);
|
|
if (G.player.hp > before) spawnText(G.player.x, G.player.y - 30, 'BONE KNIT +' + (G.player.hp - before), '#d98991');
|
|
}
|
|
}
|
|
|
|
function nextFloor() {
|
|
G.floor++;
|
|
addScore(250);
|
|
Sfx.stairs();
|
|
genFloor(G.floor);
|
|
enterRoom(0, 0);
|
|
// Shield Heart perk: fresh shield hearts each floor
|
|
G.player.shieldHp = G.player.stats.shieldPerk || 0;
|
|
if (G.player.shieldHp > 0) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); }
|
|
// legendary once-per-floor flags reset
|
|
G.player.secondSkinUsed = false;
|
|
G.player.lastCutActive = false;
|
|
G.player.x = W / 2; G.player.y = H / 2;
|
|
addToast('FLOOR ' + G.floor, 'the meat runs deeper here');
|
|
Music.requestFloorMusic();
|
|
G.transition = 0.5;
|
|
}
|
|
|
|
// ---- hazards (acid pools, fire, bear traps) ----
|
|
function updateHazards(dt) {
|
|
const p = G.player;
|
|
for (let i = G.hazards.length - 1; i >= 0; i--) {
|
|
const h = G.hazards[i];
|
|
h.t += dt;
|
|
if (h.t >= h.life) { G.hazards.splice(i, 1); continue; }
|
|
if (h.hostile) {
|
|
h.tick = (h.tick || 0) - dt;
|
|
if (h.tick <= 0 && dist2(h.x, h.y, p.x, p.y) < (h.r + p.r) * (h.r + p.r)) {
|
|
h.tick = h.tickRate || 0.4;
|
|
hurtPlayer(h.dmg || 1, angleTo(h.x, h.y, p.x, p.y));
|
|
}
|
|
}
|
|
if (h.kind === 'trap') {
|
|
for (const e of G.enemies) {
|
|
if (e.boss || e.hp <= 0) continue;
|
|
if (h.caught && h.caught.has(e)) continue; // each victim springs it once
|
|
if (dist2(h.x, h.y, e.x, e.y) < (h.r + e.r) * (h.r + e.r)) {
|
|
e.rootT = 2.2;
|
|
damageEnemy(e, h.dmg, rand(0, TAU), false);
|
|
spawnBlood(e.x, e.y, 0, 6);
|
|
Sfx.trapSnap({ x: h.x, y: h.y });
|
|
// a trap snaps shut on a few victims before it breaks
|
|
if (!h.caught) h.caught = new Set();
|
|
h.caught.add(e);
|
|
h.charges = (h.charges === undefined ? 1 : h.charges) - 1;
|
|
if (h.charges <= 0) { G.hazards.splice(i, 1); break; }
|
|
}
|
|
}
|
|
} else if (!h.hostile || h.dps > 0) { // weapon pools hurt enemies; zero-DPS boss pools do not
|
|
for (const e of G.enemies) {
|
|
if (e.hp <= 0) continue;
|
|
if (e === h.owner) continue;
|
|
if (dist2(h.x, h.y, e.x, e.y) < (h.r + e.r * 0.5) * (h.r + e.r * 0.5)) {
|
|
if (h.kind === 'acid') { e.slowT = 0.4; tickEnemyDamage(e, h.dps * dt); }
|
|
else { e.burnT = Math.max(e.burnT, 0.8); e.burnDps = h.dps; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- rendering ----
|
|
function hashTile(x, y) { return ((x * 73856093) ^ (y * 19349663)) >>> 0; }
|
|
|
|
const FLOOR_TILE_POOL = [
|
|
'tile_floor1', 'tile_floor1', 'tile_floor1',
|
|
'tile_floor2', 'tile_floor2', 'tile_floor2',
|
|
'tile_floor3', 'tile_floor3',
|
|
'tile_floor4', 'tile_floor6', 'tile_floor6', 'tile_floor6',
|
|
'tile_floor5', 'tile_floor7', 'tile_floor8', 'tile_floor1',
|
|
];
|
|
const WALL_TILE_POOL = ['tile_wall', 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4'];
|
|
|
|
function drawFloorTiles(target, r) {
|
|
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 = 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) {
|
|
// A room's 600 floor tiles are static. Cache them into one bitmap in the
|
|
// 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 + ':' + r.shape + ':' + r.theme;
|
|
if (!G.roomLayer || G.roomLayerKey !== key) {
|
|
const layer = document.createElement('canvas');
|
|
layer.width = W; layer.height = H;
|
|
const lctx = layer.getContext('2d');
|
|
lctx.imageSmoothingEnabled = false;
|
|
drawFloorTiles(lctx, r);
|
|
G.roomLayer = layer; G.roomLayerKey = key;
|
|
}
|
|
ctx.drawImage(G.roomLayer, 0, 0);
|
|
}
|
|
|
|
function drawRoom(ctx) {
|
|
const r = G.cur;
|
|
drawFloor(ctx, r);
|
|
// blood decals
|
|
for (const d of r.decals) {
|
|
Sprites.draw(ctx, d.img, d.x, d.y, d.rot, 48 * d.s, false, 0.55);
|
|
}
|
|
// hazards
|
|
for (const h of G.hazards) {
|
|
const k = 1 - h.t / h.life;
|
|
if (h.kind === 'trap') {
|
|
Sprites.draw(ctx, 'bullet_saw', h.x, h.y, h.t * 0.5, 28, false, clamp(k * 3, 0, 1));
|
|
} else {
|
|
ctx.globalAlpha = clamp(k * 2, 0, 0.55);
|
|
const pulse = h.r * (0.82 + Math.sin(h.t * 5) * 0.08);
|
|
ctx.fillStyle = h.hostile ? (h.kind === 'acid' ? '#a7c92b' : '#ff642b') : (h.kind === 'acid' ? '#4f9e25' : '#d65b18');
|
|
ctx.beginPath(); ctx.arc(h.x, h.y, pulse, 0, TAU); ctx.fill();
|
|
ctx.strokeStyle = h.hostile ? '#fff0a0' : (h.kind === 'acid' ? '#a5e34c' : '#ffb13b');
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.arc(h.x, h.y, pulse * 0.72, 0, TAU); ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
}
|
|
// walls
|
|
drawWalls(ctx, r);
|
|
}
|
|
|
|
function drawWalls(ctx, r) {
|
|
const locked = roomLocked();
|
|
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' ? top : a.y1;
|
|
if (r.doors[dir]) {
|
|
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' ? left : a.x1;
|
|
if (r.doors[dir]) {
|
|
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' ? 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' ? 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();
|
|
}
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 3;
|
|
ctx.strokeRect(a.x0, a.y0, a.w, a.h);
|
|
|
|
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 = 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];
|
|
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);
|
|
Sprites.draw(ctx, sprite, x, y, rot, 150, false, 1, 1, 0.82);
|
|
ctx.save();
|
|
if (horiz) ctx.translate(x, y); else { ctx.translate(x, y); ctx.rotate(Math.PI / 2); }
|
|
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);
|
|
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;
|
|
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), tw = 3 + (hh % 2);
|
|
ctx.fillStyle = hh % 4 === 0 ? '#e8dcc2' : '#cfc0a4';
|
|
ctx.beginPath();
|
|
ctx.moveTo(side * DOOR_HALF, ty - tw);
|
|
ctx.lineTo(side * DOOR_HALF, ty + tw);
|
|
ctx.lineTo(side * (DOOR_HALF - th), ty);
|
|
ctx.closePath(); ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
ctx.restore();
|
|
const chevOff = dir === 'n' ? -1 : (dir === 's' ? 1 : 0);
|
|
const chevOffX = dir === 'w' ? -1 : (dir === 'e' ? 1 : 0);
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.5 + Math.sin(G.time * 3 + dirSeed) * 0.2;
|
|
ctx.strokeStyle = locked ? '#ff2a3c' : '#e8b04a';
|
|
ctx.lineWidth = 3;
|
|
for (let c = 1; c <= 2; c++) {
|
|
const d0 = WALL + 10 + c * 12;
|
|
ctx.beginPath();
|
|
if (horiz) {
|
|
const yy = y + chevOff * d0;
|
|
ctx.moveTo(x - 10, yy); ctx.lineTo(x, yy + chevOff * 8); ctx.lineTo(x + 10, yy);
|
|
} else {
|
|
const xx = x + chevOffX * d0;
|
|
ctx.moveTo(xx, y - 10); ctx.lineTo(xx + chevOffX * 8, y); ctx.lineTo(xx, y + 10);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
if (locked) {
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.5 + Math.sin(G.time * 6) * 0.2;
|
|
ctx.fillStyle = '#8f1f2a';
|
|
if (horiz) ctx.fillRect(x - DOOR_HALF, y - 8, DOOR_HALF * 2, 16);
|
|
else ctx.fillRect(x - 8, y - DOOR_HALF, 16, DOOR_HALF * 2);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|