/** * RoofFadeSystem — Phaser 3, per-building * * One instance per physical building. Each instance: * - Creates its own Image of the shared roof texture * - Clips it to this building's footprint via a geometry mask * so only THIS building's roof section fades — all others are unaffected * - Fades when the player walks under the building footprint (incl. halls) * - Stays closed on optional exclude rects (balconies, courtyards) * - Fades back in on exit */ const ROOF_TILE = 48; class RoofFadeSystem { /** * @param {Phaser.Scene} scene * @param {string} roofKey - preloaded texture key for the roof image * @param {number} worldW - world width in pixels (roof display size) * @param {number} worldH - world height in pixels * @param {number[]} visualRect - [tx, ty, tw, th] tile coords — roof mask footprint * @param {number[][]} triggerRects - [[tx,ty,tw,th], ...] areas that peel the roof * @param {object} [opts] * @param {number[][]} [opts.excludeRects] - outdoor slices that keep the roof visible * @param {number} [opts.tileSize=48] * @param {number} [opts.roofDepth=8] * @param {number} [opts.fadeSpeed=4.8] alpha/sec (~0.08/frame @ 60fps) */ constructor(scene, roofKey, worldW, worldH, visualRect, triggerRects, opts = {}) { const T = opts.tileSize ?? ROOF_TILE; this._fadeSpeed = opts.fadeSpeed ?? 4.8; this._alpha = 1.0; const toPx = ([zx, zy, zw, zh]) => ({ x: zx * T, y: zy * T, right: (zx + zw) * T, bottom: (zy + zh) * T, }); // Trigger rects in pixel coords for fast per-frame hit-testing. this._triggers = triggerRects.map(toPx); this._excludes = (opts.excludeRects || []).map(toPx); // One Image per building — same texture, independent alpha. this.roof = scene.add.image(0, 0, roofKey) .setOrigin(0, 0) .setDisplaySize(worldW, worldH) .setDepth(opts.roofDepth ?? 8); // Geometry mask clips the image to this building's footprint only. const [vx, vy, vw, vh] = visualRect; const clipG = scene.make.graphics({ add: false }); clipG.fillStyle(0xffffff); clipG.fillRect(vx * T, vy * T, vw * T, vh * T); this.roof.setMask(clipG.createGeometryMask()); } _inRect(px, py, r) { return px >= r.x && px < r.right && py >= r.y && py < r.bottom; } _isInside(px, py) { let underRoof = false; for (const t of this._triggers) { if (this._inRect(px, py, t)) { underRoof = true; break; } } if (!underRoof) return false; for (const e of this._excludes) { if (this._inRect(px, py, e)) return false; } return true; } /** * @param {number} px - player world-X pixels * @param {number} py - player world-Y pixels * @param {number} dt - delta time ms */ update(px, py, dt) { const target = this._isInside(px, py) ? 0.0 : 1.0; const step = (dt / 1000) * this._fadeSpeed; if (this._alpha < target) { this._alpha = Math.min(this._alpha + step, target); } else if (this._alpha > target) { this._alpha = Math.max(this._alpha - step, target); } if (this._alpha < 0.004) this._alpha = 0; if (this._alpha > 0.996) this._alpha = 1; this.roof.setAlpha(this._alpha); } get isFullyOpen() { return this._alpha === 0; } get isFullyClosed() { return this._alpha === 1; } destroy() { this.roof?.destroy(); } }