(function (root) {
  'use strict';

  const Danchi = root.Danchi = root.Danchi || {};
  const DATA = Danchi.DATA;
  const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
  const inRect = (x, y, rect) => x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;

  class Game {
    constructor(seed) {
      this.reset(seed);
    }

    reset(seed) {
      const oldBest = this.state ? this.state.best : 0;
      this.seed = Number.isFinite(seed) ? seed >>> 0 : (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
      this.randomState = this.seed || 0x6d2b79f5;
      this.serial = 0;
      this.events = [];
      this.billed = new Set();
      this.cooldown = 0;
      this.enemyTimer = DATA.tuning.firstEnemyDelay;
      this.residentTimer = DATA.tuning.firstResidentDelay;
      this.intruderTimer = DATA.tuning.firstIntruderDelay;
      this.quietTimer = 0;
      this.rocketExploded = false;
      this.suppressInvoiceFloaters = false;
      this.residentDeck = [];
      this.state = {
        seed: this.seed, time: 0, mode: 'title', paused: false,
        score: 0, best: oldBest, progress: 0, phase: 0, weapon: 0, unlockedWeapons: DATA.weapons.map(weapon => weapon.unlockPhase <= 0),
        combo: 0, maxCombo: 0, comboTimer: 0, multiplier: 1, shots: 0, hits: 0, kills: 0,
        enemyKills: 0, headshots: 0, warningShots: 0, residentHits: 0, thefts: 0, accidents: 0,
        cells: [], actors: [], particles: [], floaters: [], tracers: [], bulletMarks: [], blasts: [], log: [],
        boss: null, bossDefeated: false, bossProjectiles: [], bossBombHits: 0, bossScoreLost: 0, shake: 0, flash: 0, recoil: 0,
        endingTime: 0, endingReason: null, buildingCollapsed: false, caseReady: false, caseOpened: false,
        structure: { hp: DATA.structure.maxHp, maxHp: DATA.structure.maxHp, damageRatio: 0,
          lastSource: null, lastCauseId: null, lastHitX: null, lastHitY: null, warningLevel: 0, totalDamage: 0 },
        message: '', messageTimer: 0, invoiceTotal: 0, rocketTarget: null,
        intruderWarning: 0
      };
      this._makeCells();
      // Title actors also use the real, shared animation definitions.
      [0, 4, 7].forEach((index) => this._spawnResident(this.state.cells[index], true));
      return this.state;
    }

    start() {
      if (this.state.mode !== 'title') this.reset();
      this.state.mode = 'playing';
      this.state.time = 0;
      this.state.message = DATA.phases[0].message;
      this.state.messageTimer = DATA.tuning.messageDuration + 1;
      this._spawnEnemy('zombie', this.state.cells[6]);
      this._spawnEnemy('zombie', this.state.cells[3]);
      this._emit('start');
      return this.state;
    }

    _random() {
      let t = this.randomState += 0x6d2b79f5;
      t = Math.imul(t ^ t >>> 15, t | 1);
      t ^= t + Math.imul(t ^ t >>> 7, t | 61);
      return ((t ^ t >>> 14) >>> 0) / 4294967296;
    }

    _pick(array) { return array[Math.floor(this._random() * array.length)]; }
    _id(prefix) { return `${prefix}_${++this.serial}`; }
    _emit(type, fields) { this.events.push(Object.assign({ type }, fields)); }
    drainEvents() { return this.events.splice(0); }

    _makeCells() {
      const grid = DATA.grid;
      for (let row = 0; row < grid.rows; row++) {
        for (let col = 0; col < grid.cols; col++) {
          const index = row * grid.cols + col;
          const template = DATA.balconyTemplates[(index * 3 + (row === 2 ? 2 : 0)) % DATA.balconyTemplates.length];
          const cell = {
            id: `${grid.rows - row}F-${String(col + 1).padStart(2, '0')}`,
            col, row, x: grid.x + grid.stepX * col, y: grid.y + grid.stepY * row,
            w: grid.w, h: grid.h, damage: 0, damageState: 'normal',
            templateId: template.id, template: template.id, residentType: template.resident,
            decoration: template.decoration.slice(), props: [], cooldown: 0,
            light: 0.45 + this._random() * 0.5, palette: index % 5,
            eventState: 'idle', currentAnimation: null,
            roomHeat: 0, roomShotDamage: 0, roomShotCount: 0, roomFire: null,
            interiorDamage: 0, soot: 0
          };
          const placements = [{ type: 'window', dx: 8, dy: 1 }, ...template.props, { type: 'rail', dx: 0, dy: 59 }];
          placements.forEach((placement, pindex) => {
            const def = DATA.props[placement.type];
            const prop = {
              id: `${cell.id}_${placement.type}_${pindex}`, type: placement.type,
              x: cell.x + placement.dx, y: cell.y + placement.dy,
              w: def.w, h: def.h, hp: def.hp, maxHp: def.hp, state: 'intact', cellId: cell.id,
              hitTimer: 0, fireTimer: 0, billed: false, age: 0
            };
            prop.originX = prop.x;
            prop.originY = prop.y;
            if (prop.type === 'window') {
              prop.damageStage = 0;
              prop.impacts = [];
              const paneW = (prop.w - def.paneInset * 2 - def.paneGap * (def.panes - 1)) / def.panes;
              prop.panes = Array.from({ length: def.panes }, (_, pane) => ({
                id: `${prop.id}_pane_${pane}`, x: prop.x + def.paneInset + pane * (paneW + def.paneGap),
                y: prop.y + def.paneInset, w: paneW, h: prop.h - def.paneInset * 2,
                hp: def.hp / def.panes, maxHp: def.hp / def.panes, stage: 'intact',
                impactX: prop.x + def.paneInset + pane * (paneW + def.paneGap) + paneW / 2,
                impactY: prop.y + prop.h / 2, variant: Math.floor(this._random() * 4)
              }));
            }
            cell.props.push(prop);
          });
          this.state.cells.push(cell);
        }
      }
    }

    _cell(id) { return this.state.cells.find(cell => cell.id === id); }
    _actorsInCell(cell) {
      return this.state.actors.filter(actor => {
        if (actor.dead || actor.cellId !== cell.id) return false;
        // Intruders retain their departure cellId while travelling toward the player.
        // Their actual body centre must still be on that balcony for its accident to hit.
        return actor.kind !== 'intruder' || inRect(actor.x, actor.y - actor.h / 2, cell);
      });
    }
    _prop(id) {
      for (const cell of this.state.cells) {
        const prop = cell.props.find(item => item.id === id);
        if (prop) return prop;
      }
      return null;
    }

    _availableCells() {
      const occupied = new Set(this.state.actors.filter(actor => actor.kind !== 'intruder').map(actor => actor.cellId));
      return this.state.cells.filter(cell => !occupied.has(cell.id) && cell.cooldown <= 0);
    }

    _newActor(kind, type, cell, def) {
      const actor = {
        id: this._id(kind), kind, type, cellId: cell.id,
        x: cell.x + cell.w / 2, y: cell.y + 70,
        homeX: cell.x + cell.w / 2, homeY: cell.y + 70,
        w: def.w || (def.appearance && def.appearance.build === 'heavy' ? 40 : 32), h: def.h || 58,
        hp: def.hp || 1, maxHp: def.hp || 1, age: 0,
        pose: def.animation || 'idle', animation: def.animation || 'idle',
        hitTimer: 0, reaction: null, reactionTime: 0, retreat: 0,
        dead: false, deadTime: 0, phaseOffset: this._random() * Math.PI * 2,
        speed: def.speed || 1, speech: '', speechTimer: 0, complaintId: null,
        heldDestroyed: false, heldHit: false, warningRetreat: false
      };
      this.state.actors.push(actor);
      return actor;
    }

    _spawnResident(preferredCell, titleOnly) {
      const available = this._availableCells();
      const cell = preferredCell && available.includes(preferredCell) ? preferredCell : this._pick(available);
      if (!cell) return null;
      if (!titleOnly && !this.residentDeck.length) {
        this.residentDeck = Object.keys(DATA.residents);
        for (let i = this.residentDeck.length - 1; i > 0; i--) {
          const j = Math.floor(this._random() * (i + 1));
          [this.residentDeck[i], this.residentDeck[j]] = [this.residentDeck[j], this.residentDeck[i]];
        }
      }
      const type = titleOnly ? cell.residentType : this.residentDeck.pop();
      const def = DATA.residents[type];
      const actor = this._newActor('resident', type, cell, def);
      actor.lifetime = DATA.tuning.residentLifetime + this._random() * 5;
      actor.age = titleOnly ? this._random() * 6 : 0;
      cell.currentAnimation = def.animation;
      return actor;
    }

    _spawnEnemy(type, preferredCell) {
      const available = this._availableCells();
      const cell = preferredCell && available.includes(preferredCell) ? preferredCell : this._pick(available);
      if (!cell) return null;
      const chosen = type || this._pick(DATA.phases[this.state.mode === 'boss' ? DATA.phases.length - 1 : this.state.phase].enemyTypes);
      const def = DATA.enemies[chosen];
      const actor = this._newActor('enemy', chosen, cell, def);
      actor.lifetime = def.lifetime;
      cell.eventState = 'enemy';
      this._emit('spawn', { kind: 'enemy', enemyType: chosen, x: actor.x, y: actor.y });
      return actor;
    }

    _spawnIntruder() {
      const route = DATA.tuning.intruderRoute;
      const cell = this.state.cells[9];
      const actor = this._newActor('intruder', 'intruder', cell, DATA.enemies.intruder);
      actor.x = actor.homeX = route[0].x;
      actor.y = actor.homeY = route[0].y;
      actor.routeProgress = 0;
      actor.arrived = false;
      actor.carriedItem = null;
      actor.pose = 'climb';
      this.state.intruderWarning = 3;
      this._message('物干し泥棒がこっちへ！　撃ち落として追い払え。', 3.4);
      this._emit('intruder', { x: actor.x, y: actor.y });
      return actor;
    }

    selectWeapon(index) {
      if (!Number.isInteger(index) || !DATA.weapons[index] || !this.state.unlockedWeapons[index]) return false;
      if (!['title', 'playing', 'boss'].includes(this.state.mode)) return false;
      if (this.state.weapon !== index) {
        this.state.weapon = index;
        this._emit('weapon', { weapon: index });
      }
      return true;
    }

    cycleWeapon(direction) {
      const delta = direction < 0 ? -1 : 1;
      for (let offset = 1; offset <= DATA.weapons.length; offset++) {
        const index = (this.state.weapon + delta * offset + DATA.weapons.length * 2) % DATA.weapons.length;
        if (this.state.unlockedWeapons[index]) return this.selectWeapon(index);
      }
      return false;
    }

    openCase() {
      if (this.state.mode !== 'case' || !this.state.caseReady || this.state.paused) return false;
      this.state.caseOpened = true;
      this.state.mode = 'rocket';
      this._message('非常時につき。向かいの建物をクリック。', 30);
      this._emit('case', { opened: true });
      return true;
    }

    shoot(x, y) {
      const state = this.state;
      if (!Number.isFinite(x) || !Number.isFinite(y) || state.paused) return false;
      if (state.mode === 'case') return inRect(x, y, DATA.caseRect) ? this.openCase() : false;
      if (state.mode === 'rocket') {
        if (!inRect(x, y, DATA.building)) return false;
        state.mode = 'ending';
        state.endingReason = 'rocket';
        state.endingTime = 0;
        state.rocketTarget = { x, y };
        state.message = '';
        state.messageTimer = 0;
        state.shake = 3;
        this._emit('rocket', { x, y });
        return true;
      }
      if (!['playing', 'boss'].includes(state.mode) || state.bossDefeated || this.cooldown > 0) return false;
      const weapon = DATA.weapons[state.weapon];
      const shotId = this._id('bullet');
      this.cooldown = weapon.cooldown;
      state.shots++;
      state.recoil = 1;
      state.shake = Math.max(state.shake, weapon.shake);
      this._emit('shot', { weapon: state.weapon, x, y, shotId });
      const impactPoints = this._impactPoints(x, y, state.weapon);
      this._tracer(x, y, state.weapon, impactPoints);
      const markCandidates = impactPoints.map(point => ({ point, surface: this._bulletSurface(point.x, point.y) }));

      // A reacting resident still stops a bullet, but cannot be charged again.
      const liveActors = state.actors.filter(actor => !actor.dead && (actor.kind === 'resident' ? actor.retreat < 0.9 : !actor.reaction && actor.retreat < 0.15));
      const geometry = Danchi.ActorGeometry;
      const priority = actor => actor.kind === 'boss' ? 2000 : actor.kind === 'intruder' ? 1000 : actor.y;
      const targets = liveActors.map(actor => {
        const def = actor.kind === 'resident' ? DATA.residents[actor.type] : DATA.enemies[actor.type];
        return { actor, body: { x: actor.x - actor.w / 2, y: actor.y - actor.h, w: actor.w, h: actor.h },
          head: geometry.head(actor, def), held: actor.kind === 'resident' ? geometry.held(actor, def) : null,
          hit: false, headHit: false, heldHit: false, hitPoint: null };
      }).sort((a, b) => priority(b.actor) - priority(a.actor));
      const bundle = state.boss && !state.boss.dead && state.boss.dynamite && state.boss.dynamite.active ? state.boss.dynamite : null;
      const dynamiteHit = bundle && impactPoints.some(point => inRect(point.x, point.y, bundle));
      // Preserve the exact pre-shot silhouettes: defeated actors and destroyed
      // hand-held objects must still stop their own pellets for this whole shot.
      const blockingRects = targets.flatMap(target => [target.body, target.head, target.held].filter(Boolean));
      if (bundle) blockingRects.push({ x: bundle.x, y: bundle.y, w: bundle.w, h: bundle.h });
      for (const point of impactPoints) {
        if (bundle && inRect(point.x, point.y, bundle)) continue;
        const target = targets.find(item => [item.held, item.head, item.body].some(rect => rect && inRect(point.x, point.y, rect)));
        if (!target) continue;
        target.hit = true;
        target.hitPoint = point;
        if (target.held && inRect(point.x, point.y, target.held)) target.heldHit = true;
        else if (target.head && inRect(point.x, point.y, target.head)) target.headHit = true;
      }
      const victims = targets.filter(target => target.hit);
      const enemyHit = !!dynamiteHit || victims.some(target => target.actor.kind !== 'resident');
      const warningHit = victims.some(target => target.actor.kind === 'resident' && target.heldHit && target.held.destructible);
      const residentHit = victims.some(target => target.actor.kind === 'resident' && !target.heldHit);
      if (enemyHit) this._advanceCombo();
      else if (warningHit) this._advanceCombo();
      else if (residentHit) this._resetCombo();

      let hasHit = victims.length > 0 || !!dynamiteHit;
      // A pellet that struck the armed bundle detonates it before another pellet
      // from this same shot can finish off the boss's body.
      if (dynamiteHit) this._detonateDynamite(shotId);
      if (state.mode === 'ending') { if (hasHit) state.hits++; return true; }
      for (const target of victims) {
        const actor = target.actor;
        if (actor.kind === 'resident') {
          if (target.heldHit) {
            if (target.held.destructible) this._hitHeld(actor, target.held, shotId);
          } else this._hitResident(actor, true, shotId);
        } else {
          const damage = target.headHit ? actor.kind === 'boss' ? weapon.damage * DATA.bossFight.headshotDamageMultiplier : actor.hp : weapon.damage;
          this._damageActor(actor, damage, shotId, null, false,
            target.headHit ? { headshot: true, x: target.head.x + target.head.w / 2, y: target.head.y + target.head.h / 2 } : null);
        }
      }

      // Only pellets that actually pass the characters can reach scenery.
      const sceneryPoints = impactPoints.filter(point => !blockingRects.some(rect => inRect(point.x, point.y, rect)));
      if (sceneryPoints.length) {
        const hitProps = [];
        for (const cell of state.cells) {
          for (const prop of cell.props) {
            if (prop.hp <= 0 || !['intact', 'hit'].includes(prop.state)) continue;
            if (sceneryPoints.some(point => inRect(point.x, point.y, prop))) hitProps.push(prop);
          }
        }
        hitProps.sort((a, b) => {
          const priority = { pot: 5, bbq: 4, ac: 3, rail: 2, window: 1 };
          return priority[b.type] - priority[a.type];
        });
        const props = weapon.radius ? hitProps.slice(0, 4) : hitProps.slice(0, 1);
        for (const prop of props) {
          const actualImpact = sceneryPoints.find(point => inRect(point.x, point.y, prop) && (prop.type !== 'window' || this._windowGlassAt(prop, point.x, point.y))) || sceneryPoints.find(point => inRect(point.x, point.y, prop));
          const beforeHp = prop.hp;
          this._damageProp(prop, weapon.damage, shotId, null, 0, { x: actualImpact.x, y: actualImpact.y, weapon: state.weapon });
          if (prop.hp < beforeHp) hasHit = true;
        }
      }
      if (hasHit) state.hits++;
      if (!hasHit) {
        this._particles(x, y, 4, ['#e9d6af', '#ac9685'], 45, 0.35);
        this._emit('miss', { x, y });
        this._resetCombo();
      }
      if (sceneryPoints.length) {
        this._recordBulletMarks(markCandidates, state.weapon, blockingRects);
        this._damageStructureFromShot(impactPoints, blockingRects, state.weapon, shotId);
        if (state.mode !== 'ending') this._heatRooms(impactPoints, blockingRects, state.weapon, shotId);
      }
      this._checkProgress();
      return true;
    }

    _advanceCombo() {
      const state = this.state;
      state.combo++;
      state.maxCombo = Math.max(state.maxCombo, state.combo);
      state.comboTimer = DATA.scoring.comboWindow;
      state.multiplier = Math.min(DATA.scoring.maxMultiplier, 1 + Math.floor(state.combo / DATA.scoring.comboStep));
      if (DATA.scoring.comboMilestones.includes(state.combo)) {
        this._emit('combo', { combo: state.combo, multiplier: state.multiplier });
      }
    }

    _resetCombo() {
      this.state.combo = 0;
      this.state.comboTimer = 0;
      this.state.multiplier = 1;
    }

    _award(points, x, y, label, multiply) {
      const amount = Math.round(points * (multiply ? this.state.multiplier : 1));
      if (amount <= 0) return;
      this.state.score += amount;
      this.state.progress += amount;
      this.state.best = Math.max(this.state.best || 0, this.state.score);
      this._floater(x, y, `${label ? `${label} ` : ''}+${amount.toLocaleString('en-US')}`, '#ffe479');
    }

    _penalty(points, x, y, label) {
      this.state.score = Math.max(0, this.state.score - points);
      this._floater(x, y, `${label} −${points}`, '#ffb28c');
    }

    _hitResident(actor, direct, source) {
      if (actor.dead) return;
      if (direct && !actor.complaintId) {
        this.state.residentHits++;
        const entry = this._log({ source, target: actor.id, cellId: actor.cellId, label: '住民への迷惑料', cost: DATA.scoring.residentCharge, causeId: null, event: 'residentComplaint' });
        actor.complaintId = entry.id;
        this._floater(actor.x, actor.y - actor.h - 5, `請求 +¥${DATA.scoring.residentCharge.toLocaleString('en-US')}`, '#ffbd85');
      }
      if (actor.reaction) return;
      actor.reaction = 'surprised';
      actor.pose = 'surprised';
      actor.reactionTime = 0;
      actor.hitTimer = 0.15;
      actor.speech = this._pick(DATA.tuning.residentBarks);
      actor.speechTimer = 1.35;
      this._particles(actor.x, actor.y - actor.h * 0.65, 5, ['#ffeecb', '#ffffff'], 40, 0.3);
      this._emit('resident', { x: actor.x, y: actor.y, direct: !!direct });
    }

    _hitHeld(actor, held, source) {
      if (actor.dead || actor.heldHit || actor.heldDestroyed || actor.retreat > 0 || !held.destructible) return false;
      actor.heldHit = actor.heldDestroyed = actor.warningRetreat = true;
      actor.reaction = actor.pose = 'retreat';
      actor.reactionTime = 0;
      actor.speech = 'うわっ！';
      actor.speechTimer = DATA.tuning.warningRetreatDuration;
      this.state.warningShots++;
      const x = held.x + held.w / 2, y = held.y + held.h / 2;
      this._award(DATA.scoring.warningShot, x, y - 12, '警告射撃', false);
      this._particles(x, y, 12, ['#f8ddb2', '#796c6e', '#d6d5c6'], 85, 0.6);
      this._emit('warningShot', { x, y, actorId: actor.id, prop: held.type, score: DATA.scoring.warningShot, source });
      return true;
    }

    _damageActor(actor, amount, source, causeId, accident, options) {
      if (actor.dead || actor.kind === 'resident') return;
      const applied = Math.min(actor.hp, amount);
      const headshot = !!(options && options.headshot);
      actor.hp = Math.max(0, actor.hp - amount);
      actor.hitTimer = 0.12;
      this._particles(actor.x, actor.y - actor.h * 0.65, actor.kind === 'boss' ? 9 : 6, ['#fff2cb', '#e9b25f', '#a9c784'], 60, 0.45);
      this._emit('hit', { x: actor.x, y: actor.y - actor.h / 2, kind: actor.kind });
      if (headshot) {
        this.state.headshots++;
        actor.headshot = true;
      }
      if (actor.hp > 0) {
        if (headshot) {
          this._floater(options.x, options.y - 12, 'HEADSHOT!', '#ffe479');
          this._emit('headshot', { x: options.x, y: options.y, actorId: actor.id, enemyType: actor.type, killed: false, damage: applied, score: 0 });
        }
        return;
      }
      actor.dead = true;
      actor.pose = 'dead';
      actor.deadTime = 0;
      actor.reaction = 'defeated';
      this.state.kills++;
      this.state.enemyKills++;
      const def = DATA.enemies[actor.type];
      const baseScore = def.score * (headshot ? DATA.scoring.headshotMultiplier : 1);
      this._award(baseScore, actor.x, actor.y - actor.h, headshot ? 'HEADSHOT!' : accident ? '巻き込み！' : '', true);
      if (headshot) {
        this._emit('headshot', { x: options.x, y: options.y, actorId: actor.id, enemyType: actor.type, killed: true, damage: applied, score: Math.round(baseScore * this.state.multiplier) });
      }
      this._emit('kill', { actorId: actor.id, enemyType: actor.type, x: actor.x, y: actor.y, accident: !!accident, headshot });
      this._particles(actor.x, actor.y - actor.h / 2, actor.kind === 'boss' ? 45 : 14, ['#e6d3a3', '#b6c987', '#6c7770'], actor.kind === 'boss' ? 125 : 95, 0.8);
      if (accident) {
        this.state.accidents++;
        this._award(DATA.scoring.accidentBonus, actor.x, actor.y - actor.h - 19, '事故ボーナス', false);
      }
      if (actor.kind === 'boss') {
        this.state.bossDefeated = true;
        // Combat input ends with the boss. Ordinary enemies retreat immediately;
        // only bombs already thrown by the boss can still penalize the player.
        for (const other of this.state.actors) {
          if (!other.dead && ['enemy', 'intruder'].includes(other.kind)) {
            other.victoryRetreat = true;
            other.retreatTime = 0;
            other.pose = other.reaction = 'retreat';
          }
        }
        if (actor.dynamite) { actor.dynamite.active = false; if (!actor.dynamite.detonated) actor.dynamite.state = 'disarmed'; }
        this.quietTimer = DATA.tuning.quietDuration;
        this.state.shake = Math.max(this.state.shake, 4);
        this._message('……やっと静かになった。', DATA.tuning.quietDuration);
        this._emit('boss', { defeated: true });
        return;
      }
      // A visible enemy collapse can occasionally knock an actual nearby object.
      if (!accident && this.state.phase >= 1 && actor.kind === 'enemy' && this._random() < DATA.tuning.knockChance) {
        const cell = this._cell(actor.cellId);
        const prop = cell && this._pick(cell.props.filter(item => item.hp > 0 && ['pot', 'bbq', 'ac'].includes(item.type)));
        if (prop) {
          const event = this._log({ source, target: actor.id, cellId: actor.cellId, label: `${def.name}が小物へ激突`, cost: 0, causeId, event: 'knockback' });
          this._damageProp(prop, prop.hp, actor.id, event.id, 0);
        }
      }
    }

    _damageProp(prop, amount, source, causeId, depth, impact) {
      if (!prop || prop.hp <= 0 || !['intact', 'hit'].includes(prop.state)) return null;
      const def = DATA.props[prop.type];
      const beforeHp = prop.hp;
      let glassStage = null;
      if (prop.type === 'window') glassStage = this._damageWindowPanes(prop, amount, impact);
      else prop.hp = Math.max(0, prop.hp - amount);
      if (prop.hp === beforeHp) return null;
      prop.state = 'hit';
      prop.hitTimer = 0.2;
      this._particles(prop.x + prop.w / 2, prop.y + prop.h / 2, 3, [def.color, '#e6d8b8'], 48, 0.4);
      if (prop.hp > 0) {
        this._emit(prop.type === 'window' ? 'glass' : def.material === 'metal' ? 'metal' : 'hit', { x: impact ? impact.x : prop.x, y: impact ? impact.y : prop.y, propType: prop.type, soft: true, stage: glassStage });
        return null;
      }
      const cell = this._cell(prop.cellId);
      prop.state = 'broken';
      this.state.bulletMarks = this.state.bulletMarks.filter(mark => mark.propId !== prop.id);
      prop.chainDepth = depth || 0;
      prop.billed = true;
      const entry = this._log({ source, target: prop.id, cellId: prop.cellId, label: def.name, cost: def.cost, causeId, event: 'destroyed' });
      prop.causeId = entry.id;
      if (!impact || impact.score !== false) this._award(def.score, prop.x, prop.y, '', false);
      if (prop.type !== 'window') this._particles(prop.x + prop.w / 2, prop.y + prop.h / 2, 14, [def.color, '#d4c6a6', '#6f6458'], 90, 0.95);
      this._emit(def.material === 'glass' ? 'glass' : 'metal', { x: impact ? impact.x : prop.x, y: impact ? impact.y : prop.y, propType: prop.type, broken: true, stage: glassStage });
      cell.damage = Math.min(DATA.tuning.maxDamageState, cell.damage + (prop.type === 'rail' ? 0.65 : 0.2));
      if (cell.damage >= 1 && !cell.breached && !cell.roomFire) cell.damageState = 'cracked';
      if (this.state.phase >= 1 && prop.chainDepth < DATA.tuning.maxChainDepth) {
        if (def.falls && cell.row < DATA.grid.rows - 1 && this._random() < DATA.tuning.fallChance) {
          this._beginFall(prop, entry.id);
        } else if (def.burns && this._random() < DATA.tuning.fireChance) {
          this._ignite(prop, entry.id, prop.chainDepth + 1);
        }
      }
      return entry;
    }

    _windowPaneAt(prop, x, y) {
      return (prop.panes || []).find(pane => inRect(x, y, pane)) || null;
    }

    _windowGlassAt(prop, x, y) {
      if (!prop || prop.hp <= 0 || !inRect(x, y, prop)) return false;
      const pane = this._windowPaneAt(prop, x, y);
      return !!pane && pane.hp > 0 && pane.stage !== 'shattered';
    }

    _damageWindowPanes(prop, amount, impact) {
      const panes = prop.panes || [];
      if (!panes.length) return null;
      const area = !impact || impact.area === true;
      const hit = { x: impact && Number.isFinite(impact.x) ? impact.x : prop.x + prop.w / 2, y: impact && Number.isFinite(impact.y) ? impact.y : prop.y + prop.h / 2 };
      let selected = this._windowPaneAt(prop, hit.x, hit.y);
      if (!area && selected && selected.hp <= 0) return null;
      if (!selected) selected = panes.filter(pane => pane.hp > 0).sort((a, b) => Math.abs(a.x + a.w / 2 - hit.x) - Math.abs(b.x + b.w / 2 - hit.x))[0];
      if (!selected) return null;
      const targets = area ? panes.filter(pane => pane.hp > 0) : [selected];
      let remaining = amount;
      let shardStage = 'crack';
      for (const pane of targets) {
        const applied = Math.min(pane.hp, remaining);
        if (applied <= 0) continue;
        pane.hp -= applied;
        remaining -= applied;
        pane.impactX = clamp(hit.x, pane.x + 1, pane.x + pane.w - 1);
        pane.impactY = clamp(hit.y, pane.y + 1, pane.y + pane.h - 1);
        pane.variant = Math.floor(this._random() * 4);
        if (pane.hp <= 0) {
          pane.stage = area ? 'shattered' : 'chipped';
          pane.shatterTimer = area ? 0 : 0.16;
          shardStage = 'shatter';
        } else {
          pane.stage = pane.hp / pane.maxHp < 0.3 ? 'chipped' : 'cracked';
          if (pane.stage === 'chipped') shardStage = 'chip';
        }
        prop.impacts.push({ x: pane.impactX, y: pane.impactY, pane: panes.indexOf(pane), paneId: pane.id, stage: pane.stage, variant: pane.variant });
        this._glassShards(pane.impactX, pane.impactY, DATA.props.window.shards[shardStage], shardStage);
      }
      prop.hp = panes.reduce((sum, pane) => sum + pane.hp, 0);
      prop.damageStage = prop.hp <= 0 ? 3 : panes.some(pane => pane.hp <= 0 || pane.stage === 'chipped') ? 2 : 1;
      if (prop.impacts.length > DATA.props.window.maxImpacts) prop.impacts.splice(0, prop.impacts.length - DATA.props.window.maxImpacts);
      this.state.bulletMarks = this.state.bulletMarks.filter(mark => mark.propId !== prop.id || this._windowGlassAt(prop, mark.x, mark.y));
      return shardStage;
    }

    _glassShards(x, y, count, stage) {
      const speed = stage === 'shatter' ? 125 : stage === 'chip' ? 85 : 45;
      for (let i = 0; i < count && this.state.particles.length < DATA.tuning.maxParticles; i++) {
        const direction = this._random() * Math.PI * 2;
        const velocity = speed * (0.3 + this._random() * 0.7);
        const life = 0.65 + this._random() * 0.8;
        this.state.particles.push({ x, y, vx: Math.cos(direction) * velocity, vy: Math.sin(direction) * velocity - 35,
          angle: this._random() * Math.PI * 2, spin: (this._random() - 0.5) * 16,
          size: 2 + this._random() * (stage === 'shatter' ? 5 : 3), life, maxLife: life,
          color: this._pick(['#cdeef0', '#a1d4df', '#e4f7ef']), shape: Math.floor(this._random() * 3), type: 'glassShard' });
      }
    }

    _heatRooms(points, blockers, weaponIndex, source) {
      const bounds = DATA.roomFire.bounds;
      const hitRooms = new Map();
      for (const point of points) {
        if (blockers.some(rect => inRect(point.x, point.y, rect))) continue;
        const cell = this.state.cells.find(item => !item.breached && !item.roomFire && inRect(point.x, point.y, { x: item.x + bounds.dx, y: item.y + bounds.dy, w: bounds.w, h: bounds.h }));
        if (cell) hitRooms.set(cell, (hitRooms.get(cell) || 0) + 1);
      }
      const weapon = DATA.weapons[weaponIndex];
      for (const [cell, count] of hitRooms) {
        if (this.state.mode === 'ending' || this.state.mode === 'result') return;
        const fraction = count / points.length;
        cell.roomHeat = Math.min(DATA.roomFire.threshold, cell.roomHeat + weapon.roomHeat * fraction);
        cell.roomShotDamage = Math.min(DATA.roomFire.maxShotDamage, cell.roomShotDamage + weapon.damage * fraction);
        cell.roomShotCount++;
        cell.lastRoomShot = source;
        cell.interiorDamage = Math.max(cell.interiorDamage, cell.roomHeat / DATA.roomFire.threshold * 0.3);
        if (cell.roomHeat >= DATA.roomFire.threshold - 0.00001) this._igniteRoom(cell, source, null, 'gunfire');
      }
    }

    _charge(fields, x, y) {
      const before = this.state.invoiceTotal;
      const entry = this._log(fields);
      const added = this.state.invoiceTotal - before;
      if (added > 0 && !this.suppressInvoiceFloaters) this._floater(x, y, `請求 +¥${added.toLocaleString('en-US')}`, '#ffc183');
      return entry;
    }

    _igniteRoom(cell, source, causeId, reason) {
      if (!cell || cell.roomFire || this.state.mode === 'ending' || this.state.mode === 'result') return false;
      const ignition = this._log({ source, target: `room_ignition_${cell.id}`, cellId: cell.id, label: reason === 'dynamite' ? 'ダイナマイトの火が室内へ延焼' : '室内への連続射撃で着火', cost: 0, causeId, event: 'roomIgnition' });
      const interior = this._charge({ source: ignition.target, target: `room_interior_${cell.id}`, cellId: cell.id, label: '室内内装の火災修繕', cost: DATA.roomFire.interiorCost, causeId: ignition.id, event: 'roomInteriorBurned' }, cell.x + cell.w / 2, cell.y - 8);
      cell.roomHeat = DATA.roomFire.threshold;
      cell.roomFire = { stage: 'burning', age: 0, fireAge: 0, timer: DATA.roomFire.burningDuration + DATA.roomFire.spreadingDuration + DATA.roomFire.smokeDuration,
        active: true, intensity: 0.55, smoke: 0.2, source, ignitionId: ignition.id, causeId: interior.id,
        damageIds: [interior.id], everIgnited: true, spreadDone: false, particleTimer: 0, reason };
      cell.eventState = 'roomFire';
      cell.interiorDamage = Math.max(cell.interiorDamage, 0.5);
      this.state.accidents++;
      this._actorsInCell(cell).filter(actor => actor.kind === 'resident').forEach(actor => this._hitResident(actor, false));
      this._emit('fire', { room: true, cellId: cell.id, x: cell.x + cell.w / 2, y: cell.y + 55 });
      this._damageStructure(DATA.structure.roomIgnitionDamage, { source: ignition.target, causeId: ignition.id, kind: 'roomFire', cellId: cell.id, x: cell.x + cell.w / 2, y: cell.y + 40 });
      return true;
    }

    _spreadRoomFire(cell) {
      const fire = cell.roomFire;
      if (!fire || fire.spreadDone || this.state.mode === 'ending' || this.state.mode === 'result') return;
      fire.spreadDone = true;
      const contents = this._charge({ source: `room_ignition_${cell.id}`, target: `room_contents_${cell.id}`, cellId: cell.id, label: '室内家財・家具焼損', cost: DATA.roomFire.contentsCost, causeId: fire.causeId, event: 'roomContentsBurned' }, cell.x + cell.w / 2, cell.y + 18);
      fire.damageIds.push(contents.id);
      const before = this.state.invoiceTotal;
      const wall = this._damageWall(cell, `room_ignition_${cell.id}`, contents.id, '室内火災による壁面焦損');
      if (this.state.invoiceTotal > before) this._floater(cell.x + cell.w / 2, cell.y - 5, `請求 +¥${(this.state.invoiceTotal - before).toLocaleString('en-US')}`, '#ffb277');
      fire.damageIds.push(wall.id);
      fire.causeId = contents.id;
      cell.interiorDamage = 1;
      cell.soot = 1;
      if (!cell.breached) cell.damageState = 'burned';
      for (const prop of cell.props) {
        if (prop.hp > 0 && DATA.roomFire.spreadPropTypes.includes(prop.type)) {
          const oldTotal = this.state.invoiceTotal;
          this._damageProp(prop, prop.hp, `room_ignition_${cell.id}`, contents.id, DATA.tuning.maxChainDepth, { x: prop.x + prop.w / 2, y: prop.y + prop.h / 2, area: true, score: false });
          if (this.state.invoiceTotal > oldTotal) this._floater(prop.x + prop.w / 2, prop.y, `請求 +¥${(this.state.invoiceTotal - oldTotal).toLocaleString('en-US')}`, '#ffbc86');
        }
      }
      this._damageStructure(DATA.structure.roomSpreadDamage, { source: `room_ignition_${cell.id}`, causeId: contents.id, kind: 'roomFire', cellId: cell.id, x: cell.x + cell.w / 2, y: cell.y + 35 });
    }

    _updateRoomFires(dt) {
      const config = DATA.roomFire;
      const hotEnd = config.burningDuration + config.spreadingDuration;
      const end = hotEnd + config.smokeDuration;
      for (const cell of this.state.cells) {
        const fire = cell.roomFire;
        if (!fire || fire.stage === 'out') continue;
        fire.age += dt;
        fire.fireAge = fire.age;
        fire.timer = Math.max(0, end - fire.age);
        fire.active = fire.age < hotEnd;
        if (fire.age < config.burningDuration) {
          fire.stage = 'burning'; fire.intensity = 0.55 + fire.age / config.burningDuration * 0.45; fire.smoke = 0.3;
        } else if (fire.age < hotEnd) {
          fire.stage = 'spreading'; fire.intensity = 1; fire.smoke = 0.7;
          this._spreadRoomFire(cell);
          if (this.state.mode === 'ending') return;
        } else if (fire.age < end) {
          fire.stage = 'smoldering'; fire.intensity = 0.18 * (1 - (fire.age - hotEnd) / config.smokeDuration); fire.smoke = 0.65;
        } else {
          fire.stage = 'out'; fire.intensity = 0; fire.smoke = config.smokeResidue;
          cell.eventState = 'scorched';
        }
        fire.particleTimer -= dt;
        if (fire.particleTimer <= 0 && fire.stage !== 'out') {
          fire.particleTimer = config.particleInterval;
          const x = cell.x + 22 + this._random() * (cell.w - 44);
          this._particles(x, cell.y + 58, fire.active ? 2 : 1, fire.active ? ['#ffb649', '#f77435', '#fadd77'] : ['#69636b', '#8a8081'], 28, 0.85, fire.active ? 'fire' : 'smoke');
        }
      }
    }

    _detonateDynamite(source) {
      const boss = this.state.boss;
      const bundle = boss && boss.dynamite;
      if (!boss || boss.dead || boss.hp <= 0 || this.state.mode !== 'boss' || !bundle || !bundle.active || bundle.detonated) return false;
      bundle.active = false; bundle.detonated = true; bundle.state = 'exploded'; bundle.hp = 0;
      bundle.flash = 1; bundle.timer = 0; bundle.explosionAge = 0;
      bundle.blastX = bundle.x + bundle.w / 2; bundle.blastY = bundle.y + bundle.h / 2;
      this.state.blasts.push({ id: bundle.id, type: 'dynamite', x: bundle.blastX, y: bundle.blastY, age: 0, duration: 2.1 });
      const totalBefore = this.state.invoiceTotal;
      const previousSuppression = this.suppressInvoiceFloaters;
      this.suppressInvoiceFloaters = true;
      const blast = this._charge({ source, target: bundle.id, cellId: boss.cellId, label: 'ダイナマイト束誘爆・全棟緊急補修', cost: DATA.dynamite.cost, causeId: null, event: 'dynamite' }, bundle.blastX, bundle.blastY - 28);
      bundle.causeId = blast.id;
      this._emit('dynamite', { detonated: true, x: bundle.blastX, y: bundle.blastY, cost: DATA.dynamite.cost });
      this.state.shake = Math.max(this.state.shake, DATA.dynamite.shake);
      this.state.flash = Math.max(this.state.flash, DATA.dynamite.flash);
      this._particles(bundle.blastX, bundle.blastY, DATA.dynamite.particles, ['#fff4bd', '#ffc44f', '#ef773d', '#676068'], 195, 1.65);
      this._damageActor(boss, boss.hp, bundle.id, blast.id, false);
      const origin = this._cell(boss.cellId);
      for (const offset of DATA.dynamite.neighbours) {
        const cell = this.state.cells.find(item => item.col === origin.col + offset.dc && item.row === origin.row + offset.dr);
        if (!cell) continue;
        this._igniteRoom(cell, bundle.id, blast.id, 'dynamite');
        if (this.state.mode === 'ending') break;
        for (const prop of cell.props) {
          if (prop.hp > 0 && DATA.dynamite.damagePropTypes.includes(prop.type)) {
            const before = this.state.invoiceTotal;
            this._damageProp(prop, prop.hp, bundle.id, blast.id, DATA.tuning.maxChainDepth, { x: prop.x + prop.w / 2, y: prop.y + prop.h / 2, area: true, score: false });
            if (this.state.invoiceTotal > before && !this.suppressInvoiceFloaters) this._floater(prop.x + prop.w / 2, prop.y, `請求 +¥${(this.state.invoiceTotal - before).toLocaleString('en-US')}`, '#ffd292');
          }
        }
      }
      this.suppressInvoiceFloaters = previousSuppression;
      this._damageStructure(DATA.structure.dynamiteDamage, { source: bundle.id, causeId: blast.id, kind: 'dynamite', cellId: boss.cellId, x: bundle.blastX, y: bundle.blastY });
      if (this.state.mode !== 'ending') {
        this._floater(bundle.blastX, bundle.blastY - 28, `大事故 +¥${(this.state.invoiceTotal - totalBefore).toLocaleString('en-US')}`, '#ffe09a');
        this._message('爆弾を撃つなと言ったのに。請求額、急上昇。', 3.5);
      }
      return true;
    }

    _log(fields) {
      // Every physical object has one billable repair. Zero-cost causal events remain readable.
      if (fields.cost > 0 && this.billed.has(fields.target)) return this.state.log.find(entry => entry.target === fields.target && entry.cost > 0);
      const entry = Object.assign({ id: this._id('event'), time: this.state.time, causeId: null, cost: 0 }, fields);
      if (entry.cost > 0) this.billed.add(entry.target);
      this.state.log.push(entry);
      this.state.invoiceTotal += entry.cost;
      return entry;
    }

    _beginFall(prop, causeId) {
      const cell = this._cell(prop.cellId);
      const lower = this.state.cells.find(item => item.col === cell.col && item.row === cell.row + 1);
      if (!lower) return;
      prop.state = 'falling';
      prop.fallTime = 0;
      prop.fallFrom = { x: prop.x, y: prop.y };
      prop.fallTargetId = lower.id;
      prop.fallCauseId = causeId;
      prop.fallTo = { x: lower.x + lower.w / 2 - prop.w / 2, y: lower.y + 55 - prop.h };
      prop.rotation = 0;
      this._emit('fall', { x: prop.x, y: prop.y, propType: prop.type });
      this._floater(prop.x, prop.y - 4, 'あっ。', '#f4e9d2');
    }

    _landProp(prop) {
      prop.state = 'broken';
      const lower = this._cell(prop.fallTargetId);
      if (!lower) return;
      const impact = this._log({ source: prop.id, target: `${prop.id}_impact`, cellId: lower.id, label: `${DATA.props[prop.type].name.replace(/破損|交換/g, '')}が階下へ落下`, cost: 0, causeId: prop.fallCauseId, event: 'impact' });
      this._particles(prop.x + prop.w / 2, prop.y + prop.h, 24, ['#e5d3ab', '#b48e6c', '#776a5c'], 95, 0.9);
      this._emit('fall', { impact: true, x: prop.x, y: prop.y, propType: prop.type });
      this.state.shake = Math.max(this.state.shake, 2.1);
      const target = lower.props.find(item => item.hp > 0 && item.type === 'bbq') || lower.props.find(item => item.hp > 0 && item.type === 'pot');
      if (target) {
        const broken = this._damageProp(target, target.hp, prop.id, impact.id, (prop.chainDepth || 0) + 1);
        if (target.type === 'bbq' && broken && target.state !== 'burning') this._ignite(target, broken.id, (prop.chainDepth || 0) + 2);
      } else {
        this._damageWall(lower, prop.id, impact.id, '外壁の落下物衝突痕');
      }
      const actors = this._actorsInCell(lower);
      actors.forEach(actor => {
        if (actor.kind === 'resident') this._hitResident(actor, false);
        else if (actor.kind !== 'boss') this._damageActor(actor, 180, prop.id, impact.id, true);
      });
      this.state.accidents++;
      this._award(DATA.scoring.accidentBonus, lower.x + 50, lower.y - 8, '階下へお届け', false);
    }

    _damageWall(cell, source, causeId, label) {
      cell.damage = Math.max(cell.damage, 1.5);
      cell.damageState = cell.breached ? 'hole' : 'cracked';
      return this._log({ source, target: `wall_${cell.id}`, cellId: cell.id, label: label || '共用部壁面修繕', cost: DATA.tuning.wallRepairCost, causeId, event: 'wallDamage' });
    }

    _insideBuilding(x, y) {
      const config = DATA.bulletMarks;
      if (!inRect(x, y, config.surfaceBounds) || inRect(x, y, config.foregroundTowel)) return false;
      const edge = config.foregroundEdge;
      for (let i = 0; i < edge.length - 1; i++) {
        if (x >= edge[i].x && x <= edge[i + 1].x) {
          const t = (x - edge[i].x) / (edge[i + 1].x - edge[i].x);
          return y < edge[i].y + (edge[i + 1].y - edge[i].y) * t;
        }
      }
      return true;
    }

    _damageStructureFromShot(points, blockers, weaponIndex, source) {
      const arriving = points.filter(point => this._insideBuilding(point.x, point.y) && !blockers.some(rect => inRect(point.x, point.y, rect)));
      if (!arriving.length) return;
      const weapon = DATA.weapons[weaponIndex];
      const x = arriving.reduce((sum, point) => sum + point.x, 0) / arriving.length;
      const y = arriving.reduce((sum, point) => sum + point.y, 0) / arriving.length;
      const cell = this.state.cells.find(item => inRect(x, y, item));
      const boss = this.state.boss;
      if (boss && !boss.dead && boss.speechCooldown <= 0) {
        boss.speech = DATA.tuning.bossBarks[boss.speechIndex++ % DATA.tuning.bossBarks.length];
        boss.speechTimer = DATA.tuning.bossSpeechDuration;
        boss.speechCooldown = DATA.tuning.bossSpeechInterval;
      }
      this._damageStructure(weapon.damage * weapon.structuralImpact * arriving.length / points.length,
        { source, causeId: null, kind: 'bullet', cellId: cell ? cell.id : '全棟', x, y });
    }

    _damageStructure(amount, cause) {
      const state = this.state;
      if (!Number.isFinite(amount) || amount <= 0 || !['playing', 'boss', 'case', 'rocket'].includes(state.mode) || state.buildingCollapsed) return 0;
      const structure = state.structure;
      const applied = Math.min(structure.hp, amount);
      if (applied <= 0) return 0;
      structure.hp = Math.max(0, structure.hp - applied);
      structure.totalDamage += applied;
      structure.damageRatio = 1 - structure.hp / structure.maxHp;
      structure.lastSource = cause.source;
      structure.lastCauseId = cause.causeId || null;
      structure.lastKind = cause.kind;
      structure.lastHitX = Number.isFinite(cause.x) ? cause.x : DATA.building.x + DATA.building.w / 2;
      structure.lastHitY = Number.isFinite(cause.y) ? cause.y : DATA.building.y + DATA.building.h / 2;
      if (structure.hp <= 0) {
        this._beginCollapse(cause);
      } else {
        const ratio = structure.hp / structure.maxHp;
        while (structure.warningLevel < DATA.structure.warningRatios.length && ratio <= DATA.structure.warningRatios[structure.warningLevel]) {
          structure.warningLevel++;
          this._message(DATA.structure.warningMessages[structure.warningLevel - 1], 3);
          this._emit('structureWarning', { level: structure.warningLevel, hp: structure.hp, maxHp: structure.maxHp, ratio });
        }
      }
      return applied;
    }

    _beginCollapse(cause) {
      const state = this.state;
      if (state.endingReason || state.buildingCollapsed || ['title', 'ending', 'result'].includes(state.mode)) return false;
      const structure = state.structure;
      const source = cause && cause.source || structure.lastSource || 'buildingStructure';
      const causeId = cause && cause.causeId || structure.lastCauseId || null;
      const x = cause && Number.isFinite(cause.x) ? cause.x : structure.lastHitX ?? DATA.building.x + DATA.building.w / 2;
      const y = cause && Number.isFinite(cause.y) ? cause.y : structure.lastHitY ?? DATA.building.y + DATA.building.h / 2;
      const failure = this._log({ source, target: 'building_structure_failure', cellId: cause && cause.cellId || '全棟', label: '累積損傷で建物が耐久限界に到達', cost: 0, causeId, event: 'structureFailure' });
      const rebuild = this._log({ source, target: 'building_final', cellId: '全棟', label: '建物全壊・全棟再建費', cost: DATA.structure.rebuildCost, causeId: failure.id, event: 'collapse' });
      structure.hp = 0;
      structure.damageRatio = 1;
      structure.collapseCauseId = failure.id;
      state.collapseLogId = rebuild.id;
      state.mode = 'ending';
      state.endingReason = 'collapse';
      state.endingTime = 0;
      state.buildingCollapsed = true;
      state.collapseOrigin = { x, y };
      state.rocketTarget = null;
      state.caseReady = false;
      state.caseOpened = false;
      state.intruderWarning = 0;
      this.quietTimer = 0;
      this._clearBuilding();
      state.floaters = [];
      this._floater(DATA.width / 2, 136, `全壊 +¥${DATA.structure.rebuildCost.toLocaleString('en-US')}`, '#ffcf8b');
      state.shake = 7;
      state.flash = 0.15;
      this._message('建物、全壊。再建費はあなた宛てです。', 3);
      this._particles(x, y, 75, ['#d8c4a4', '#968778', '#625d61'], 135, 1.7);
      this._emit('collapse', { started: true, reason: 'collapse', x, y });
      return true;
    }

    _clearBuilding() {
      const state = this.state;
      for (const actor of state.actors) {
        actor.hp = 0;
        actor.dead = true;
        if (actor.dynamite) { actor.dynamite.active = false; if (!actor.dynamite.detonated) actor.dynamite.state = 'disarmed'; }
      }
      state.actors = [];
      state.boss = null;
      state.bossProjectiles = [];
      state.caseReady = false;
      state.bulletMarks = [];
      state.tracers = [];
      state.cells.forEach(cell => {
        cell.damage = 3;
        cell.damageState = 'hole';
        cell.eventState = 'collapse';
        cell.props.forEach(prop => {
          prop.hp = 0; prop.state = 'broken';
          if (prop.panes) { prop.damageStage = 3; prop.panes.forEach(pane => { pane.hp = 0; pane.stage = 'shattered'; pane.shatterTimer = 0; }); }
        });
        if (cell.roomFire) { cell.roomFire.stage = 'out'; cell.roomFire.active = false; cell.roomFire.intensity = 0; cell.roomFire.timer = 0; }
      });
    }

    _ignite(prop, causeId, depth) {
      if (prop.state === 'burning' || depth > DATA.tuning.maxChainDepth) return;
      prop.state = 'burning';
      prop.fireTimer = DATA.tuning.fireDuration;
      prop.fireAge = 0;
      prop.fireTick = 0;
      prop.fireSpread = false;
      prop.fireCauseId = causeId;
      const cell = this._cell(prop.cellId);
      cell.eventState = 'fire';
      this.state.accidents++;
      this._floater(prop.x, prop.y - 10, 'BBQ、大成功。', '#ffa365');
      this._emit('explosion', { small: true, x: prop.x, y: prop.y });
      this._emit('fire', { x: prop.x, y: prop.y });
      this._award(DATA.scoring.accidentBonus, prop.x, prop.y - 25, '炎上ボーナス', false);
    }

    _spreadFire(prop) {
      if (this.state.mode === 'ending' || this.state.mode === 'result') return;
      const cell = this._cell(prop.cellId);
      prop.fireSpread = true;
      let parent = prop.fireCauseId;
      if (cell.decoration.includes('laundry') || cell.decoration.includes('futon') || cell.decoration.includes('clothesline')) {
        const laundry = this._log({ source: prop.id, target: `laundry_${cell.id}`, cellId: cell.id, label: '洗濯物焼失', cost: DATA.tuning.laundryRepairCost, causeId: parent, event: 'ignited' });
        parent = laundry.id;
        cell.laundryBurned = true;
      }
      this._damageWall(cell, prop.id, parent, '共用部壁面焦損');
      cell.damageState = 'burned';
      this._actorsInCell(cell).forEach(actor => {
        if (actor.kind === 'resident') this._hitResident(actor, false);
        else if (actor.kind !== 'boss') this._damageActor(actor, 180, prop.id, parent, true);
      });
      if (!prop.structureBurned) {
        prop.structureBurned = true;
        this._damageStructure(DATA.structure.bbqFireDamage, { source: prop.id, causeId: parent, kind: 'bbqFire', cellId: cell.id, x: prop.x, y: prop.y });
      }
    }

    _checkProgress() {
      const state = this.state;
      if (state.mode !== 'playing') return;
      while (state.phase < DATA.phases.length - 1 && state.progress >= DATA.phases[state.phase + 1].threshold) {
        state.phase++;
        const phase = DATA.phases[state.phase];
        const unlock = phase.unlockWeapon;
        const newlyUnlocked = !state.unlockedWeapons[unlock];
        state.unlockedWeapons[unlock] = true;
        this._message(phase.message, DATA.tuning.messageDuration + 0.5);
        this._emit('unlock', { phase: state.phase, weapon: unlock, newWeapon: newlyUnlocked });
        this.enemyTimer = Math.min(this.enemyTimer, 0.7);
        this.residentTimer = Math.min(this.residentTimer, 1.7);
      }
      if (state.progress >= DATA.scoring.bossThreshold) this._spawnBoss();
    }

    _spawnBoss() {
      if (this.state.mode !== 'playing') return;
      const state = this.state;
      state.mode = 'boss';
      const cell = state.cells[7];
      state.actors = state.actors.filter(actor => actor.cellId !== cell.id);
      // The breach needs one visible slot; preserve the rest of the ongoing wave.
      if (state.actors.length >= DATA.tuning.maxActors) {
        const retiring = state.actors.find(actor => actor.dead || actor.retreat > 0) || state.actors.find(actor => actor.kind === 'enemy');
        if (retiring) state.actors = state.actors.filter(actor => actor !== retiring);
      }
      const boss = this._newActor('boss', 'boss', cell, DATA.enemies.boss);
      boss.x = boss.homeX = cell.x + cell.w / 2;
      boss.y = boss.homeY = cell.y + 92;
      boss.stunTimer = 0;
      boss.rage = { elapsed: 0, duration: DATA.bossFight.rageDuration, ratio: 0, warning: false, throws: 0 };
      boss.speech = DATA.tuning.bossBarks[0];
      boss.speechTimer = DATA.tuning.bossSpeechDuration;
      boss.speechCooldown = DATA.tuning.bossSpeechInterval;
      boss.speechIndex = 1;
      boss.dynamite = { id: `${boss.id}_dynamite`, x: boss.x + DATA.dynamite.offsetX, y: boss.y + DATA.dynamite.offsetY,
        w: DATA.dynamite.w, h: DATA.dynamite.h, hp: 1, active: true, detonated: false,
        state: 'armed', flash: 0, timer: 0, explosionAge: null, blastX: null, blastY: null };
      state.boss = boss;
      this.enemyTimer = Math.min(this.enemyTimer, DATA.phases[DATA.phases.length - 1].enemyInterval);
      const breach = this._log({ source: boss.id, target: `breach_${boss.id}`, cellId: cell.id, label: '巨大クレーマーが壁を突き破った', cost: 0, causeId: null, event: 'bossBreach' });
      const wallRepair = this._damageWall(cell, boss.id, breach.id, '巨大クレーマーによる外壁大穴');
      cell.props.filter(prop => ['window', 'rail'].includes(prop.type) && prop.hp > 0).forEach(prop => {
        prop.hp = 0;
        prop.state = 'broken';
        prop.hitTimer = 0;
        if (prop.panes) { prop.damageStage = 3; prop.panes.forEach(pane => { pane.hp = 0; pane.stage = 'shattered'; pane.shatterTimer = 0; }); }
        // These parts disappear in the actual breach. Their repair is included in
        // the wall item, and they must no longer be invisible shootable objects.
        const damage = this._log({ source: boss.id, target: prop.id, cellId: cell.id, label: `${DATA.props[prop.type].name}（外壁修繕に含む）`, cost: 0, causeId: breach.id, event: 'bossBreakage' });
        prop.causeId = damage.id;
        prop.repairCoveredBy = wallRepair.id;
      });
      cell.damage = 3;
      cell.damageState = 'hole';
      cell.breached = true;
      cell.eventState = 'boss';
      state.bulletMarks = state.bulletMarks.filter(mark => !this._insideHole(cell, mark.x, mark.y) && !cell.props.some(prop => prop.hp <= 0 && prop.id === mark.propId));
      state.shake = 5;
      state.flash = 0.15;
      this._particles(boss.x, boss.y - 58, 50, ['#d8bf96', '#9c8d7c', '#675e5a'], 135, 1.4);
      this._message('赤い束は爆弾！　ボスの本体を狙って！', 5);
      this._emit('boss', { spawned: true, x: boss.x, y: boss.y });
    }

    _message(text, duration) {
      this.state.message = text;
      this.state.messageTimer = duration || DATA.tuning.messageDuration;
    }

    update(dtSeconds) {
      if (!Number.isFinite(dtSeconds) || dtSeconds <= 0 || this.state.paused) return;
      const dt = Math.min(dtSeconds, DATA.tuning.maxDt);
      const state = this.state;
      if (state.mode === 'result') return;
      state.time += dt;
      this.cooldown = Math.max(0, this.cooldown - dt);
      state.shake = Math.max(0, state.shake - dt * 12);
      state.flash = Math.max(0, state.flash - dt * 1.7);
      state.recoil = Math.max(0, state.recoil - dt * 8);
      state.intruderWarning = Math.max(0, state.intruderWarning - dt);
      state.messageTimer = Math.max(0, state.messageTimer - dt);
      if (!state.messageTimer) state.message = '';
      state.comboTimer = Math.max(0, state.comboTimer - dt);
      if (!state.comboTimer && state.combo) this._resetCombo();
      this._updateEffects(dt);
      state.blasts.forEach(blast => { blast.age += dt; });
      state.blasts = state.blasts.filter(blast => blast.age < blast.duration);
      if (['playing', 'boss', 'case', 'rocket'].includes(state.mode) && state.structure.hp <= 0) this._beginCollapse();
      this._updateBossProjectiles(dt);
      if (state.mode === 'title') {
        state.actors.forEach(actor => { actor.age += dt; });
        return;
      }
      if (state.mode === 'ending') {
        this._updateEnding(dt);
        return;
      }
      this._updateActors(dt);
      this._updateProps(dt);
      if (state.mode === 'ending') return;
      this._updateRoomFires(dt);
      if (state.mode === 'playing') {
        this._checkProgress();
        if (state.mode !== 'playing') return;
        this._updateSpawns(dt);
      } else if (state.mode === 'boss') {
        if (!state.bossDefeated) {
          this._updateBossRage(dt);
          this._updateSpawns(dt);
        } else {
          this.quietTimer -= dt;
          if (this.quietTimer <= 0) {
            state.mode = 'case';
            state.caseReady = true;
            state.boss = null;
            this._message('非常用ケースが解錠された。右下の赤いケースをクリック。', 30);
            this._emit('case', { ready: true });
          }
        }
      }
    }

    _updateBossRage(dt) {
      const state = this.state, boss = state.boss;
      if (state.mode !== 'boss' || state.bossDefeated || !boss || boss.dead) return;
      const rage = boss.rage;
      rage.elapsed += dt;
      rage.ratio = clamp(rage.elapsed / rage.duration, 0, 1);
      if (!rage.warning && rage.elapsed >= rage.duration - 4) {
        rage.warning = true;
        this._emit('bossRage', { warning: true, x: boss.x, y: boss.y - boss.h, remaining: Math.max(0, rage.duration - rage.elapsed) });
      }
      if (rage.elapsed + 1e-9 >= rage.duration) {
        const projectile = { id: this._id('bossBomb'), fromX: boss.x - 52, fromY: boss.y - 70,
          toX: 480, toY: 505, age: 0, duration: DATA.bossFight.bombFlightDuration };
        state.bossProjectiles.push(projectile);
        rage.elapsed = Math.max(0, rage.elapsed - rage.duration);
        rage.ratio = rage.elapsed / rage.duration;
        rage.warning = false;
        rage.throws++;
        this._emit('bossThrow', { x: projectile.fromX, y: projectile.fromY, projectileId: projectile.id });
      }
    }

    _updateBossProjectiles(dt) {
      const state = this.state;
      if (state.buildingCollapsed) { state.bossProjectiles = []; return; }
      for (const projectile of state.bossProjectiles) {
        projectile.age += dt;
        if (projectile.age + 1e-9 < projectile.duration) continue;
        const penalty = Math.min(state.score, DATA.bossFight.bombScorePenalty);
        state.bossBombHits++;
        state.bossScoreLost += penalty;
        this._penalty(penalty, projectile.toX, projectile.toY - 65, '爆弾直撃！');
        this._resetCombo();
        state.flash = Math.max(state.flash, 0.7);
        state.shake = Math.max(state.shake, 8);
        state.blasts.push({ id: projectile.id, type: 'bossBomb', x: projectile.toX, y: projectile.toY, age: 0, duration: 1.1 });
        this._particles(projectile.toX, projectile.toY - 15, 75, ['#fff0bd', '#ffc562', '#d76b40', '#66576b'], 185, 1.1);
        this._emit('bossBomb', { x: projectile.toX, y: projectile.toY, penalty, configuredPenalty: DATA.bossFight.bombScorePenalty, projectileId: projectile.id });
      }
      state.bossProjectiles = state.bossProjectiles.filter(projectile => projectile.age + 1e-9 < projectile.duration);
    }

    _updateSpawns(dt) {
      const state = this.state;
      if (state.mode !== 'playing' && !(state.mode === 'boss' && !state.bossDefeated)) return;
      const phaseIndex = state.mode === 'boss' ? DATA.phases.length - 1 : state.phase;
      const phase = DATA.phases[phaseIndex];
      this.enemyTimer -= dt;
      this.residentTimer -= dt;
      if (phaseIndex >= 2) this.intruderTimer -= dt;
      // Fading enemies still occupy their visible slot until they leave the balcony.
      const enemies = state.actors.filter(actor => actor.kind === 'enemy');
      const residents = state.actors.filter(actor => actor.kind === 'resident');
      const hasIntruder = state.actors.some(actor => actor.kind === 'intruder');
      const ordinaryLimit = DATA.tuning.maxActors - (phaseIndex >= 2 && !hasIntruder ? 1 : 0);
      if (this.enemyTimer <= 0 && enemies.length < phase.maxEnemies && state.actors.length < ordinaryLimit) {
        const actor = this._spawnEnemy();
        this.enemyTimer = actor ? phase.enemyInterval * (1 + (this._random() * 2 - 1) * DATA.tuning.enemySpawnJitter) : 0.25;
      }
      // The enemy timer keeps retrying when cells were temporarily occupied.
      if (this.residentTimer <= 0 && residents.length < phase.maxResidents && state.actors.length < Math.min(ordinaryLimit, DATA.tuning.maxActors - 1)) {
        const actor = this._spawnResident();
        this.residentTimer = actor ? DATA.tuning.residentInterval * (0.8 + this._random() * 0.5) : 0.5;
      }
      if (phaseIndex >= 2 && this.intruderTimer <= 0 && !hasIntruder && state.actors.length < DATA.tuning.maxActors) {
        this._spawnIntruder();
        this.intruderTimer = DATA.tuning.intruderInterval * (0.9 + this._random() * 0.2);
      }
    }

    _updateActors(dt) {
      const state = this.state;
      for (const cell of state.cells) cell.cooldown = Math.max(0, cell.cooldown - dt);
      for (const actor of state.actors) {
        actor.age += dt;
        actor.hitTimer = Math.max(0, actor.hitTimer - dt);
        actor.stunTimer = Math.max(0, (actor.stunTimer || 0) - dt);
        actor.speechCooldown = Math.max(0, (actor.speechCooldown || 0) - dt);
        if (actor.dynamite && actor.dynamite.detonated) {
          actor.dynamite.explosionAge += dt;
          actor.dynamite.flash = Math.max(0, actor.dynamite.flash - dt * 2);
        }
        actor.speechTimer = Math.max(0, actor.speechTimer - dt);
        if (!actor.speechTimer) actor.speech = '';
        if (actor.dead) {
          actor.deadTime += dt;
          actor.retreat = clamp(actor.deadTime / DATA.tuning.enemyFadeTime, 0, 1);
          continue;
        }
        if (actor.victoryRetreat) {
          actor.retreatTime += dt;
          actor.retreat = clamp(actor.retreatTime / 0.6, 0, 1);
          continue;
        }
        if (actor.kind === 'resident') {
          if (actor.warningRetreat) {
            actor.reactionTime += dt;
            actor.pose = actor.reaction = 'retreat';
            actor.retreat = clamp(actor.reactionTime / DATA.tuning.warningRetreatDuration, 0, 1);
          } else if (actor.reaction) {
            actor.reactionTime += dt;
            if (actor.reactionTime < 0.38) actor.pose = actor.reaction = 'surprised';
            else if (actor.reactionTime < 1.05) actor.pose = actor.reaction = 'angry';
            else {
              actor.pose = actor.reaction = 'retreat';
              actor.retreat = clamp((actor.reactionTime - 1.05) / (DATA.tuning.residentReaction - 1.05), 0, 1);
            }
          } else if (actor.age > actor.lifetime) {
            actor.pose = 'retreat';
            actor.retreat = clamp((actor.age - actor.lifetime) / 0.7, 0, 1);
          }
        } else if (actor.kind === 'intruder') {
          this._updateIntruder(actor, dt);
        } else if (actor.kind === 'enemy') {
          const motion = DATA.enemies[actor.type].motion || {};
          actor.x = actor.homeX + Math.sin(actor.age * (motion.frequencyX ?? 0) + actor.phaseOffset) * (motion.swayX ?? 0);
          actor.y = actor.homeY + Math.sin(actor.age * (motion.frequencyY ?? 0) + actor.phaseOffset + (motion.phaseY ?? 0)) * (motion.swayY ?? 0);
          if (actor.age > actor.lifetime) {
            actor.pose = 'retreat';
            actor.retreat = clamp((actor.age - actor.lifetime) / 0.65, 0, 1);
          }
        } else if (actor.kind === 'boss') {
          actor.x = actor.homeX + Math.sin(actor.age * 1.3) * 5;
          actor.y = actor.homeY + Math.sin(actor.age * 2.6) * 2;
          actor.pose = actor.stunTimer > 0 ? 'stunned' : 'rage';
          if (actor.dynamite) {
            actor.dynamite.x = actor.x + DATA.dynamite.offsetX;
            actor.dynamite.y = actor.y + DATA.dynamite.offsetY;
          }
        }
      }
      state.actors = state.actors.filter(actor => {
        if (actor.retreat >= 1) {
          const cell = this._cell(actor.cellId);
          if (cell && actor.kind !== 'intruder') {
            cell.cooldown = actor.warningRetreat ? 0 : actor.kind === 'resident' ? DATA.tuning.residentSpawnCooldown : 0.35;
            if (cell.eventState !== 'fire') cell.eventState = 'idle';
            cell.currentAnimation = null;
          }
          return false;
        }
        return true;
      });
    }

    _updateIntruder(actor, dt) {
      if (actor.arrived) {
        actor.leaveTime += dt;
        actor.x -= dt * 100;
        actor.y += dt * 12;
        actor.retreat = clamp(actor.leaveTime / DATA.tuning.intruderLeaveTime, 0, 1);
        return;
      }
      const route = DATA.tuning.intruderRoute;
      actor.routeProgress = clamp(actor.age / DATA.tuning.intruderJourney, 0, 1);
      const p = actor.routeProgress * (route.length - 1);
      const index = Math.min(route.length - 2, Math.floor(p));
      const t = p - index;
      actor.x = route[index].x + (route[index + 1].x - route[index].x) * t;
      actor.y = route[index].y + (route[index + 1].y - route[index].y) * t;
      if (actor.routeProgress >= 1) {
        actor.arrived = true;
        actor.leaveTime = 0;
        actor.carriedItem = this._pick(DATA.tuning.theftItems);
        actor.pose = 'carry';
        this.state.thefts++;
        this._penalty(DATA.scoring.theftPenalty, actor.x, actor.y - 60, `${actor.carriedItem}を盗まれた！`);
        this._log({ source: actor.id, target: `theft_${actor.id}`, cellId: '自宅', label: `${actor.carriedItem}を持ち逃げ`, cost: 0, causeId: null, event: 'theft' });
        this._emit('theft', { item: actor.carriedItem, x: actor.x, y: actor.y });
      }
    }

    _updateProps(dt) {
      for (const cell of this.state.cells) {
        for (const prop of cell.props) {
          prop.age += dt;
          for (const pane of prop.panes || []) {
            if (pane.shatterTimer > 0) {
              pane.shatterTimer = Math.max(0, pane.shatterTimer - dt);
              if (pane.shatterTimer === 0) pane.stage = 'shattered';
            }
          }
          prop.hitTimer = Math.max(0, prop.hitTimer - dt);
          if (prop.state === 'hit' && !prop.hitTimer) prop.state = 'intact';
          if (prop.state === 'falling') {
            prop.fallTime += dt;
            const t = clamp(prop.fallTime / DATA.tuning.fallDuration, 0, 1);
            prop.x = prop.fallFrom.x + (prop.fallTo.x - prop.fallFrom.x) * t;
            prop.y = prop.fallFrom.y + (prop.fallTo.y - prop.fallFrom.y) * t * t;
            prop.rotation = t * 2;
            if (t >= 1) this._landProp(prop);
          } else if (prop.state === 'burning') {
            prop.fireAge += dt;
            prop.fireTimer -= dt;
            prop.fireTick -= dt;
            if (prop.fireTick <= 0) {
              prop.fireTick = 0.085;
              this._particles(prop.x + prop.w / 2, prop.y, 2, ['#ffad43', '#ec6540', '#4e484b'], 26, 0.8, 'fire');
            }
            if (!prop.fireSpread && prop.fireAge >= DATA.tuning.fireDelay) this._spreadFire(prop);
            if (this.state.mode === 'ending') return;
            if (prop.fireTimer <= 0) {
              prop.state = 'broken';
              cell.eventState = 'smoke';
              this._particles(prop.x + prop.w / 2, prop.y, 12, ['#bbb4a5', '#777578'], 22, 1.5, 'smoke');
            }
          }
        }
      }
    }

    _advanceCollapseEvents(oldTime, time, reason) {
      const state = this.state;
      DATA.structure.floorImpactTimes.forEach((impactTime, index) => {
        if (oldTime < impactTime && time >= impactTime) {
          const x = DATA.building.x + DATA.building.w / 2;
          const y = DATA.building.y + DATA.building.h - 9;
          this._particles(x, y, 58, ['#d8c5a5', '#a3917e', '#6a6466'], 160, 1.8);
          state.shake = Math.max(state.shake, 5.5 - index);
          this._emit('collapse', { impact: true, reason, index, x, y });
        }
      });
      if (oldTime < DATA.structure.settledTime && time >= DATA.structure.settledTime) {
        this._emit('collapse', { settled: true, reason, x: DATA.width / 2, y: DATA.building.y + DATA.building.h });
        this._message('……更地になりました。', 1.8);
      }
    }

    _updateEnding(dt) {
      const state = this.state;
      const oldTime = state.endingTime;
      state.endingTime += dt;
      if (state.endingReason === 'collapse') {
        this._advanceCollapseEvents(oldTime, state.endingTime, 'collapse');
        if (state.endingTime >= DATA.structure.collapseDuration) this._finishResult();
        return;
      }
      if (!this.rocketExploded && state.endingTime >= DATA.tuning.rocketFlight) {
        this.rocketExploded = true;
        state.shake = 10;
        state.flash = 1;
        const target = state.rocketTarget;
        const rocket = this._log({ source: 'playerRocket', target: 'building_final', cellId: '全棟', label: '非常用ロケット使用に伴う全棟特別修繕', cost: DATA.tuning.finalRepairCost, causeId: null, event: 'rocket' });
        state.rocketLogId = rocket.id;
        state.buildingCollapsed = true;
        state.structure.hp = 0;
        state.structure.damageRatio = 1;
        state.structure.lastSource = 'playerRocket';
        state.structure.lastCauseId = rocket.id;
        this._clearBuilding();
        this._particles(target.x, target.y, 130, ['#fff1ba', '#ffb13d', '#f36c3b', '#69616a', '#bda383'], 250, 2.1);
        this._emit('explosion', { finale: true, x: target.x, y: target.y });
        this._emit('collapse', { started: true, reason: 'rocket', x: target.x, y: target.y });
      }
      this._advanceCollapseEvents(oldTime - DATA.tuning.rocketFlight, state.endingTime - DATA.tuning.rocketFlight, 'rocket');
      if (state.endingTime >= DATA.tuning.rocketFlight + DATA.structure.collapseDuration) {
        this._finishResult();
      }
    }

    _finishResult() {
      const state = this.state;
      if (state.mode === 'result') return;
      state.mode = 'result';
      state.message = state.endingReason === 'collapse' ? '建物を撃ち続けて更地にしないでください。' : 'ベランダでロケットランチャーを使用しないでください。';
      state.messageTimer = 0;
      state.best = Math.max(state.best || 0, state.score);
      state.invoiceTotal = state.log.reduce((sum, entry) => sum + entry.cost, 0);
      this._emit('result', { score: state.score, total: state.invoiceTotal, kills: state.kills, endingReason: state.endingReason });
    }

    _impactPoints(x, y, weapon) {
      const style = DATA.weapons[weapon].marks;
      const points = [{ x, y }];
      for (let i = 1; i < style.count; i++) {
        const angle = this._random() * Math.PI * 2;
        const radius = Math.sqrt(this._random()) * style.spread;
        points.push({ x: x + Math.cos(angle) * radius, y: y + Math.sin(angle) * radius });
      }
      return points;
    }

    _insideHole(cell, x, y) {
      const hole = DATA.bulletMarks.hole;
      return (x - cell.x - cell.w / 2) ** 2 + (y - cell.y - hole.centreDy) ** 2 <= hole.radius ** 2;
    }

    _propSurfaceContains(prop, x, y) {
      if (!inRect(x, y, prop)) return false;
      const inset = DATA.bulletMarks.propInsets;
      if (prop.type === 'pot') return y >= prop.y + prop.h * inset.potBodyStart && x >= prop.x + 2 && x <= prop.x + prop.w - 2;
      if (prop.type === 'bbq') return y >= prop.y + prop.h * inset.bbqBodyStart && y <= prop.y + prop.h * inset.bbqBodyEnd;
      if (prop.type === 'ac') return inRect(x, y, { x: prop.x + inset.ac, y: prop.y + inset.ac, w: prop.w - inset.ac * 2, h: prop.h - inset.ac * 2 });
      if (prop.type === 'rail') {
        const rail = DATA.bulletMarks.rail;
        const dy = y - prop.y;
        if (dy <= rail.topHeight || (dy >= prop.h - rail.bottomOffset && dy <= prop.h - rail.bottomOffset + rail.bottomHeight)) return true;
        if (dy < rail.topHeight || dy > prop.h - rail.bottomOffset) return false;
        const step = (prop.w - rail.barInset * 2) / (rail.barCount - 1);
        const index = Math.round((x - prop.x - rail.barInset) / step);
        // Damage temporarily bends the middle bars; leave their tiny surfaces unmarked.
        if (prop.state === 'hit' && index > 4 && index < 8) return false;
        const barX = prop.x + rail.barInset + index * step;
        return index >= 0 && index < rail.barCount && x >= barX && x <= barX + rail.barWidth;
      }
      return prop.type === 'window' && this._windowGlassAt(prop, x, y);
    }

    _bulletSurface(x, y) {
      const config = DATA.bulletMarks;
      if (!this._insideBuilding(x, y)) return null;
      if (this.state.cells.some(cell => (cell.breached || cell.damageState === 'hole') && this._insideHole(cell, x, y))) return null;

      // Test real solid silhouettes first, leaving gaps between rails and under BBQs empty.
      const propPriority = ['rail', 'pot', 'bbq', 'ac'];
      for (const type of propPriority) {
        for (const cell of this.state.cells) {
          const prop = cell.props.find(item => item.type === type && item.hp > 0 && ['intact', 'hit'].includes(item.state) && this._propSurfaceContains(item, x, y));
          if (prop) return { kind: DATA.props[type].material === 'metal' ? 'dent' : 'chip', surface: 'prop', cellId: cell.id, propId: prop.id };
        }
      }

      const opening = config.opening;
      for (const cell of this.state.cells) {
        const rect = { x: cell.x + opening.dx, y: cell.y + opening.dy, w: opening.w, h: opening.h };
        if (!inRect(x, y, rect)) continue;
        const fw = opening.frameWidth;
        const frame = x <= rect.x + fw || x >= rect.x + rect.w - fw || y <= rect.y + fw || y >= rect.y + rect.h - fw;
        const mullion = x >= cell.x + opening.mullionDx && x <= cell.x + opening.mullionDx + opening.mullionWidth;
        if (frame || mullion) return { kind: 'dent', surface: 'frame', cellId: cell.id, propId: null };
        const window = cell.props.find(prop => prop.type === 'window' && prop.hp > 0 && ['intact', 'hit'].includes(prop.state) && this._windowGlassAt(prop, x, y));
        if (window) return { kind: 'glass', surface: 'prop', cellId: cell.id, propId: window.id };
        return null; // A shattered window exposes empty air, not a new wall surface.
      }
      const nearby = this.state.cells.find(cell => inRect(x, y, { x: cell.x - 12, y: cell.y - 15, w: cell.w + 24, h: cell.h + 25 }));
      return { kind: 'chip', surface: 'wall', cellId: nearby ? nearby.id : null, propId: null };
    }

    _recordBulletMarks(candidates, weapon, actorRects) {
      const style = DATA.weapons[weapon].marks;
      for (const candidate of candidates) {
        const { point, surface } = candidate;
        if (!surface || actorRects.some(rect => inRect(point.x, point.y, rect))) continue;
        if (surface.propId) {
          const prop = this._prop(surface.propId);
          if (!prop || prop.hp <= 0 || !['intact', 'hit'].includes(prop.state) || !this._propSurfaceContains(prop, point.x, point.y)) continue;
        }
        this.state.bulletMarks.push(Object.assign({
          id: this._id('mark'), x: point.x, y: point.y, weapon, shape: style.shape,
          size: style.size + this._random() * style.sizeJitter,
          rotation: this._random() * Math.PI * 2, variant: Math.floor(this._random() * 4)
        }, surface));
      }
      const overflow = this.state.bulletMarks.length - DATA.bulletMarks.maxCount;
      if (overflow > 0) this.state.bulletMarks.splice(0, overflow);
    }

    _tracer(x, y, weapon, impactPoints) {
      const state = this.state;
      for (const point of impactPoints || [{ x, y }]) state.tracers.push({ x1: 613, y1: 514, x2: point.x, y2: point.y, life: 0.09, maxLife: 0.09, weapon });
      if (state.tracers.length > DATA.tuning.maxTracers) state.tracers.splice(0, state.tracers.length - DATA.tuning.maxTracers);
    }

    _floater(x, y, text, color) {
      this.state.floaters.push({ x, y, text, color, life: 1.3, maxLife: 1.3 });
      if (this.state.floaters.length > DATA.tuning.maxFloaters) this.state.floaters.shift();
    }

    _particles(x, y, count, colors, speed, life, type) {
      const remaining = DATA.tuning.maxParticles - this.state.particles.length;
      for (let i = 0; i < Math.min(count, remaining); i++) {
        const angle = this._random() * Math.PI * 2;
        const velocity = speed * (0.25 + this._random() * 0.75);
        const particleLife = life * (0.6 + this._random() * 0.6);
        const special = type === 'fire' || type === 'smoke';
        this.state.particles.push({
          x, y, vx: Math.cos(angle) * velocity * (special ? 0.5 : 1),
          vy: special ? -velocity - 16 : Math.sin(angle) * velocity - speed * 0.35,
          life: particleLife, maxLife: particleLife, color: this._pick(colors),
          size: special ? 3 + this._random() * 6 : 2 + this._random() * 4,
          type: type || 'debris', rotation: this._random() * Math.PI
        });
      }
    }

    _updateEffects(dt) {
      this.state.particles.forEach(particle => {
        particle.life -= dt;
        particle.x += particle.vx * dt;
        particle.y += particle.vy * dt;
        if (particle.type === 'glassShard') particle.angle += particle.spin * dt;
        if (particle.type !== 'fire' && particle.type !== 'smoke') particle.vy += DATA.tuning.particleGravity * dt;
      });
      this.state.particles = this.state.particles.filter(particle => particle.life > 0);
      this.state.floaters.forEach(floater => { floater.life -= dt; floater.y -= dt * 21; });
      this.state.floaters = this.state.floaters.filter(floater => floater.life > 0);
      this.state.tracers.forEach(tracer => { tracer.life -= dt; });
      this.state.tracers = this.state.tracers.filter(tracer => tracer.life > 0);
    }
  }

  Danchi.Game = Game;
})(globalThis);
