/* Pixel scenery and presentation. This module never mutates the simulation. */
(() => {
  'use strict';
  const D = globalThis.Danchi = globalThis.Danchi || {};
  const W = 960, H = 540, RESOLUTION = D.DATA?.renderScale || 2;
  const quantize = value => Math.round(value * RESOLUTION) / RESOLUTION;
  const palette = {
    ink: '#243042', wall: '#c7b49c', wallLight: '#e1ceb0', wallShade: '#9b9290',
    iron: '#333b48', ironLight: '#818992', warm: '#f2b25d', leaf: '#538332',
  };
  const rect = (c, color, x, y, w, h) => {
    c.fillStyle = color; c.fillRect(quantize(x), quantize(y), quantize(w), quantize(h));
  };
  function poly(c, color, points) {
    c.fillStyle = color; c.beginPath();
    points.forEach(([x, y], i) => i ? c.lineTo(quantize(x), quantize(y)) : c.moveTo(quantize(x), quantize(y)));
    c.closePath(); c.fill();
  }
  function line(c, color, x1, y1, x2, y2, width = 1) {
    c.strokeStyle = color; c.lineWidth = width; c.beginPath();
    c.moveTo(quantize(x1), quantize(y1)); c.lineTo(quantize(x2), quantize(y2)); c.stroke();
  }
  function hash(a, b = 0) { const v = Math.sin(a * 127.1 + b * 311.7) * 43758.5453; return v - Math.floor(v); }
  // Both endings share this clock once the first structural impact occurs.
  function collapseTime(state) {
    return (state.endingTime || 0) - (state.endingReason === 'collapse' ? 0 : (D.DATA?.tuning?.rocketFlight || .68));
  }
  function sinkingOffset(time) {
    const impacts = D.DATA?.structure?.floorImpactTimes || [.65, 1.35, 2.1];
    const settled = D.DATA?.structure?.settledTime || 4;
    const keys = [[0, 0], [.18, 3], [impacts[0], 78], [impacts[1], 181], [impacts[2], 292], [settled - .8, 387], [settled, 461]];
    if (time <= 0) return 0;
    for (let i = 1; i < keys.length; i++) {
      if (time > keys[i][0]) continue;
      const [start, from] = keys[i - 1], [end, to] = keys[i];
      const p = Math.max(0, Math.min(1, (time - start) / (end - start)));
      // Brief resistance at each slab, then the weight accelerates through it.
      const ease = p * p * (2.3 - 1.3 * p);
      return from + (to - from) * ease;
    }
    return keys.at(-1)[1];
  }
  function cloud(c, x, y, size, color) {
    const s = size;
    [[0, 2, 10, 3], [2, 0, 6, 5], [5, -2, 4, 7], [9, 1, 5, 4], [12, 3, 3, 2]].forEach(a => rect(c, color, x + a[0] * s, y + a[1] * s, a[2] * s, a[3] * s));
  }
  function foliage(c, x, y, s, seed = 0, bright = false) {
    const colors = bright ? ['#365e28', '#527e29', '#719c37', '#8baa42'] : ['#304e30', '#436b32', '#5b8233', '#74913a'];
    for (let i = 0; i < 25; i++) {
      const dx = (hash(i + seed, 1) - .5) * s, dy = (hash(i + seed, 2) - .5) * s * .7;
      const r = s * (.12 + hash(i + seed, 3) * .14);
      rect(c, colors[i % 4], x + dx, y + dy, r, r * .8);
    }
  }
  function plant(c, x, y, scale = 1, style = 0) {
    const s = scale;
    rect(c, '#593e33', x - 7 * s, y - 1 * s, 14 * s, 3 * s);
    poly(c, '#bb6740', [[x - 6 * s, y], [x + 6 * s, y], [x + 4 * s, y + 10 * s], [x - 4 * s, y + 10 * s]]);
    rect(c, '#e09152', x - 7 * s, y - 1 * s, 14 * s, 3 * s);
    line(c, '#567635', x, y, x + 1 * s, y - 21 * s, Math.max(1, s));
    for (let i = 0; i < 8; i++) {
      const yy = y - (i * 2.4 + 4) * s, side = i % 2 ? -1 : 1;
      const xx = x + side * (7 + (i % 3) * 3) * s;
      line(c, '#577930', x, yy + 3 * s, xx, yy - 4 * s, Math.max(1, s * 2));
      poly(c, i % 3 === 0 ? '#81a741' : '#50792f', [[xx, yy - 8 * s], [xx + side * 5 * s, yy - 3 * s], [xx - side * 2 * s, yy], [xx - side * 4 * s, yy - 2 * s]]);
    }
    if (style === 1) for (let i = 0; i < 4; i++) rect(c, ['#ef9dad', '#f3d08e'][i % 2], x + (i - 2) * 5 * s, y - 20 * s + i % 2 * 4 * s, 4 * s, 4 * s);
  }
  function bottle(c, x, y, s = 1) {
    rect(c, '#172b1d', x - 4 * s, y - 33 * s, 8 * s, 33 * s);
    rect(c, '#386e30', x - 3 * s, y - 27 * s, 6 * s, 26 * s);
    rect(c, '#487a37', x - 2 * s, y - 38 * s, 4 * s, 12 * s);
    rect(c, '#8cac4d', x - 2 * s, y - 38 * s, 4 * s, 2 * s);
    rect(c, '#d1ceb1', x - 3 * s, y - 18 * s, 6 * s, 11 * s);
    rect(c, '#66866a', x, y - 16 * s, 2 * s, 7 * s);
    rect(c, '#8db466', x - 2 * s, y - 30 * s, 1 * s, 8 * s);
  }
  function chair(c, x, y, color = '#617d7f') {
    rect(c, '#343d42', x - 8, y - 21, 17, 15); rect(c, color, x - 6, y - 19, 13, 11);
    rect(c, '#333b42', x - 10, y - 5, 22, 4); rect(c, color, x - 8, y - 5, 18, 2);
    line(c, '#393e43', x - 7, y - 2, x - 10, y + 13, 2); line(c, '#393e43', x + 8, y - 2, x + 11, y + 13, 2);
  }
  function bicycle(c, x, y, color = '#b5503e') {
    c.strokeStyle = '#28343e'; c.lineWidth = 2;
    [x, x + 27].forEach(xx => { c.beginPath(); c.arc(xx, y, 10, 0, Math.PI * 2); c.stroke(); });
    line(c, '#829194', x - 8, y, x + 8, y); line(c, '#829194', x + 19, y, x + 35, y);
    line(c, color, x, y, x + 11, y - 15, 2); line(c, color, x + 11, y - 15, x + 17, y, 2);
    line(c, color, x, y, x + 17, y, 2); line(c, color, x + 17, y, x + 23, y - 15, 2);
    line(c, color, x + 11, y - 15, x + 23, y - 15, 2); line(c, color, x + 23, y - 15, x + 27, y, 2);
    rect(c, '#31393b', x + 7, y - 19, 10, 3); line(c, '#333d42', x + 23, y - 15, x + 21, y - 23, 2);
    line(c, '#333d42', x + 20, y - 23, x + 28, y - 23, 2);
  }
  const decorationPainters = {
    lounge(c, x, y) { chair(c, x + 21, y + 57, '#567b7e'); rect(c, '#4b4541', x + 77, y + 57, 19, 3); line(c, '#5b5044', x + 83, y + 60, x + 83, y + 73, 2); bottle(c, x + 81, y + 56, .42); },
    laundry(c, x, y) {
      line(c, '#3f4c55', x + 2, y + 14, x + 108, y + 14);
      [[13, '#d7e0d6', 18], [39, '#809bbe', 20], [69, '#d5bb9e', 15]].forEach(([dx, col, hh]) => {
        rect(c, '#655744', x + dx + 3, y + 12, 2, 4); rect(c, col, x + dx, y + 15, 17, hh);
        rect(c, '#ffffff30', x + dx + 2, y + 17, 3, hh - 2);
      });
    },
    repair(c, x, y) { rect(c, '#844d3b', x + 13, y + 64, 20, 10); rect(c, '#ad6b43', x + 11, y + 62, 24, 4); rect(c, '#303f49', x + 20, y + 59, 7, 3); line(c, '#8a8c8a', x + 39, y + 70, x + 52, y + 55, 2); },
    bicycle(c, x, y) { bicycle(c, x + 60, y + 65); },
    garden(c, x, y) { plant(c, x + 10, y + 61, .65); plant(c, x + 95, y + 63, .8, 1); },
    shrine(c, x, y) { rect(c, '#684f3c', x + 68, y + 54, 34, 18); rect(c, '#a9664a', x + 66, y + 53, 38, 3); rect(c, '#8d733d', x + 80, y + 38, 12, 15); rect(c, '#e0b460', x + 83, y + 34, 6, 9); [73, 95].forEach(dx => { rect(c, '#e3d4ab', x + dx, y + 44, 2, 9); rect(c, '#fdd57b', x + dx, y + 42, 2, 2); }); },
    boxes(c, x, y) { rect(c, '#a6855c', x + 79, y + 54, 25, 20); rect(c, '#c3a477', x + 79, y + 54, 25, 4); rect(c, '#d7bb91', x + 89, y + 54, 3, 20); rect(c, '#7b7666', x + 79, y + 69, 25, 2); rect(c, '#8b9b82', x + 5, y + 59, 10, 15); },
    telescope(c, x, y) { line(c, '#4c5355', x + 92, y + 45, x + 80, y + 71, 2); line(c, '#4c5355', x + 92, y + 45, x + 102, y + 72, 2); line(c, '#9ab6b0', x + 82, y + 47, x + 103, y + 36, 6); line(c, '#3c515b', x + 102, y + 35, x + 104, y + 39, 3); },
    storage(c, x, y) { rect(c, '#5e625c', x + 8, y + 33, 21, 41); rect(c, '#a7aaa0', x + 10, y + 35, 17, 37); rect(c, '#767c76', x + 10, y + 46, 17, 2); rect(c, '#4b5451', x + 23, y + 39, 2, 5); rect(c, '#4b5451', x + 23, y + 54, 2, 8); },
    antenna(c, x, y) { line(c, '#414a51', x + 89, y + 4, x + 89, y + 65, 2); line(c, '#51535b', x + 76, y + 18, x + 102, y + 11, 2); [77, 84, 92, 99].forEach(dx => line(c, '#60646b', x + dx, y + 8, x + dx, y + 23)); },
  };
  const decorationRecipes = {
    chair: (c, x, y) => chair(c, x + 19, y + 57),
    table(c, x, y) { rect(c, '#4b4541', x + 78, y + 56, 20, 3); line(c, '#5b5044', x + 86, y + 59, x + 86, y + 73, 2); },
    bottle: (c, x, y) => bottle(c, x + 84, y + 56, .42),
    toolbox: decorationPainters.repair,
    bike: decorationPainters.bicycle,
    altar: decorationPainters.shrine,
    box: decorationPainters.boxes,
    fridge: decorationPainters.storage,
    antenna: decorationPainters.antenna,
    laundry: decorationPainters.laundry,
    clothesline(c, x, y) { line(c, '#434e53', x + 2, y + 14, x + 107, y + 14); },
  };

  class Renderer {
    constructor(canvas) {
      this.canvas = canvas; this.ctx = canvas.getContext('2d', { alpha: false });
      canvas.width = W * RESOLUTION; canvas.height = H * RESOLUTION; this.ctx.imageSmoothingEnabled = false;
      this.backdrop = document.createElement('canvas'); this.backdrop.width = W * RESOLUTION; this.backdrop.height = H * RESOLUTION;
      this.backgroundContext = this.backdrop.getContext('2d'); this.backgroundContext.imageSmoothingEnabled = false;
      this.backgroundContext.scale(RESOLUTION, RESOLUTION);
      this.distant = document.createElement('canvas'); this.distant.width=W*RESOLUTION; this.distant.height=H*RESOLUTION;
      const distantContext=this.distant.getContext('2d');distantContext.scale(RESOLUTION,RESOLUTION);distantContext.imageSmoothingEnabled=false;
      this.facade = document.createElement('canvas'); this.facade.width=W*RESOLUTION; this.facade.height=H*RESOLUTION;
      const facadeContext=this.facade.getContext('2d');facadeContext.scale(RESOLUTION,RESOLUTION);facadeContext.imageSmoothingEnabled=false;
      this.front = document.createElement('canvas'); this.front.width = W * RESOLUTION; this.front.height = H * RESOLUTION;
      const foregroundContext = this.front.getContext('2d'); foregroundContext.scale(RESOLUTION, RESOLUTION);
      this.makeScenery(distantContext);this.drawBuilding(facadeContext);
      this.backgroundContext.drawImage(this.distant,0,0,W,H);this.backgroundContext.drawImage(this.facade,0,0,W,H);
      this.makeForeground(foregroundContext);
    }
    makeScenery(c) {
      const sky = c.createLinearGradient(0, 0, 0, 410); sky.addColorStop(0, '#4499e7'); sky.addColorStop(.5, '#91c8ed'); sky.addColorStop(1, '#d0d9c9');
      c.fillStyle = sky; c.fillRect(0, 0, W, H);
      cloud(c, 12, 25, 5, '#d2e7f6'); cloud(c, 268, 16, 3, '#c7e1f6'); cloud(c, 528, 6, 4, '#c5e0f7'); cloud(c, 824, 29, 5, '#d5e7f4');
      cloud(c, 103, 68, 2, '#bddbf1'); cloud(c, 730, 72, 2, '#d5e7f4');
      for (let layer = 0; layer < 3; layer++) {
        const tint = [['#8bafe0', '#a2b4d4', '#9eb9de'], ['#afa3b3', '#a4acbf', '#99adc5'], ['#ccada3', '#b5a6a4', '#a7aeb5']][layer];
        let x = -19;
        for (let i = 0; x < W; i++) {
          const width = 24 + hash(i, layer + 2) * 41, h = 70 + hash(i, layer + 5) * (105 + layer * 20), bottom = 295 + layer * 48;
          rect(c, tint[i % 3], x, bottom - h, width, h);
          rect(c, '#ffffff16', x + 1, bottom - h + 2, 3, h);
          if (i % 3 === 1) rect(c, tint[(i + 1) % 3], x + width * .3, bottom - h - 6, width * .4, 6);
          for (let yy = bottom - h + 11; yy < bottom - 7; yy += 14) {
            for (let xx = x + 7; xx < x + width - 6; xx += 10) rect(c, layer === 2 ? '#727f982e' : '#dae4f542', xx, yy, 3, 6);
          }
          x += width + 3;
        }
      }
      [[64, 141], [890, 128], [109, 166]].forEach(([x, y]) => {
        line(c, '#879bbe', x, y, x, y + 64, 2); line(c, '#7f94b8', x - 18, y + 6, x + 27, y + 6, 2);
        line(c, '#829bc0', x - 18, y + 6, x, y - 2); line(c, '#829bc0', x, y - 2, x + 27, y + 6); line(c, '#8da3c0', x + 22, y + 7, x + 22, y + 25);
      });
      // The diagonal street supplies the depth without moving the camera.
      poly(c, '#7b858b', [[-20, 419], [138, 313], [193, 339], [14, 521]]);
      poly(c, '#b4b3a1', [[-15, 405], [134, 309], [144, 318], [-10, 448]]);
      line(c, '#d8c9a2', 15, 453, 161, 331, 2);
      [[33, 423, '#e8b33e'], [78, 385, '#d8dac6'], [111, 358, '#d9794f'], [15, 474, '#d5b24b']].forEach(([x, y, color]) => {
        poly(c, '#3e515c', [[x - 3, y + 4], [x + 13, y - 8], [x + 24, y - 3], [x + 8, y + 10]]);
        poly(c, color, [[x - 3, y], [x + 12, y - 12], [x + 22, y - 7], [x + 7, y + 6]]);
        poly(c, '#6d95a2', [[x + 3, y - 4], [x + 12, y - 10], [x + 18, y - 7], [x + 9, y - 1]]);
      });
      for (let i = 0; i < 15; i++) {
        const left = i % 2 === 0, x = left ? 14 + hash(i, 0) * 106 : 826 + hash(i, 0) * 117, y = 296 + hash(i, 1) * 144;
        rect(c, '#7b7650', x + 8, y + 8, 3, 37); foliage(c, x, y, 39 + hash(i, 4) * 25, i, true);
      }
      // Side-wall slogans are deliberately sparse, like faded painted ads.
      c.save(); c.globalAlpha = .65; c.font = 'bold 10px monospace'; c.fillStyle = '#526f8e';
      ['SAME', 'IDIOTS', 'DIFFERENT', 'DAY.'].forEach((s, i) => c.fillText(s, 22, 234 + i * 13));
      c.strokeStyle = '#5b7790'; c.lineWidth = 2; c.beginPath(); c.arc(42, 305, 10, 0, Math.PI * 2); c.stroke(); rect(c, '#5b7790', 37, 300, 2, 3); rect(c, '#5b7790', 45, 300, 2, 3);
      c.beginPath(); c.arc(42, 305, 5, 0, Math.PI); c.stroke();
      ['BETTER', 'NEIGHBORS', 'TOMORROW?'].forEach((s, i) => c.fillText(s, 860, 305 + i * 13)); c.restore();
    }
    drawBuilding(c) {
      // Roof furniture lives behind a thick concrete cornice.
      rect(c, '#8d837e', 177, 58, 47, 22); rect(c, '#ad9b8b', 180, 56, 42, 4);
      for (let i = 0; i < 7; i++) rect(c, '#766f70', 190 + i * 3, 64, 1, 11);
      rect(c, '#8b7970', 431, 46, 69, 34); rect(c, '#ad9580', 432, 43, 67, 4);
      rect(c, '#796d68', 450, 47, 4, 31); rect(c, '#bc9d7b', 484, 49, 10, 29);
      [[271, 46], [302, 60], [529, 56], [708, 51], [773, 53]].forEach(([x, y]) => { rect(c, '#796c65', x, y, 7, 80 - y); rect(c, '#c3a27d', x + 1, y + 2, 2, 75 - y); rect(c, '#77706a', x - 2, y, 11, 3); });
      // Water tower.
      [623, 651].forEach(x => line(c, '#514e4f', x, 38, x - 6, 78, 4));
      line(c, '#655d54', 620, 49, 653, 68, 2); line(c, '#655d54', 651, 49, 618, 68, 2);
      rect(c, '#775e4c', 615, 1, 43, 43); rect(c, '#a67e50', 620, 2, 32, 39); rect(c, '#c19459', 625, 2, 10, 39);
      rect(c, '#66584e', 611, 0, 51, 4); rect(c, '#655746', 613, 38, 48, 5);
      rect(c, '#775e4c', 638, 0, 2, 40); rect(c, '#846b53', 649, 0, 2, 40);
      [[395, 24], [567, 39], [691, 28]].forEach(([x, y]) => {
        line(c, '#655948', x, y, x, 81, 3); line(c, '#b7a083', x + 2, y, x + 2, 76);
        [12, 28].forEach(dy => { line(c, '#665c52', x - 12, y + dy, x + 15, y + dy - 3, 2); [-10, 1, 12].forEach(dx => rect(c, '#786752', x + dx, y + dy - 8, 3, 6)); });
      });
      c.strokeStyle = '#645e6266'; c.lineWidth = 1; c.beginPath(); c.moveTo(395, 39); c.quadraticCurveTo(480, 71, 567, 51); c.quadraticCurveTo(632, 71, 691, 40); c.stroke();
      rect(c, '#79787a', 134, 81, 697, 368); rect(c, palette.wall, 131, 85, 693, 352);
      rect(c, '#e2cdb0', 131, 85, 6, 352); rect(c, '#948d8b', 816, 85, 12, 354);
      // The offset slabs and shadows break up the rectilinear facade.
      for (let row = 0; row < 3; row++) {
        const y = 117 + row * 105;
        rect(c, '#968b82', 140, y - 5, 683, 90);
        rect(c, '#c9b7a0', 138, y - 2, 683, 84);
        rect(c, '#ac9d8e', 138, y + 74, 684, 12);
        for (let col = 0; col < 5; col++) this.drawRoom(c, 150 + col * 138, y, col, row);
        poly(c, '#9a8e84', [[132, y + 84], [827, y + 84], [820, y + 95], [138, y + 95]]);
        rect(c, '#6b6d71', 138, y + 90, 683, 4);
        rect(c, '#d8c5a6', 130, y + 80, 699, 10); rect(c, '#ebd8b7', 130, y + 80, 699, 3);
        rect(c, '#b5a38d', 130, y + 88, 699, 3);
        for (let col = 0; col < 5; col++) { rect(c, '#afa08b', 147 + col * 138, y + 82, 2, 7); rect(c, '#fae6bd', 154 + col * 138, y + 82, 34, 1); }
      }
      // Concrete seams, chips and small repairs are fixed and deterministic.
      for (let col = 0; col < 4; col++) {
        const x = 270 + col * 138;
        rect(c, '#a6998c', x, 111, 13, 321); rect(c, '#d7c5aa', x, 111, 3, 321);
        line(c, '#928780', x + 9, 108, x + 9, 433);
        for (let i = 0; i < 9; i++) {
          const yy = 122 + i * 36 + hash(col, i) * 8;
          rect(c, '#e1cbaa', x + 1, yy, 3, 7); rect(c, '#8f877f', x + 10, yy + 2, 2, 5);
        }
      }
      for (let i = 0; i < 130; i++) {
        const x = 135 + hash(i, 10) * 685, y = 96 + hash(i, 17) * 10;
        rect(c, i % 3 ? '#9a908133' : '#f1d7ae88', x, y, 2 + hash(i, 4) * 6, 1 + hash(i, 5) * 2);
      }
      [[281, 107, 319], [696, 104, 333], [143, 109, 330]].forEach(([x, y, h]) => {
        rect(c, '#696d70', x, y, 3, h); rect(c, '#abb0a6', x, y, 1, h);
        for (let yy = y + 20; yy < y + h; yy += 51) rect(c, '#7a7974', x - 2, yy, 7, 3);
      });
      rect(c, '#686f75', 129, 103, 701, 6); rect(c, '#968e85', 123, 79, 709, 25);
      rect(c, '#c8b69a', 123, 78, 709, 21); rect(c, '#dfcaa7', 123, 78, 709, 3);
      rect(c, '#b8a387', 123, 95, 709, 4);
      [219, 363, 568, 742].forEach(x => { line(c, '#8f867b', x, 78, x + 1, 99); rect(c, '#e7cd9d', x + 1, 81, 3, 2); });
      // Shallow basement strip, a little overgrown.
      rect(c, '#879082', 134, 432, 690, 17); rect(c, '#707a73', 157, 437, 656, 13);
      for (let i = 0; i < 12; i++) foliage(c, 120 + i * 67, 446 + hash(i, 2) * 8, 33, i + 91);
    }
    drawRoom(c, x, y, col, row) {
      const n = row * 5 + col, warm = [true, false, true, true, false, false, true, true, false, true, true, true, false, true, true][n];
      rect(c, '#7e776f', x + 4, y - 2, 101, 77); rect(c, '#dfc8a8', x + 3, y - 3, 103, 3);
      rect(c, '#55585e', x + 8, y + 1, 94, 74); rect(c, warm ? '#4b4140' : '#414e61', x + 12, y + 5, 87, 68);
      rect(c, warm ? '#806148' : '#596778', x + 20, y + 7, 77, 54);
      rect(c, warm ? '#a7794c' : '#626e80', x + 49, y + 7, 47, 54);
      poly(c, warm ? '#bc8649' : '#6c7b8e', [[x + 51, y + 7], [x + 96, y + 7], [x + 96, y + 59], [x + 72, y + 59]]);
      rect(c, '#3e4045', x + 20, y + 58, 77, 15);
      rect(c, warm ? '#72604a' : '#4d5b67', x + 23, y + 59, 74, 10);
      if (n % 3 === 0) {
        rect(c, '#5a4a40', x + 70, y + 28, 22, 27); rect(c, '#aa8657', x + 71, y + 30, 20, 1);
        [0, 1, 2, 3].forEach(i => rect(c, ['#7e8b6b', '#b27155', '#bfac79', '#7c8392'][i], x + 73 + i * 4, y + 34 - i % 2 * 3, 3, 11 + i % 2 * 3));
        rect(c, '#a08660', x + 71, y + 46, 20, 2);
      } else if (n % 3 === 1) {
        rect(c, '#3e464e', x + 60, y + 37, 28, 21); rect(c, '#759198', x + 63, y + 40, 21, 13); rect(c, '#a0afb044', x + 63, y + 40, 21, 2);
        rect(c, '#524d41', x + 58, y + 58, 33, 6);
      } else {
        rect(c, '#967050', x + 67, y + 28, 20, 19); rect(c, '#d2b78f', x + 69, y + 30, 16, 15); poly(c, '#798366', [[x + 69, y + 45], [x + 77, y + 35], [x + 83, y + 43], [x + 85, y + 39], [x + 85, y + 45]]);
      }
      if (warm) {
        rect(c, '#e5b06316', x + 56, y + 21, 33, 41); rect(c, '#e4b96b19', x + 64, y + 24, 18, 35);
        const lx = x + (n % 2 ? 60 : 87);
        line(c, '#493f37', lx, y + 37, lx, y + 59, 2); rect(c, '#665141', lx - 6, y + 59, 13, 2);
        poly(c, '#f0bb67', [[lx - 5, y + 29], [lx + 5, y + 29], [lx + 8, y + 39], [lx - 8, y + 39]]);
        rect(c, '#ffe1a4', lx - 3, y + 30, 5, 8);
      }
      // Sliding frames are static. Curtains and both panes are drawn from live damage state.
      rect(c, '#343e4e', x + 9, y + 3, 3, 68); rect(c, '#313e4e', x + 53, y + 3, 4, 68); rect(c, '#879093', x + 56.5, y + 4, .5, 67);
      rect(c, '#343b48', x + 98, y + 3, 3, 68); rect(c, '#b2a58e', x + 9, y + 70, 94, 3);
      line(c, '#b8b9ab', x + 11.5, y + 4, x + 11.5, y + 68, .5);
      line(c, '#cbc6b0', x + 98, y + 4.5, x + 98, y + 69, .5);
      line(c, '#d4b995', x + 12, y + 71.5, x + 99, y + 71.5, .5);
      rect(c, '#292f36', x + 51, y + 38.5, 1.5, 8); rect(c, '#a6aaa1', x + 51.5, y + 39, .5, 6);
      rect(c, '#bbaa91', x - 3, y + 72, 116, 8);
      poly(c, '#e1c6a0', [[x - 3, y + 73], [x + 112, y + 73], [x + 107, y + 77], [x, y + 77]]);
      // Weathering on the exposed shoulders, not across the actor silhouette.
      for (let i = 0; i < 6; i++) {
        rect(c, '#a09587', x - 10 + hash(i, n + 1) * 11, y + hash(i, n + 7) * 60, 2 + hash(n, i) * 4, 2);
      }
    }
    makeForeground(c) {
      c.imageSmoothingEnabled = false;
      // A near balcony rail in perspective occupies the bottom edge only.
      poly(c, '#171f2a', [[0, 435], [174, 432], [715, 499], [960, 483], [960, 495], [714, 512], [174, 445], [0, 448]]);
      poly(c, '#a1aaa8', [[0, 431], [174, 428], [715, 494], [960, 478], [960, 484], [714, 501], [172, 436], [0, 439]]);
      for (let i = 0; i < 10; i++) {
        const x = 75 + i * 81, y = 435 + Math.max(0, x - 174) * .125;
        rect(c, '#242f3b', x, y, 7, 110); rect(c, '#58616b', x, y, 2, 110);
      }
      // Striped towel draped across the front rail.
      poly(c, '#dcddd3', [[135, 428], [194, 429], [198, 491], [141, 486]]);
      poly(c, '#aa514a', [[146, 428], [160, 429], [164, 487], [149, 486]]);
      poly(c, '#45678f', [[174, 429], [187, 430], [191, 490], [178, 489]]);
      rect(c, '#eadfbd', 141, 484, 58, 3);
      // Left foreground is a deliberately oversized domestic still life.
      plant(c, 44, 449, 2.4);
      for (let i = 0; i < 7; i++) {
        const x = 29 + (i % 3) * 12, y = 431 - i * 7;
        poly(c, ['#315b2d', '#477333', '#638b32'][i % 3], [[43, 449], [x - 20, y - 23], [x - 16, y - 39], [x - 5, y - 28], [x + 8, y - 29], [x + 13, y - 11]]);
      }
      poly(c, '#603f2d', [[0, 501], [190, 480], [338, 540], [0, 540]]);
      poly(c, '#bc8856', [[0, 493], [185, 475], [323, 525], [318, 533], [0, 516]]);
      line(c, '#d2a370', 8, 497, 177, 481, 2); line(c, '#966d49', 171, 483, 297, 527);
      poly(c, '#9c7854', [[64, 486], [164, 471], [223, 491], [220, 516], [67, 521]]);
      poly(c, '#e3c79b', [[60, 480], [160, 467], [224, 488], [116, 505]]);
      poly(c, '#c8a478', [[116, 505], [224, 488], [224, 506], [116, 523]]);
      poly(c, '#e0bd8d', [[60, 480], [116, 505], [116, 523], [60, 499]]);
      c.save(); c.translate(132, 512); c.rotate(-.16); c.font = '900 13px monospace'; c.fillStyle = '#33363c'; c.fillText('PIZZA :)', 0, 0); c.restore();
      poly(c, '#b65b49', [[104, 481], [133, 474], [154, 485], [118, 487]]); rect(c, '#dec398', 121, 479, 7, 3); rect(c, '#dec398', 135, 482, 6, 2);
      bottle(c, 24, 534, 1.8); bottle(c, 289, 526, 1.1);
    }
    drawCellContents(c, cell, art, time) {
      const x=cell.x,y=cell.y, fire=cell.roomFire, burned=!!fire&&(fire.everIgnited||['burning','spreading','smoldering','out'].includes(fire.stage));
      if(burned){
        const loss=fire.stage==='burning'?Math.min(1,(fire.age||0)/3):1, bottom=y+63-loss*26;
        poly(c,'#49484b',[[x+13,y+6],[x+31,y+6],[x+29,bottom-6],[x+26,bottom+4],[x+23,bottom-4],[x+19,bottom+1],[x+18,bottom-7],[x+13,bottom-2]]);
        line(c,'#766157',x+17,y+8,x+18,bottom-7,1);line(c,'#75665e',x+24,y+8,x+24,bottom-6,.5);
      }else{
        rect(c,'#758398',x+13,y+6,17,58);rect(c,'#5c6b81',x+13,y+6,4,58);
        rect(c,'#929aad',x+20,y+6,3,57);rect(c,'#68768f',x+27,y+6,5,57);
      }
      (cell.decoration || []).forEach((type, index) => {
        if (cell.laundryBurned && ['laundry', 'futon', 'clothesline'].includes(type)) return;
        const recipe = decorationRecipes[type];
        if (recipe) recipe(c, cell.x, cell.y);
        else if (['cup', 'pigeon', 'tv', 'bucket'].includes(type) && art.prop) {
          art.prop(c, { type, x: cell.x + (index % 2 ? 80 : 7), y: cell.y + 46, w: 20, h: 20, state: 'intact' }, {}, time);
        }
      });
    }
    drawRail(c, cell, rail) {
      const x = rail?.x ?? cell.x, y = rail?.y ?? cell.y + 59, width = rail?.w ?? cell.w;
      const height = rail?.h ?? 24, damaged = rail?.state === 'hit' || (rail?.hp ?? 1) < (rail?.maxHp ?? 1) * .65;
      rect(c, '#252f3e', x, y, width, 3); rect(c, '#8b9699', x, y, width, 1);
      for (let i = 0; i <= 11; i++) {
        const xx = x + 3 + i * (width - 6) / 11;
        if (damaged && i > 4 && i < 8) { line(c, '#3a424c', xx, y + 1, xx - 5, y + 12, 2); continue; }
        rect(c, '#363e49', xx, y + 2, 2, height - 5); rect(c, '#9a9b95', xx, y + 2, 1, height - 5);
      }
      rect(c, '#4d5156', x, y + height - 3, width, 2);
      line(c, '#bbc0b4', x + .5, y + .5, x + width - .5, y + .5, .5);
    }
    drawBulletMarks(c, state, propMap, railsOnly = false) {
      for (const mark of state.bulletMarks || []) {
        const prop = mark.propId ? propMap.get(mark.propId) : null;
        if (mark.propId && (!prop || prop.hp <= 0 || prop.state === 'broken' || prop.state === 'falling')) continue;
        if(prop?.type==='window'&&prop.panes?.some(pane=>pane.stage==='shattered'&&mark.x>=pane.x&&mark.x<=pane.x+pane.w&&mark.y>=pane.y&&mark.y<=pane.y+pane.h))continue;
        if ((prop?.type === 'rail') !== railsOnly) continue;
        const s = Math.max(.6, mark.size || 1.5), v = mark.variant || 0;
        c.save(); c.translate(quantize(mark.x), quantize(mark.y)); c.rotate(mark.rotation || 0);
        if (mark.kind === 'glass') {
          rect(c, '#293c4666', -.5, -.5, 1, 1);
          for (let i = 0; i < 3; i++) {
            const angle = i * Math.PI * 2 / 3 + v * .18;
            line(c, '#d8edddbb', 0, 0, Math.cos(angle) * s * 2.5, Math.sin(angle) * s * 2.5, .5);
          }
        } else if (mark.kind === 'dent') {
          poly(c, '#b8b8a8', [[-s,-s*.4],[-s*.3,-s*.85],[s*.8,-s*.6],[s,s*.3],[s*.25,s*.7],[-s*.8,s*.6]]);
          poly(c, '#303b43', [[-s*.8,-s*.4],[s*.5,-s*.65],[s*.7,s*.2],[-s*.15,s*.45],[-s*.7,s*.2]]);
          line(c, '#d0ceac', -s*.5, s*.65, s*.7, s*.4, .5);
          if (mark.shape !== 'pellet') line(c, '#27323e', -s*.8, -s*.25, -s*1.5, -s*.55, .5);
        } else {
          const long = mark.shape === 'slash' ? 1.35 : 1;
          poly(c, '#e0c7a5', [[-s*1.2,-s*.25],[-s*.45,-s*long],[s*.15,-s*.8],[s*.85,-s*.55],[s,s*.35],[s*.3,s*.8],[-s*.6,s*.65]]);
          poly(c, '#877361', [[-s,-s*.3],[-s*.35,-s*.8*long],[s*.5,-s*.5],[s*.75,s*.1],[s*.1,s*.5],[-s*.7,s*.35]]);
          poly(c, '#3e3d3c', [[-s*.45,-s*.3],[s*.15,-s*.45],[s*.4,0],[0,s*.25],[-s*.5,s*.1]]);
          line(c, '#f0d9b6', -s*.5, s*.65, s*.3, s*.6, .5);
          if (mark.shape !== 'pellet') {
            const arms = mark.shape === 'slash' ? 2 : 3;
            for (let i = 0; i < arms; i++) {
              const angle = i * Math.PI * 2 / arms + v * .28, length = s * (1.65 + hash(v + i, 4) * .55);
              const x1 = Math.cos(angle) * s * .6, y1 = Math.sin(angle) * s * .6;
              const x2 = Math.cos(angle + .2) * length, y2 = Math.sin(angle + .2) * length;
              line(c, '#776b5f', x1, y1, x2, y2, .5);
              line(c, '#a7967e', x2, y2, x2 + Math.cos(angle - .45) * s * .6, y2 + Math.sin(angle - .45) * s * .6, .5);
            }
          }
        }
        c.restore();
      }
    }
    drawDamage(c, cell) {
      const amount = Math.max(typeof cell.damage === 'number' ? cell.damage : cell.damage ? 1 : 0, Math.min(3,(cell.roomShotDamage||0)/140));
      if (!amount) return;
      const x = cell.x + 104, y = cell.y + 20;
      line(c, '#6c655e', x + 2, y - 15, x - 4, y - 3, 2); line(c, '#6c655e', x - 4, y - 3, x + 2, y + 2, 2);
      line(c, '#776e63', x + 2, y + 2, x - 6, y + 13); line(c, '#776e63', x - 6, y + 13, x - 2, y + 24);
      if (amount > 1) {
        poly(c, '#5d5a56', [[cell.x - 10, cell.y + 25], [cell.x - 2, cell.y + 18], [cell.x + 8, cell.y + 25], [cell.x + 5, cell.y + 35], [cell.x - 8, cell.y + 40]]);
        rect(c, '#a58d73', cell.x - 10, cell.y + 24, 6, 4);
      }
      if (amount > 2 || cell.damageState === 'burned') { c.save(); c.globalAlpha = cell.damageState === 'burned' ? .28 : .18; rect(c, '#2d2b2c', cell.x, cell.y + 2, cell.w, cell.h - 6); c.restore(); }
      if(amount>1.5){
        for(let i=0;i<6;i++){
          const xx=cell.x+8+i*17,yy=cell.y+80,sz=2+hash(i,cell.col+cell.row)*4;
          poly(c,'#877a6b',[[xx,yy],[xx+sz,yy-3],[xx+sz+3,yy+1],[xx+2,yy+4]]);
          line(c,'#dbc4a5',xx,yy,xx+sz,yy-3,.5);
        }
      }
    }
    drawRoomFire(c, cell, time, front = false) {
      const fire=cell.roomFire;if(!fire||(!fire.everIgnited&&!fire.stage))return;
      const stage=fire.stage||'burning',active=stage==='burning'||stage==='spreading';
      const intensity=Math.max(0,Math.min(1,fire.intensity??(stage==='spreading'?1:stage==='burning'?.6:stage==='smoldering'?.2:0)));
      const x=cell.x,y=cell.y,phase=time*9+cell.col*2.3+cell.row;
      const flame=(fx,fy,height,width,seed)=>{
        const flicker=Math.sin(phase+seed)*width*.3;
        poly(c,'#d8532c',[[fx-width*.65,fy],[fx-width*.7,fy-height*.35],[fx-width*.25,fy-height*.65],[fx+flicker,fy-height],[fx+width*.1,fy-height*.5],[fx+width*.5,fy-height*.7],[fx+width*.8,fy-height*.27],[fx+width*.6,fy]]);
        poly(c,'#ff9c38',[[fx-width*.43,fy],[fx-width*.4,fy-height*.3],[fx+flicker*.5,fy-height*.78],[fx+width*.25,fy-height*.39],[fx+width*.4,fy-height*.49],[fx+width*.46,fy]]);
        poly(c,'#ffdb6b',[[fx-width*.2,fy],[fx-width*.2,fy-height*.18],[fx+width*.1,fy-height*.5],[fx+width*.23,fy-height*.22],[fx+width*.22,fy]]);
      };
      c.save();
      if(!front){
        // Soot remains after the fire, while illumination stays confined to this room.
        c.globalAlpha=stage==='burning'?.24:.39;
        poly(c,'#393735',[[x+11,y+4],[x+100,y+4],[x+98,y+66],[x+79,y+71],[x+59,y+65],[x+38,y+71],[x+13,y+65]]);
        c.globalAlpha=1;
        for(let i=0;i<5;i++){
          const xx=x+19+i*18,hh=8+hash(i,cell.col)*17;
          c.globalAlpha=.18+hash(i,cell.row)*.11;
          poly(c,'#514a44',[[xx-6,y+6],[xx-8,y-3],[xx-3,y-hh],[xx+2,y-hh*.7],[xx+7,y+5]]);
        }
        c.globalAlpha=1;
        if(active){
          c.save();c.beginPath();c.rect(x+12,y+5,86,66);c.clip();
          c.globalAlpha=(.1+intensity*.12)*(1+Math.sin(phase)*.12);rect(c,'#ff963f',x+12,y+5,86,66);c.globalAlpha=1;
          for(let i=0;i<6;i++){
            const height=(17+intensity*22)*( .78+Math.sin(phase*.8+i*2)*.22),fx=x+18+i*15;
            flame(fx,y+67,height,8+intensity*3,i*2);
          }
          flame(x+23,y+46,14+intensity*12,5,4);c.restore();
        }
        // A modest handful of puffs avoids burying the neighbouring silhouettes.
        const count=stage==='out'?2:stage==='smoldering'?4:5;
        for(let i=0;i<count;i++){
          const age=(time*(active?.28:.15)+i/count+cell.col*.11)%1;
          const xx=x+22+(i%3)*24+Math.sin(age*7+i)*7,yy=y+12-age*(active?66:46);
          c.globalAlpha=(1-age)*(active?.16:stage==='out'?.055:.12);
          cloud(c,xx,yy,.65+age*1.05,active?'#4e4945':'#8d8a80');
        }
        c.globalAlpha=1;
      }else if(active){
        // Small flames curl around the sides of the sill; the central target stays clear.
        flame(x+13,y+72,13+intensity*12,6,3);flame(x+99,y+72,11+intensity*15,6,9);
        for(let i=0;i<5;i++){
          const a=(time*.7+i/5)%1,xx=x+14+(i%2)*79+Math.sin(time*2+i)*3,yy=y+56-a*60;
          c.globalAlpha=(1-a)*.8;rect(c,i%2?'#ffc774':'#eb8b43',xx,yy,1,2);
        }
      }
      c.restore();
    }
    bossHole(c, boss, time) {
      const x = boss.homeX ?? boss.x, y = (boss.homeY ?? boss.y) - 57, r = 64;
      const pts = [];
      for (let i = 0; i < 24; i++) { const a = i * Math.PI / 12, radius = r * (.89 + hash(i, 28) * .21); pts.push([x + Math.cos(a) * radius, y + Math.sin(a) * radius]); }
      poly(c, '#706c68', pts.map(([xx, yy]) => [x + (xx - x) * 1.12, y + (yy - y) * 1.12]));
      poly(c, '#282e37', pts); poly(c, '#36343a', pts.map(([xx, yy]) => [x + (xx - x) * .83, y + (yy - y) * .83]));
      for (let i = 0; i < 15; i++) {
        const a = i * Math.PI * 2 / 15, rr = r * (1 + hash(i, 8) * .07), xx = x + Math.cos(a) * rr, yy = y + Math.sin(a) * rr;
        rect(c, '#d5bfa1', xx - 4, yy - 3, 6 + hash(i, 2) * 7, 5 + hash(i, 3) * 6);
        rect(c, '#8b8174', xx - 4, yy + 4, 9, 3);
      }
      if ((boss.hitTimer || 0) > 0) { c.save(); c.globalAlpha = Math.min(.16, boss.hitTimer); poly(c, '#ffbe64', pts); c.restore(); }
    }
    drawSmoke(c, x, y, t, strength = 1, burning = true) {
      for (let i = 0; i < 8; i++) {
        const age = ((t * .26 + i / 8) % 1), drift = Math.sin(age * 7 + i) * 8 + age * 10;
        c.globalAlpha = (1 - age) * .29 * strength;
        cloud(c, x + drift - age * 13, y - age * 61, .6 + age * 1.1, '#42434b');
      }
      c.globalAlpha = 1;
      if (burning) for (let i = 0; i < 5; i++) {
        const h = 9 + (Math.sin(t * 16 + i * 2) * .5 + .5) * 17, xx = x + i * 5 - 8;
        poly(c, '#ed7832', [[xx - 3, y], [xx - 2, y - h * .5], [xx + 2, y - h], [xx + 5, y - h * .25], [xx + 6, y]]);
        poly(c, '#ffd565', [[xx, y], [xx + 1, y - h * .63], [xx + 4, y - 2]]);
      }
    }
    drawDynamiteBlast(c, state) {
      // The blast outlives its actor, including a direct dynamite kill.
      if (Array.isArray(state.blasts)) {
        state.blasts.filter(blast => blast.type === 'dynamite').forEach(blast => this.drawBlast(c, blast));
      } else {
        const bundle = state.boss?.dynamite;
        if (bundle) this.drawBlast(c, { x: bundle.blastX ?? bundle.x + bundle.w * .5, y: bundle.blastY ?? bundle.y + bundle.h * .5, age: bundle.explosionAge, duration: 2.1 });
      }
    }
    drawBlast(c, blast) {
      const {x,y,age}=blast,duration=blast.duration||2.1;
      if(!Number.isFinite(age)||age<0||age>=duration)return;
      c.save();
      if(age<.7){
        const p=age/.7,expand=1-(1-p)**3,r=14+expand*69;
        c.globalAlpha=Math.max(0,1-p)*.75;c.strokeStyle='#ffe7a3';c.lineWidth=Math.max(.5,4*(1-p));
        c.beginPath();c.arc(x,y,20+p*136,0,Math.PI*2);c.stroke();
        for(let i=0;i<9;i++){
          const angle=i*Math.PI*2/9,xx=x+Math.cos(angle)*r*.63,yy=y+Math.sin(angle)*r*.5;
          const size=(13+hash(i,4)*17)*( .6+expand*.8);
          c.globalAlpha=Math.min(1,(1-p)*2.2);
          cloud(c,xx-size,yy,size/7,'#b94f2d');cloud(c,xx-size*.78,yy-size*.12,size/9,'#f79932');cloud(c,xx-size*.5,yy-size*.04,size/13,'#ffdc73');
        }
        if(p<.5){c.globalAlpha=(1-p*2)*.75;const points=[];for(let i=0;i<16;i++){const a=i*Math.PI/8,rr=r*(i%2?.28:.6);points.push([x+Math.cos(a)*rr,y+Math.sin(a)*rr]);}poly(c,'#fff3c2',points);}
      }
      if(age>.24){
        const p=Math.min(1,(age-.24)/Math.max(.01,duration-.24));
        for(let i=0;i<6;i++){
          c.globalAlpha=(1-p)*.25;
          cloud(c,x-65+i*22+Math.sin(i*2)*p*21,y-20-p*54+Math.cos(i*3)*15,1.8+p*1.6,['#655d51','#8c8070','#ada08a'][i%3]);
        }
      }
      c.restore();
    }
    drawBossBombs(c, state) {
      const time = state.time || 0;
      const position = (bomb, progress) => ({
        x: bomb.fromX + (bomb.toX - bomb.fromX) * progress,
        y: bomb.fromY + (bomb.toY - bomb.fromY) * progress - Math.sin(progress * Math.PI) * 135,
      });
      // An incoming bomb crosses in front of our balcony and grows toward the
      // viewer. Its endpoints are the actual release and impact coordinates.
      (state.bossProjectiles || []).forEach(bomb => {
        const duration = bomb.duration || D.DATA.bossFight?.bombFlightDuration || 1.1;
        if (bomb.age < 0 || bomb.age > duration) return;
        const progress = Math.max(0, Math.min(1, bomb.age / duration));
        const point = position(bomb, progress), scale = .72 + progress * progress * 2.7;
        c.save();
        c.globalAlpha = .09 + progress * .18;
        c.fillStyle = '#152433'; c.beginPath(); c.ellipse(bomb.toX, bomb.toY + 3, 12 + progress * 31, 4 + progress * 6, 0, 0, Math.PI * 2); c.fill();
        for (let i = 5; i > 0; i--) {
          const previous = Math.max(0, progress - i * .036), next = Math.max(0, progress - (i - 1) * .036);
          const from = position(bomb, previous), to = position(bomb, next);
          c.globalAlpha = (1 - i / 6) * .55;
          line(c, '#ffdf9b', from.x, from.y, to.x, to.y, 1.5 + progress * 2);
        }
        c.globalAlpha = 1; c.translate(point.x, point.y); c.rotate(-.6 + progress * 3.2); c.scale(scale, scale);
        if (D.Art.thrownBomb) D.Art.thrownBomb(c, { x: 0, y: 0, r: 12 }, time);
        c.restore();
      });
      (state.blasts || []).filter(blast => blast.type === 'bossBomb').forEach(blast => {
        const age = blast.age, duration = blast.duration || 1.1;
        if (!Number.isFinite(age) || age < 0 || age >= duration) return;
        const p = age / duration, spread = 1 - (1 - p) ** 3, x = blast.x, y = blast.y;
        c.save();
        if (age < .17) {
          c.globalAlpha = (1 - age / .17) * .3; rect(c, '#ffe1b0', 0, 405, W, 135);
        }
        c.globalAlpha = (1 - p) * .74; c.strokeStyle = '#ffe5b0'; c.lineWidth = 3 * (1 - p) + .5;
        c.beginPath(); c.ellipse(x, y - 4, 20 + spread * 195, 9 + spread * 53, 0, 0, Math.PI * 2); c.stroke();
        if (age < .5) {
          const fire = age / .5;
          for (let i = 0; i < 6; i++) {
            const xx = x - 83 + i * 30, yy = y - 12 - Math.sin(i * 1.7) * 10 - fire * (30 + hash(i, 47) * 25);
            const size = (2.7 + Math.sin(fire * Math.PI) * 3.8) * (1 - Math.abs(i - 2.5) * .12);
            c.globalAlpha = Math.min(1, (1 - fire) * 2);
            cloud(c, xx, yy, size, '#bd5732'); cloud(c, xx + size, yy - 2, size * .72, '#ffb448'); cloud(c, xx + size * 2.2, yy, size * .4, '#ffe58b');
          }
        }
        for (let i = 0; i < 17; i++) {
          const vx = (hash(i, 50) - .5) * 470, vy = 90 + hash(i, 53) * 175;
          const xx = x + vx * age, yy = y - vy * age + age * age * 190, size = 3 + hash(i, 51) * 6;
          c.globalAlpha = Math.min(1, (1 - p) * 2); c.save(); c.translate(xx, yy); c.rotate(age * (i % 2 ? -6 : 7));
          poly(c, ['#bba082', '#ded0ac', '#65636a'][i % 3], [[-size, -size * .3], [size * .65, -size * .65], [size, size * .5], [-size * .45, size * .6]]); c.restore();
        }
        if (age > .2) for (let i = 0; i < 7; i++) {
          c.globalAlpha = (1 - p) * .23;
          cloud(c, x - 134 + i * 35 - p * 12, y - p * 65 + Math.sin(i * 2) * 13, 2.4 + p * 2.4, ['#b5a18b', '#d7c4a8', '#8e8275'][i % 3]);
        }
        c.restore();
      });
    }
    drawStructuralStress(c, state) {
      const structure=state.structure;if(!structure||!structure.maxHp)return;
      const ratio=Math.max(0,Math.min(1,structure.damageRatio??1-structure.hp/structure.maxHp));
      if(ratio<.24)return;
      const strength=(ratio-.24)/.76, count=Math.min(4,1+Math.floor(strength*4));
      c.save();
      for(let column=0;column<count;column++){
        const x=274+column*138,limit=6+Math.floor(strength*7),points=[];
        for(let i=0;i<limit;i++)points.push([x+(hash(i,column+5)-.5)*13,109+i*25]);
        c.strokeStyle='#71695f';c.lineWidth=.75+strength*1.35;c.beginPath();points.forEach(([xx,yy],i)=>i?c.lineTo(xx,yy):c.moveTo(xx,yy));c.stroke();
        points.slice(1,-1).forEach(([xx,yy],i)=>{
          if(i%3!==1)return;
          const side=i%2?1:-1;
          line(c,'#877767',xx,yy,xx+side*(6+strength*9),yy+5,.75);
          line(c,'#e5cfad',xx+.5,yy+1,xx+side*7,yy+6,.5);
        });
      }
      if(strength>.36){
        for(let i=0;i<3;i++){
          const age=(state.time*.33+i/3)%1,x=270+i*208,y=203+(i%2)*105;
          c.globalAlpha=(1-age)*strength*.17;cloud(c,x+age*9,y+age*29,.42+age*.46,'#d2c2a6');
        }
      }
      c.restore();
    }
    drawCollapseFacade(c, state) {
      const t=Math.max(0,collapseTime(state)),drop=sinkingOffset(t);
      const impacts=D.DATA?.structure?.floorImpactTimes||[.65,1.35,2.1],settled=D.DATA?.structure?.settledTime||4;
      if(this.collapseState!==state){
        this.collapseState=state;
        this.collapseFacade=document.createElement('canvas');this.collapseFacade.width=W*RESOLUTION;this.collapseFacade.height=H*RESOLUTION;
        const snapshot=this.collapseFacade.getContext('2d');snapshot.scale(RESOLUTION,RESOLUTION);snapshot.imageSmoothingEnabled=false;
        // Preserve the actual broken panes, scorched rooms and missing rails while
        // the rigid upper building sinks. No simulation fields are modified.
        this.drawOccupiedBuilding(snapshot,state,state.time||0,true);
      }
      if(t<settled){
        const shudder=Math.sin(t*72)*Math.min(2.1,.35+t*.6);
        c.save();c.beginPath();c.rect(121,0,718,446);c.clip();
        c.drawImage(this.collapseFacade,shudder,drop,W,H);
        c.restore();
        // At the ground contact, slabs crush into a narrow, jagged stack.
        const sourceY=Math.max(0,Math.min(425,431-drop));
        for(let i=0;i<6;i++){
          const offset=Math.sin(t*29+i*2)*2;
          c.drawImage(this.collapseFacade,(131+i*115)*RESOLUTION,sourceY*RESOLUTION,115*RESOLUTION,18*RESOLUTION,131+i*115+offset,433+i%2*2,115,Math.max(2,10-t));
        }
      }
      const pile=Math.min(1,drop/275),debrisVisibility=Math.min(1,t*2);c.save();c.globalAlpha=debrisVisibility;
      c.translate(0,450);c.scale(1,Math.max(.015,pile));c.translate(0,-450);
      const silhouette=[[131,450],[139,435],[170,425],[203,432],[238,414],[273,429],[308,420],[345,435],[388,417],[421,427],[454,408],[490,425],[523,412],[558,431],[590,419],[626,434],[661,414],[697,429],[727,420],[770,434],[805,423],[828,443],[828,455],[131,455]];
      poly(c,'#595b58',silhouette);
      poly(c,'#a5957f',silhouette.map(([x,y],i)=>[x,y-(i>1&&i<23?3:0)]));
      for(let i=0;i<51;i++){
        const x=137+hash(i,31)*673,y=425+hash(i,33)*24,w=8+hash(i,3)*28,h=4+hash(i,6)*13;
        poly(c,'#645f54',[[x,y-h],[x+w,y-h*.6],[x+w+3,y+3],[x+2,y+5]]);
        poly(c,['#c8b599','#a99b86','#d3c0a1','#8d887a'][i%4],[[x,y-h],[x+w*.5,y-h-2],[x+w,y-h*.6],[x+w-2,y+1],[x+2,y+2]]);
        line(c,'#e1cbae',x+1,y-h,x+w*.5,y-h-2,.75);
      }
      [177,283,413,567,710,786].forEach((x,i)=>{
        const y=424-i%3*4;
        line(c,'#383f43',x,y+10,x-3,y-13,2);line(c,'#697374',x-3,y-13,x+5,y-19,1);
      });
      c.restore();
      // Concrete kicks outward from the slab being crushed, then falls into the pile.
      for(let i=0;i<35;i++){
        const age=t-(impacts[i%3]-.16+hash(i,11)*.3);if(age<0||age>1.8)continue;
        const x=146+hash(i,12)*660+(hash(i,13)-.5)*age*125,y=428-age*(65+hash(i,14)*74)+age*age*113;
        if(y>450)continue;
        const size=4+hash(i,15)*12;
        c.save();c.translate(x,y);c.rotate(age*(hash(i,16)-.5)*5);
        poly(c,'#6c665b',[[-size*.5,-size*.35],[size*.5,-size*.2],[size*.4,size*.5],[-size*.3,size*.4]]);
        poly(c,['#d5bfa0','#b9aa91','#8d887d'][i%3],[[-size*.5,-size*.35],[size*.5,-size*.2],[size*.3,size*.2],[-size*.3,size*.3]]);c.restore();
      }
    }
    drawCollapseDust(c, state) {
      const t=collapseTime(state);if(t<.2)return;
      const settled=D.DATA?.structure?.settledTime||4,peak=settled-.8;
      const strength=t<peak?Math.min(.5,(t-.2)*.2):Math.max(.035,.48-(t-peak)*.14);
      c.save();
      for(let i=0;i<16;i++){
        const rise=Math.min(1,Math.max(0,(t-.2-(i%3)*.34)/2.2)),x=115+i*43+Math.sin(i*2)*rise*12;
        const y=443-rise*(35+hash(i,2)*54)+Math.sin(i*3)*11;
        c.globalAlpha=strength*(.64+hash(i,4)*.3);
        cloud(c,x,y,2.8+rise*1.7,['#a8a18f','#c5bda8','#918e81'][i%3]);
      }
      if(t<settled+.1){
        (D.DATA?.structure?.floorImpactTimes||[0.65,1.35,2.1]).forEach((impact,index)=>{
          const age=t-impact;if(age<0||age>1)return;
          c.globalAlpha=(1-age)*.19;
          for(let i=0;i<6;i++)cloud(c,125+i*119+age*9,436-age*24+index*2,1.8+age*2.2,'#dfd3b9');
        });
      }
      c.restore();
    }
    drawEnding(c, state, pointer) {
      const t = state.endingTime || 0, flight = D.DATA?.tuning?.rocketFlight || .68;
      if(state.endingReason==='collapse'){this.drawCollapseDust(c,state);return;}
      const target = state.rocketTarget || { x: 480, y: 263 };
      if (t < flight) {
        const origin = D.Art.muzzle(3, 1, (state.time || 0) - t, { x: target.x, y: target.y, inside: true }), p = Math.min(1, t / flight);
        const x = origin.x + (target.x - origin.x) * p, y = origin.y + (target.y - origin.y) * p;
        line(c, '#e9c193', origin.x, origin.y, x, y, 3);
        const tail = Math.max(0, p - .12);
        line(c, '#ffdb84', origin.x + (target.x - origin.x) * tail, origin.y + (target.y - origin.y) * tail, x, y, 4);
        rect(c, '#ffffdf', x - 3, y - 4, 6, 8);
        return;
      }
      const blast = Math.max(0,collapseTime(state));
      if (blast < .95) {
        c.save();
        for (let i = 0; i < 15; i++) {
          const a = i * 2.39996, radius = 30 + blast * 145 * hash(i + 3, 2);
          const x = target.x + Math.cos(a) * radius * 1.3, y = target.y + Math.sin(a) * radius;
          const size = (30 + hash(i, 1) * 30) * Math.min(1.4, blast * 5 + .4);
          c.globalAlpha = Math.min(1, (.95 - blast) * 2);
          cloud(c, x - size, y, size / 8, '#d16830'); cloud(c, x - size * .8, y - size * .12, size / 10, '#ffa53f'); cloud(c, x - size * .5, y - size * .08, size / 15, '#ffdf7a');
        }
        c.globalAlpha=Math.max(0,1-blast/.65)*.72;c.strokeStyle='#ffe4a0';c.lineWidth=2;
        c.beginPath();c.arc(target.x,target.y,20+blast*265,0,Math.PI*2);c.stroke();c.restore();
      }
      this.drawCollapseDust(c,state);
    }
    drawEffects(c, state, pointer) {
      const t = state.time || 0;
      this.drawDynamiteBlast(c,state);
      (state.tracers || []).forEach(tracer => {
        const a = Math.max(0, Math.min(1, tracer.life / (tracer.maxLife || .1)));
        const origin = D.Art.muzzle(tracer.weapon ?? state.weapon ?? 0, state.recoil || 0, t, pointer);
        c.globalAlpha = a; line(c, '#ffda86', origin.x, origin.y, tracer.x2, tracer.y2, tracer.weapon === 1 ? 2 : 1);
        rect(c, '#fffbd1', tracer.x2 - 2, tracer.y2 - 2, 4, 4);
      }); c.globalAlpha = 1;
      (state.particles || []).forEach(p => {
        const a = Math.max(0, Math.min(1, p.life / (p.maxLife || 1))), size = p.size || 3;
        c.globalAlpha = Math.min(1, a * 2); const col = p.color || '#d0b99a';
        if (p.type === 'smoke' || p.type === 'dust') { c.globalAlpha = a * .45; cloud(c, p.x - size, p.y, size / 5, col); }
        else if (p.type === 'glassShard') {
          c.save();c.translate(p.x,p.y);c.rotate(p.angle??p.rotation??0);
          const stretch=p.shape===1?1.7:p.shape===2?.7:1.15;
          poly(c,col,[[-size*.65,-size*.25],[size*.55,-size*.55],[size*.2,size*stretch]]);
          line(c,'#eafaf0cc',-size*.65,-size*.25,size*.2,size*stretch,.6);
          line(c,'#91c1d18a',-size*.65,-size*.25,size*.55,-size*.55,.5);c.restore();
        }
        else if (p.type === 'spark' || p.type === 'glass') {
          line(c, col, p.x, p.y, p.x - (p.vx || 0) * .02, p.y - (p.vy || 0) * .02, Math.max(1, size / 2));
        } else { rect(c, col, p.x - size / 2, p.y - size / 2, size, size); if (size > 3) rect(c, '#ffffff40', p.x - size / 2, p.y - size / 2, size, 1); }
      }); c.globalAlpha = 1;
      if (state.mode === 'ending' || state.mode === 'result') {
        this.drawEnding(c, state, pointer);
      }
    }
    drawFloaters(c, state) {
      (state.floaters || []).forEach(f => {
        const a = Math.max(0, Math.min(1, f.life / (f.maxLife || 1)));
        c.save(); c.globalAlpha = Math.min(1, a * 3); c.textAlign = 'center'; c.lineJoin = 'round';
        c.font = /^\+?\d/.test(f.text || '') ? '900 18px "Arial Black", sans-serif' : 'bold 12px "Yu Gothic", sans-serif';
        c.strokeStyle = '#25313d'; c.lineWidth = 4; c.strokeText(f.text || '', Math.round(f.x), Math.round(f.y));
        c.fillStyle = f.color || '#ffe57e'; c.fillText(f.text || '', Math.round(f.x), Math.round(f.y)); c.restore();
      });
    }
    drawPointer(c, state, pointer) {
      if (!pointer?.inside || state.mode === 'title' || state.mode === 'result' || state.mode === 'ending' || state.paused) return;
      const x = Math.round(pointer.x), y = Math.round(pointer.y);
      const onCase = state.caseReady && x >= 748 && y >= 455;
      const r = onCase ? 12 : state.weapon === 1 ? 15 : 9, color = onCase ? '#bafb97' : '#ffeccc';
      c.save(); c.strokeStyle = '#152634cc'; c.lineWidth = 3;
      [[x - r, y, x - 4, y], [x + 4, y, x + r, y], [x, y - r, x, y - 4], [x, y + 4, x, y + r]].forEach(p => line(c, '#152634cc', ...p, 3));
      [[x - r, y, x - 4, y], [x + 4, y, x + r, y], [x, y - r, x, y - 4], [x, y + 4, x, y + r]].forEach(p => line(c, color, ...p));
      rect(c, '#172331', x - 2, y - 2, 4, 4); rect(c, color, x, y, 1, 1);
      if (state.weapon === 1) { c.strokeStyle = '#ffeccc55'; c.lineWidth = 1; c.beginPath(); c.arc(x, y, r + 4, 0, Math.PI * 2); c.stroke(); }
      c.restore();
    }
    drawOccupiedBuilding(c, state, t, hideActors = false) {
      const art = D.Art || {}, data = D.DATA || {};
      let intruders = [];
      c.drawImage(this.facade, 0, 0, W, H);
      const cells = state.cells || [];
      cells.forEach(cell => this.drawCellContents(c, cell, art, t));
      cells.forEach(cell => this.drawDamage(c, cell));
      this.drawStructuralStress(c,state);
      cells.forEach(cell => this.drawRoomFire(c, cell, t));
      // A breach belongs to the building, so it neither sways with nor vanishes with its boss.
      const breachCells = cells.filter(cell => cell.damageState === 'hole' && cell.eventState !== 'collapse');
      breachCells.forEach(cell => this.bossHole(c, {
        homeX: cell.x + cell.w / 2, homeY: cell.y + 92,
        hitTimer: state.boss?.cellId === cell.id ? state.boss.hitTimer : 0,
      }, t));
      if (state.boss && !breachCells.some(cell => cell.id === state.boss.cellId)) this.bossHole(c, state.boss, t);
      const allProps = cells.flatMap(cell => cell.props || []);
      const propMap = new Map(allProps.map(prop => [prop.id, prop]));
      allProps.filter(p => p.type !== 'rail').forEach(p => {
        if (art.prop) art.prop(c, p, data.props?.[p.type] || {}, t);
        if (p.state === 'burning') this.drawSmoke(c, p.x + p.w * .5, p.y + p.h * .65, t + hash(p.x, p.y), 1, true);
      });
      this.drawBulletMarks(c, state, propMap);
      // Wires are scenery only; the simulation supplies each intruder's fixed path.
      const actors = hideActors ? [] : state.actors || [];
      intruders = actors.filter(a => a.kind === 'intruder' || a.type === 'intruder');
      if (intruders.length) {
        const route = data.tuning?.intruderRoute || [];
        // Route positions are feet; the shared climb pose grips about 50 px above them.
        route.slice(1).forEach((point, index) => {
          const from = route[index];
          line(c, '#202c38', from.x, from.y - 50, point.x, point.y - 50, 2);
          line(c, '#bdbeaa', from.x, from.y - 51, point.x, point.y - 51);
        });
        [route[0], route[route.length - 1]].filter(Boolean).forEach(point => {
          rect(c, '#34424a', point.x - 3, point.y - 54, 6, 8); rect(c, '#a6b0a1', point.x - 1, point.y - 53, 2, 5);
        });
      }
      actors.filter(a => a.kind !== 'boss' && a.kind !== 'intruder' && a.type !== 'intruder').slice().sort((a, b) => a.y - b.y).forEach(a => {
        const defs = a.kind === 'resident' ? data.residents : data.enemies;
        if (art.actor) art.actor(c, a, defs?.[a.type] || {}, t);
      });
      cells.forEach(cell => {
        const rail = (cell.props || []).find(p => p.type === 'rail');
        if (!rail || rail.state !== 'broken') this.drawRail(c, cell, rail);
        else if (art.prop) art.prop(c, rail, data.props?.rail || {}, t);
        const hasTowel = cell.decoration?.includes('futon') || (cell.decoration?.includes('laundry') && cell.col % 2 === 0);
        if (hasTowel && !cell.laundryBurned && rail?.state !== 'broken') {
          const xx = cell.x + 77, yy = cell.y + 58, col = cell.col % 2 === 0 ? '#6689b2' : '#b85e54';
          rect(c, '#333d4a', xx - 1, yy, 24, 30); rect(c, col, xx, yy, 22, 27);
          rect(c, '#e0d4b2', xx + 3, yy, 3, 26); rect(c, '#e0d4b2', xx + 14, yy, 4, 26); rect(c, '#b6bca9', xx, yy + 26, 22, 2);
        }
      });
      this.drawBulletMarks(c, state, propMap, true);
      cells.forEach(cell => this.drawRoomFire(c, cell, t, true));
      actors.filter(a => a.kind === 'boss').forEach(a => { if (art.actor) art.actor(c, a, data.enemies?.[a.type] || {}, t); });
      if (!hideActors && state.boss && !state.boss.dead && !actors.some(a => a.id === state.boss.id) && art.actor) art.actor(c, state.boss, data.enemies?.[state.boss.type] || {}, t);
      return intruders;
    }
    render(state, pointer = {}) {
      const c = this.ctx, t = state.time || 0, art = D.Art || {}, data = D.DATA || {};
      c.imageSmoothingEnabled = false; c.globalAlpha = 1; c.setTransform(RESOLUTION, 0, 0, RESOLUTION, 0, 0);
      rect(c, '#6aa9d4', 0, 0, W, H);
      const collapseRoute=state.endingReason==='collapse',localCollapseTime=collapseTime(state);
      const sinking=['ending','result'].includes(state.mode)&&localCollapseTime>=0;
      const collapseShake=sinking&&localCollapseTime<4?Math.max(.3,2.8-localCollapseTime*.5):0;
      const shake = Math.min(5, Math.max(state.shake || 0,collapseShake));
      c.save(); c.translate(Math.round(Math.sin(t * 83) * shake), Math.round(Math.cos(t * 97) * shake * .55));
      let intruders=[];
      c.drawImage(this.distant,0,0,W,H);
      if(sinking)this.drawCollapseFacade(c,state);
      else intruders=this.drawOccupiedBuilding(c,state,t);
      this.drawEffects(c, state, pointer);
      c.drawImage(this.front, 0, 0, W, H);
      if (art.case) art.case(c, collapseRoute?{...state,mode:'playing',caseReady:false,caseOpened:false}:state, t);
      if (art.weapon && state.mode !== 'title' && state.mode !== 'result' && state.mode !== 'case') {
        const recoil = state.mode === 'ending' && !collapseRoute ? Math.max(0, 1 - (state.endingTime || 0) * 4) : state.recoil || state.weaponRecoil || 0;
        const weaponPointer = state.mode === 'ending' && state.rocketTarget ? { ...state.rocketTarget, inside: true } : pointer;
        art.weapon(c, !collapseRoute&&(state.mode === 'rocket' || (state.mode === 'ending' && state.caseOpened)) ? 3 : state.weapon || 0, recoil, t, weaponPointer);
      }
      // Climbers reach the near side of our rail; drawing them here keeps the final metres visible.
      intruders.forEach(a => { if (art.actor) art.actor(c, a, data.enemies?.[a.type] || {}, t); });
      this.drawBossBombs(c, state);
      this.drawFloaters(c, state);
      c.restore();
      if (state.flash) { c.globalAlpha = Math.min(.7, state.flash); rect(c, '#fff1d2', 0, 0, W, H); c.globalAlpha = 1; }
      this.drawPointer(c, state, pointer);
    }
  }
  D.Renderer = Renderer;
})();
