import Phaser from 'phaser' import { registerSW } from 'virtual:pwa-register' import { clearGameState, loadGameState, saveGameState } from './db.js' import './style.css' registerSW({ immediate: true, onNeedRefresh() { window.location.reload() } }) let installPrompt = null const installWrap = document.querySelector('#install-wrap') const installButton = document.querySelector('#install-button') window.addEventListener('beforeinstallprompt', (event) => { event.preventDefault() installPrompt = event installWrap.hidden = false }) installButton?.addEventListener('click', async () => { if (!installPrompt) return await installPrompt.prompt() installPrompt = null installWrap.hidden = true }) window.addEventListener('appinstalled', () => { installPrompt = null installWrap.hidden = true }) const WIDTH = 1122 const SHELL_HEIGHT = 1402 const HOUSE_Y_OFFSET = 120 const HEIGHT = 1580 // Native geometry measured from the approved 1122×1402 house shell. // Phaser fits this coordinate system to the device; the shell is no longer // stretched to a device-dependent room grid. const LEFT_X = 88 const BANK_W = 379 const RIGHT_X = 654 const TOWER_X = 438 const TOWER_W = 244 const TOWER_CENTER_X = TOWER_X + TOWER_W / 2 // Functional lift lives inside the wider decorative tower. const ELEVATOR_X = 507 const ELEVATOR_W = 108 const FLOOR_SLOTS = [ { y: 234 + HOUSE_Y_OFFSET, h: 179 }, { y: 451 + HOUSE_Y_OFFSET, h: 186 }, { y: 675 + HOUSE_Y_OFFSET, h: 184 }, { y: 896 + HOUSE_Y_OFFSET, h: 180 }, { y: 1112 + HOUSE_Y_OFFSET, h: 169 } ] const FLOOR_COUNT = FLOOR_SLOTS.length const SHELL_BEAM_Y = [432, 656, 877, 1094].map((y) => y + HOUSE_Y_OFFSET) const HOUSE_TOP = FLOOR_SLOTS[0].y const HOUSE_BOTTOM = FLOOR_SLOTS[FLOOR_COUNT - 1].y + FLOOR_SLOTS[FLOOR_COUNT - 1].h const STATUS_Y = 1538 // A true rooftop level above the shell, rather than a tiny room inside the gable. const ROOF_ROOM_BOUNDS = { x: 155, y: 42, width: 812, height: 168 } const ROOF_LIFT_STOP_Y = ROOF_ROOM_BOUNDS.y + ROOF_ROOM_BOUNDS.height - 24 const ELEVATOR_SHAFT_TOP = ROOF_LIFT_STOP_Y - 48 const ELEVATOR_CAR_W = 82 const ELEVATOR_CAR_H = 90 const ELEVATOR_OPENING_W = 78 const ELEVATOR_OPENING_H = 82 const PLAYER_DRAG_FOLLOW = 0.18 const PLAYER_DRAG_OFFSET_Y = 50 const ROOM_HOLD_MS = 520 const ROOM_HOLD_MOVE_TOLERANCE = 21 const PLAYER_STATE = { IDLE: 'idle', WALKING: 'walking', GOING_TO_LIFT: 'going-to-lift', ENTERING_LIFT: 'entering-lift', RIDING_LIFT: 'riding-lift', EXITING_LIFT: 'exiting-lift' } const PLAYER_IDLE_CROPS = [ { x: 145, y: 12, w: 199, h: 310 }, { x: 344, y: 12, w: 196, h: 310 }, { x: 538, y: 11, w: 197, h: 311 }, { x: 736, y: 11, w: 197, h: 311 } ] const PLAYER_WALK_CROPS = [ { x: 8, y: 324, w: 176, h: 294 }, { x: 183, y: 323, w: 181, h: 295 }, { x: 364, y: 323, w: 177, h: 294 }, { x: 543, y: 323, w: 179, h: 295 }, { x: 723, y: 324, w: 178, h: 294 }, { x: 899, y: 324, w: 176, h: 294 } ] const WALLPAPER_CROPS = { disco: { x: 32, y: 154, w: 236, h: 244 }, topBedroom: { x: 299, y: 154, w: 241, h: 244 }, bedroom: { x: 566, y: 154, w: 241, h: 244 }, study: { x: 833, y: 154, w: 241, h: 244 }, living: { x: 32, y: 522, w: 236, h: 255 }, playroom: { x: 299, y: 522, w: 241, h: 255 }, kitchen: { x: 566, y: 522, w: 241, h: 255 }, bathroom: { x: 833, y: 522, w: 241, h: 255 }, garage: { x: 81, y: 909, w: 294, h: 281 }, utility: { x: 411, y: 909, w: 294, h: 281 } } const FLOOR_CROPS = { lightWood: { x: 69, y: 126, w: 389, h: 350 }, warmWood: { x: 520, y: 126, w: 390, h: 350 }, blueTile: { x: 972, y: 126, w: 389, h: 350 }, neutralTile: { x: 69, y: 574, w: 389, h: 350 }, concrete: { x: 520, y: 574, w: 390, h: 350 }, disco: { x: 972, y: 574, w: 389, h: 350 } } const ARCHITECTURE_CROPS = { ceilingTrim: { x: 229, y: 52, w: 990, h: 78 }, skirting: { x: 239, y: 190, w: 972, h: 63 }, floorEdge: { x: 210, y: 306, w: 1007, h: 87 } } const ROOM_SKINS = { disco: { wallpaper: 'wallpaper-disco', floor: 'floor-disco' }, 'top-bedroom': { wallpaper: 'wallpaper-topBedroom', floor: 'floor-lightWood' }, bedroom: { wallpaper: 'wallpaper-bedroom', floor: 'floor-lightWood' }, study: { wallpaper: 'wallpaper-study', floor: 'floor-warmWood' }, living: { wallpaper: 'wallpaper-living', floor: 'floor-warmWood' }, playroom: { wallpaper: 'wallpaper-playroom', floor: 'floor-lightWood' }, kitchen: { wallpaper: 'wallpaper-kitchen', floor: 'floor-neutralTile' }, bathroom: { wallpaper: 'wallpaper-bathroom', floor: 'floor-blueTile' }, garage: { wallpaper: 'wallpaper-garage', floor: 'floor-concrete' }, utility: { wallpaper: 'wallpaper-utility', floor: 'floor-neutralTile' } } function floorY(floor) { return FLOOR_SLOTS[floor].y } function floorHeight(floor) { return FLOOR_SLOTS[floor].h } function makeRoom(id, name, floor, side, color) { const x = side === 'left' ? LEFT_X : RIGHT_X const y = floorY(floor) const height = floorHeight(floor) return { id, name, floor, side, color, x, y, width: BANK_W, height, walkBand: { minY: y + Math.round(height * 0.69), maxY: y + height - 10, homeY: y + Math.round(height * 0.84) } } } function makeRoofRoom() { const { x, y, width, height } = ROOF_ROOM_BOUNDS return { id: 'roof-garden', name: 'Rooftop Garden', floor: -1, side: 'center', isRoof: true, color: 0xf1e4ff, x, y, width, height, walkBand: { minY: y + Math.round(height * 0.68), maxY: y + height - 8, homeY: ROOF_LIFT_STOP_Y } } } const ROOMS = [ makeRoofRoom(), makeRoom('disco', 'Disco', 0, 'left', 0xf1e4ff), makeRoom('top-bedroom', 'Top Bedroom', 0, 'right', 0xffe8f1), makeRoom('bedroom', 'Bedroom', 1, 'left', 0xffeef4), makeRoom('study', 'Study', 1, 'right', 0xfff4df), makeRoom('living', 'Living Room', 2, 'left', 0xe9f5ff), makeRoom('playroom', 'Playroom', 2, 'right', 0xeee8ff), makeRoom('kitchen', 'Kitchen', 3, 'left', 0xe6f6ed), makeRoom('bathroom', 'Bathroom', 3, 'right', 0xe5f4ff), makeRoom('garage', 'Garage', 4, 'left', 0xe8eef5), makeRoom('utility', 'Utility', 4, 'right', 0xfff0df) const ROOM_BY_ID = new Map(ROOMS.map((room) => [room.id, room])) function elevatorStopY(room) { if (room.isRoof) return ROOF_LIFT_STOP_Y return room.y + Math.round(room.height / 2) } function elevatorEntry(room) { if (room.side === 'center') { return { x: TOWER_CENTER_X + 118, y: room.walkBand.homeY } } return { x: room.side === 'left' ? room.x + room.width - 24 : room.x + 24, y: room.walkBand.homeY } } const DEFAULT_STATE = { version: 3, player: { roomId: 'living', x: LEFT_X + 150, y: ROOM_BY_ID.get('living').walkBand.homeY } } function cloneDefaultState() { return JSON.parse(JSON.stringify(DEFAULT_STATE)) } function cropTexture(scene, sourceKey, key, rect) { if (scene.textures.exists(key)) return const source = scene.textures.get(sourceKey).getSourceImage() const canvas = document.createElement('canvas') canvas.width = rect.w canvas.height = rect.h const ctx = canvas.getContext('2d') ctx.drawImage(source, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.h) scene.textures.addCanvas(key, canvas) } function buildElevatorSvg() { const shaftHeight = HOUSE_BOTTOM - ELEVATOR_SHAFT_TOP const stopYs = [ ROOF_LIFT_STOP_Y, ...FLOOR_SLOTS.map((slot) => slot.y + slot.h / 2) ] const openings = stopYs.map((stopY) => { const centerY = stopY - ELEVATOR_SHAFT_TOP const y = centerY - ELEVATOR_OPENING_H / 2 return ` ♥ ` }).join('') return ` ${openings} ` } class HouseScene extends Phaser.Scene { constructor() { super('House') this.state = cloneDefaultState() this.playerState = PLAYER_STATE.IDLE this.playerDragging = false this.playerDragTarget = null this.latestPointer = null this.lastPlayerDragX = null this.playerAnimMode = 'idle' this.playerAnimFrame = 0 this.playerAnimClock = 0 this.journeyActive = false this.saveTimer = null this.roomZones = [] this.roomHoldTimer = null this.roomHoldRoom = null this.roomHoldStart = null this.roomHoldPointerId = null } preload() { this.load.image('character-sheet', '/assets/character-sheet.png') this.load.image('architecture-sheet', '/assets/architecture-sheet.png') this.load.image('wallpaper-sheet', '/assets/wallpaper-sheet.png') this.load.image('floor-sheet', '/assets/floor-sheet.png') this.load.image('shell-roof', '/assets/shell/roof_full.png') this.load.image('shell-tower', '/assets/shell/central_tower_full.png') this.load.image('shell-post-seg', '/assets/shell/outer_post_segment.png') this.load.image('shell-post-joint', '/assets/shell/outer_post_joint.png') this.load.image('shell-beam-left', '/assets/shell/floor_beam_left.png') this.load.image('shell-beam-right', '/assets/shell/floor_beam_right.png') this.load.image('shell-bracket-left', '/assets/shell/inner_bracket_left.png') this.load.image('shell-foundation-left', '/assets/shell/foundation_left.png') this.load.image('shell-foundation-right', '/assets/shell/foundation_right.png') this.load.image( 'elevator-shaft', 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(buildElevatorSvg()) ) } async create() { PLAYER_IDLE_CROPS.forEach((rect, index) => { cropTexture(this, 'character-sheet', 'player-idle-' + index, rect) }) PLAYER_WALK_CROPS.forEach((rect, index) => { cropTexture(this, 'character-sheet', 'player-walk-' + index, rect) }) Object.entries(WALLPAPER_CROPS).forEach(([key, rect]) => { cropTexture(this, 'wallpaper-sheet', 'wallpaper-' + key, rect) }) Object.entries(FLOOR_CROPS).forEach(([key, rect]) => { cropTexture(this, 'floor-sheet', 'floor-' + key, rect) }) Object.entries(ARCHITECTURE_CROPS).forEach(([key, rect]) => { cropTexture(this, 'architecture-sheet', 'architecture-' + key, rect) }) const saved = await loadGameState() this.state = saved?.version === 3 && ROOM_BY_ID.has(saved.player?.roomId) ? saved : cloneDefaultState() this.cameras.main.setBackgroundColor('#ccecff') this.drawHouse() this.buildHouseShell() this.drawRoofRoom() this.buildElevator() this.buildUi() this.spawnPlayer() this.buildRoomHoldTargets() this.bindInput() this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.persist()) window.addEventListener('pagehide', () => this.persist(), { once: true }) } createPolygonMask(points) { const shape = this.make.graphics({ add: false }) shape.fillStyle(0xffffff, 1) shape.beginPath() shape.moveTo(points[0].x, points[0].y) for (let i = 1; i < points.length; i += 1) { shape.lineTo(points[i].x, points[i].y) } shape.closePath() shape.fillPath() return shape.createGeometryMask() } addMaskedImage(room, texture, points, depth, tint = null) { const image = this.add.image( room.x + room.width / 2, room.y + room.height / 2, texture ) .setDisplaySize(room.width - 12, room.height - 12) .setDepth(depth) .setMask(this.createPolygonMask(points)) if (tint !== null) { image.setTint(tint) } return image } drawHouse() { const graphics = this.add.graphics().setDepth(0) graphics.fillStyle(0xfffbfd, 1) graphics.fillRect( 36, 220 + HOUSE_Y_OFFSET, 1050, HOUSE_BOTTOM - (220 + HOUSE_Y_OFFSET) ) for (const room of ROOMS) { if (room.isRoof) continue const skin = ROOM_SKINS[room.id] const inset = 7 const sideInset = Math.round(room.width * 0.20) const wallTop = room.y + 9 const backTop = room.y + Math.max(14, Math.round(room.height * 0.08)) const horizonY = room.y + Math.round(room.height * 0.70) const bottomY = room.y + room.height - 8 const left = room.x + inset const right = room.x + room.width - inset const backLeft = room.x + sideInset const backRight = room.x + room.width - sideInset graphics.fillStyle(room.color, 1) graphics.fillRoundedRect(room.x, room.y, room.width, room.height, 14) // Back wall: a single fitted image avoids TileSprite repeat seams. this.add.image( room.x + room.width / 2, backTop + (horizonY - backTop) / 2, skin.wallpaper ) .setDisplaySize( backRight - backLeft, Math.max(30, horizonY - backTop) ) .setDepth(1) // Left and right walls use the same source image, clipped into true // room-side polygons. Their lower edges meet the floor exactly. this.addMaskedImage( room, skin.wallpaper, [ { x: left, y: wallTop }, { x: backLeft, y: backTop }, { x: backLeft, y: horizonY }, { x: left, y: bottomY } ], 2, 0xffedf5 ) this.addMaskedImage( room, skin.wallpaper, [ { x: backRight, y: backTop }, { x: right, y: wallTop }, { x: right, y: bottomY }, { x: backRight, y: horizonY } ], 2, 0xf2f6ff ) // The floor fills the complete perspective trapezoid right to the // room's front corners, so no base-color wedges can peek through. this.addMaskedImage( room, skin.floor, [ { x: backLeft, y: horizonY }, { x: backRight, y: horizonY }, { x: right, y: bottomY }, { x: left, y: bottomY } ], 3 ) // Subtle depth/shadow where the rear wall meets the floor. graphics.fillStyle(0x6d4c61, 0.12) graphics.fillRect( backLeft, horizonY - 2, backRight - backLeft, 5 ) // Shared architecture strips. this.add.image( room.x + room.width / 2, backTop, 'architecture-ceilingTrim' ) .setDisplaySize(backRight - backLeft + 8, 16) .setDepth(6) this.add.image( room.x + room.width / 2, horizonY, 'architecture-skirting' ) .setDisplaySize(backRight - backLeft + 8, 13) .setDepth(6) this.add.image( room.x + room.width / 2, bottomY, 'architecture-floorEdge' ) .setDisplaySize(room.width - 10, 15) .setDepth(6) // Perspective edges/corners. graphics.lineStyle(3, 0xd2aabf, 0.95) graphics.lineBetween(backLeft, backTop, backLeft, horizonY) graphics.lineBetween(backRight, backTop, backRight, horizonY) graphics.lineBetween(left, wallTop, backLeft, backTop) graphics.lineBetween(right, wallTop, backRight, backTop) graphics.lineBetween(left, bottomY, backLeft, horizonY) graphics.lineBetween(right, bottomY, backRight, horizonY) graphics.lineStyle(4, 0xd5b8c6, 1) graphics.strokeRoundedRect(room.x, room.y, room.width, room.height, 14) this.add.text(room.x + 14, room.y + 16, room.name, { fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', fontSize: '16px', color: '#765466', fontStyle: 'bold', backgroundColor: 'rgba(255,255,255,0.80)', padding: { x: 6, y: 3 } }).setDepth(9) if (room.id === 'disco') { this.add.text(room.x + room.width - 48, room.y + 18, '♪ ✦', { fontSize: '18px', color: '#9b65c6' }).setDepth(9) } if (room.id === 'garage') { this.add.text(room.x + room.width - 44, room.y + 18, '🚗', { fontSize: '18px' }).setDepth(9) } } } drawRoofRoom() { const room = ROOM_BY_ID.get('roof-garden') const graphics = this.add.graphics().setDepth(30) const left = room.x const right = room.x + room.width const top = room.y const bottom = room.y + room.height const backLeft = left + 84 const backRight = right - 84 const horizonY = room.walkBand.minY // Open rooftop terrace. The transparent sky lets the pink roof remain // visible behind it while the green deck makes the playable level obvious. graphics.fillStyle(0xffffff, 0.72) graphics.fillRoundedRect(left, top, room.width, room.height, 24) graphics.fillStyle(0x94d985, 1) graphics.fillPoints([ new Phaser.Geom.Point(backLeft, horizonY), new Phaser.Geom.Point(backRight, horizonY), new Phaser.Geom.Point(right - 18, bottom - 8), new Phaser.Geom.Point(left + 18, bottom - 8) ], true) // Pink rooftop railing. graphics.lineStyle(6, 0xf3a7c1, 1) graphics.lineBetween(left + 24, horizonY - 22, right - 24, horizonY - 22) graphics.lineBetween(left + 28, horizonY - 22, left + 28, bottom - 10) graphics.lineBetween(right - 28, horizonY - 22, right - 28, bottom - 10) // Planters along the back edge. graphics.fillStyle(0xf2c08d, 1) graphics.fillRoundedRect(left + 56, horizonY - 27, 104, 22, 6) graphics.fillRoundedRect(right - 160, horizonY - 27, 104, 22, 6) const flowers = [ { x: left + 78, y: horizonY - 37, color: '#ff7fa9' }, { x: left + 104, y: horizonY - 43, color: '#ffd86a' }, { x: left + 132, y: horizonY - 36, color: '#b78cff' }, { x: right - 132, y: horizonY - 36, color: '#ff8f7e' }, { x: right - 104, y: horizonY - 44, color: '#ffd86a' }, { x: right - 78, y: horizonY - 38, color: '#b78cff' } ] flowers.forEach((flower) => { this.add.text(flower.x, flower.y, '✿', { fontFamily: 'Arial', fontSize: '23px', color: flower.color }).setOrigin(0.5).setDepth(32) }) // Bench on the left, keeping the lift landing clear in the centre/right. graphics.fillStyle(0xe3b47c, 1) graphics.fillRoundedRect(left + 218, horizonY - 12, 112, 12, 5) graphics.fillRect(left + 230, horizonY, 7, 20) graphics.fillRect(left + 309, horizonY, 7, 20) // Lift landing marker aligned with the real sixth elevator stop. graphics.fillStyle(0xfff2f7, 1) graphics.fillRoundedRect(TOWER_CENTER_X - 56, horizonY - 38, 112, 44, 10) graphics.lineStyle(4, 0xd99ab6, 1) graphics.strokeRoundedRect(TOWER_CENTER_X - 56, horizonY - 38, 112, 44, 10) graphics.lineStyle(4, 0xd5a4bd, 1) graphics.strokeRoundedRect(left, top, room.width, room.height, 24) this.add.text(left + 20, top + 14, 'Rooftop Garden', { fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', fontSize: '19px', color: '#56734e', fontStyle: 'bold', backgroundColor: 'rgba(255,255,255,0.90)', padding: { x: 8, y: 4 } }).setDepth(33) this.add.text(right - 22, top + 17, 'hold to visit', { fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', fontSize: '14px', color: '#8b6577', backgroundColor: 'rgba(255,255,255,0.86)', padding: { x: 6, y: 3 } }).setOrigin(1, 0).setDepth(33) } buildHouseShell() { const shellDepth = 7 const towerDepth = 8 this.add.image(WIDTH / 2, HOUSE_Y_OFFSET, 'shell-roof') .setOrigin(0.5, 0) .setDisplaySize(1122, 242) .setDepth(shellDepth) this.add.image(TOWER_CENTER_X, HOUSE_Y_OFFSET, 'shell-tower') .setOrigin(0.5, 0) .setDisplaySize(TOWER_W, SHELL_HEIGHT) .setDepth(towerDepth) const leftOuterX = 60.5 const rightOuterX = WIDTH - 60.5 for (let floor = 0; floor < FLOOR_COUNT; floor += 1) { const slot = FLOOR_SLOTS[floor] const segmentY = slot.y + slot.h / 2 this.add.image(leftOuterX, segmentY, 'shell-post-seg') .setDisplaySize(65, slot.h) .setDepth(shellDepth) this.add.image(rightOuterX, segmentY, 'shell-post-seg') .setDisplaySize(65, slot.h) .setFlipX(true) .setDepth(shellDepth) } for (let floor = 0; floor < SHELL_BEAM_Y.length; floor += 1) { const beamY = SHELL_BEAM_Y[floor] this.add.image(273, beamY, 'shell-beam-left') .setDisplaySize(410, 66) .setDepth(shellDepth + 1) this.add.image(843, beamY, 'shell-beam-right') .setDisplaySize(414, 66) .setDepth(shellDepth + 1) this.add.image(62.5, beamY - 12, 'shell-post-joint') .setDisplaySize(77, 55) .setDepth(shellDepth + 2) this.add.image(WIDTH - 62.5, beamY - 12, 'shell-post-joint') .setDisplaySize(77, 55) .setFlipX(true) .setDepth(shellDepth + 2) } this.add.image(0, 1240 + HOUSE_Y_OFFSET, 'shell-foundation-left') .setOrigin(0, 0) .setDisplaySize(462, 162) .setDepth(shellDepth + 1) this.add.image(660, 1240 + HOUSE_Y_OFFSET, 'shell-foundation-right') .setOrigin(0, 0) .setDisplaySize(462, 162) .setDepth(shellDepth + 1) } buildElevator() { const shaftHeight = HOUSE_BOTTOM - ELEVATOR_SHAFT_TOP this.add.image(ELEVATOR_X + ELEVATOR_W / 2, ELEVATOR_SHAFT_TOP, 'elevator-shaft') .setOrigin(0.5, 0) .setDisplaySize(ELEVATOR_W, shaftHeight) .setDepth(20) this.add.rectangle( ELEVATOR_X + ELEVATOR_W / 2, ROOF_LIFT_STOP_Y, ELEVATOR_W + 18, ELEVATOR_OPENING_H + 20, 0xfff4f8, 0.96 ) .setStrokeStyle(4, 0xd8a2b9) .setDepth(18) const currentRoom = this.currentRoom() const stopY = elevatorStopY(currentRoom) const cx = ELEVATOR_X + ELEVATOR_W / 2 this.elevatorCarBack = this.add.rectangle(cx, stopY, ELEVATOR_CAR_W, ELEVATOR_CAR_H, 0xfff8fb, 1) .setStrokeStyle(3, 0xb98ba2) .setDepth(190) const doorW = Math.floor((ELEVATOR_CAR_W - 6) / 2) this.elevatorDoorLeft = this.add.rectangle(cx - doorW / 2, stopY, doorW, ELEVATOR_CAR_H - 8, 0xf4bfd2, 1) .setStrokeStyle(2, 0xc17d9d) .setDepth(210) this.elevatorDoorRight = this.add.rectangle(cx + doorW / 2, stopY, doorW, ELEVATOR_CAR_H - 8, 0xf4bfd2, 1) .setStrokeStyle(2, 0xc17d9d) .setDepth(210) this.elevatorHeart = this.add.text(cx, stopY, '♥', { fontFamily: 'Arial', fontSize: '24px', color: '#c95b88', fontStyle: 'bold' }).setOrigin(0.5).setDepth(211) } buildUi() { this.statusText = this.add.text(WIDTH / 2, STATUS_Y, 'Living Room', { fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', fontSize: '18px', color: '#6d4c61', backgroundColor: '#fff3f8', padding: { x: 16, y: 8 } }).setOrigin(0.5).setDepth(1000) this.resetButton = this.add.text(1044, STATUS_Y, 'Reset', { fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', fontSize: '14px', color: '#7a526b', backgroundColor: '#ffffff', padding: { x: 10, y: 8 } }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setDepth(1000) this.resetButton.on('pointerdown', async () => { this.state = cloneDefaultState() await saveGameState(this.state) this.scene.restart() }) } spawnPlayer() { const room = this.currentRoom() this.state.player.x = Phaser.Math.Clamp( this.state.player.x, room.x + 30, room.x + room.width - 30 ) this.state.player.y = Phaser.Math.Clamp( this.state.player.y, room.walkBand.minY, room.walkBand.maxY ) this.player = this.add.image( this.state.player.x, this.state.player.y, 'player-idle-0' ) .setOrigin(0.5, 1) .setScale(0.32) .setDepth(300) .setData('kind', 'player') .setInteractive({ cursor: 'grab' }) this.input.setDraggable(this.player) this.updatePlayerDepthAndScale(room) this.updateStatus(room) } buildRoomHoldTargets() { this.roomHoldGraphics = this.add.graphics().setDepth(120) this.roomZones = ROOMS.map((room) => { const zone = this.add.zone( room.x + room.width / 2, room.y + room.height / 2, room.width, room.height ) .setDepth(room.isRoof ? 40 : 3) .setData('roomId', room.id) .setInteractive() zone.on('pointerdown', (pointer) => { this.startRoomHold(room, pointer) }) zone.on('pointermove', (pointer) => { if ( this.roomHoldPointerId !== pointer.id || !this.roomHoldStart ) return const moved = Phaser.Math.Distance.Between( this.roomHoldStart.x, this.roomHoldStart.y, pointer.x, pointer.y ) if (moved > ROOM_HOLD_MOVE_TOLERANCE) { this.cancelRoomHold() } }) zone.on('pointerup', () => this.cancelRoomHold()) zone.on('pointerout', () => this.cancelRoomHold()) return zone }) this.input.on('pointerup', (pointer) => { if (pointer.id === this.roomHoldPointerId) { this.cancelRoomHold() } }) } startRoomHold(room, pointer) { if ( this.journeyActive || room.id === this.state.player.roomId ) return this.cancelRoomHold(false) this.roomHoldRoom = room this.roomHoldPointerId = pointer.id this.roomHoldStart = { x: pointer.x, y: pointer.y } this.roomHoldGraphics.clear() this.roomHoldGraphics.lineStyle(6, 0xf28fb3, 1) this.roomHoldGraphics.strokeRoundedRect( room.x + 3, room.y + 3, room.width - 6, room.height - 6, 12 ) this.updateStatus(this.currentRoom(), 'Hold for ' + room.name + '…') const holdMs = room.isRoof ? 360 : ROOM_HOLD_MS this.roomHoldTimer = this.time.delayedCall(holdMs, () => { if ( this.roomHoldRoom?.id !== room.id || this.journeyActive ) return this.roomHoldTimer = null this.roomHoldRoom = null this.roomHoldStart = null this.roomHoldPointerId = null this.roomHoldGraphics.clear() this.beginElevatorJourney(room) }) } cancelRoomHold(resetStatus = true) { if (this.roomHoldTimer) { this.roomHoldTimer.remove(false) this.roomHoldTimer = null } this.roomHoldRoom = null this.roomHoldStart = null this.roomHoldPointerId = null this.roomHoldGraphics?.clear() if (resetStatus && !this.journeyActive && this.statusText) { this.updateStatus(this.currentRoom()) } } bindInput() { this.cursors = this.input.keyboard.createCursorKeys() this.keys = this.input.keyboard.addKeys('W,A,S,D') this.input.on('dragstart', (pointer, object) => { if (object.getData('kind') !== 'player' || this.journeyActive) return this.playerDragging = true this.lastPlayerDragX = object.x this.latestPointer = { x: pointer.x, y: pointer.y } this.playerState = PLAYER_STATE.WALKING this.animatePlayer('walk', 999) }) this.input.on('drag', (pointer, object) => { if (object.getData('kind') !== 'player') return this.latestPointer = { x: pointer.x, y: pointer.y } if (this.journeyActive) return const targetRoom = this.getRoomAt(pointer.x, pointer.y) const currentRoom = this.currentRoom() // Dragging the character is movement-only. Crossing into another // room never triggers travel; room changes require a deliberate hold. if (!targetRoom || targetRoom.id !== currentRoom.id) return const targetX = Phaser.Math.Clamp( pointer.x, currentRoom.x + 26, currentRoom.x + currentRoom.width - 26 ) const targetY = Phaser.Math.Clamp( pointer.y + PLAYER_DRAG_OFFSET_Y, currentRoom.walkBand.minY, currentRoom.walkBand.maxY ) const previousX = this.lastPlayerDragX ?? this.player.x if (Math.abs(targetX - previousX) > 1) { this.player.setFlipX(targetX < previousX) } this.lastPlayerDragX = targetX this.playerDragTarget = { x: targetX, y: targetY } }) this.input.on('dragend', (_pointer, object) => { if (object.getData('kind') !== 'player') return this.playerDragging = false this.lastPlayerDragX = null if (this.journeyActive) return this.settlePlayer() }) } currentRoom() { return ROOM_BY_ID.get(this.state.player.roomId) ?? ROOM_BY_ID.get('living') } getRoomAt(x, y) { return ROOMS.find((room) => ( x >= room.x && x <= room.x + room.width && y >= room.y && y <= room.y + room.height )) ?? null } updateStatus(room, message = null) { this.statusText.setText(message ?? room.name) } animatePlayer(mode, delta) { if (!this.player) return const frameCount = mode === 'walk' ? PLAYER_WALK_CROPS.length : PLAYER_IDLE_CROPS.length const frameMs = mode === 'walk' ? 105 : 420 if (this.playerAnimMode !== mode) { this.playerAnimMode = mode this.playerAnimFrame = 0 this.playerAnimClock = 0 this.player.setTexture('player-' + mode + '-0') return } this.playerAnimClock += delta if (this.playerAnimClock < frameMs) return this.playerAnimClock %= frameMs this.playerAnimFrame = (this.playerAnimFrame + 1) % frameCount this.player.setTexture('player-' + mode + '-' + this.playerAnimFrame) } updatePlayerDepthAndScale(room) { const t = Phaser.Math.Clamp( (this.player.y - room.walkBand.minY) / (room.walkBand.maxY - room.walkBand.minY), 0, 1 ) if (room.isRoof) { this.player.setScale(Phaser.Math.Linear(0.20, 0.24, t)) this.player.setDepth(299) return } this.player.setScale(Phaser.Math.Linear(0.34, 0.40, t)) this.player.setDepth(300 + room.floor) } tweenAsync(config) { return new Promise((resolve) => { this.tweens.add({ ...config, onComplete: resolve }) }) } delayAsync(ms) { return new Promise((resolve) => { this.time.delayedCall(ms, resolve) }) } async setElevatorDoors(open) { const cx = ELEVATOR_X + ELEVATOR_W / 2 const doorW = Math.floor((ELEVATOR_CAR_W - 6) / 2) const closedOffset = doorW / 2 const openOffset = ELEVATOR_CAR_W / 2 + doorW / 2 + 4 const leftX = open ? cx - openOffset : cx - closedOffset const rightX = open ? cx + openOffset : cx + closedOffset await Promise.all([ this.tweenAsync({ targets: this.elevatorDoorLeft, x: leftX, duration: 170, ease: 'Sine.easeOut' }), this.tweenAsync({ targets: this.elevatorDoorRight, x: rightX, duration: 170, ease: 'Sine.easeOut' }), this.tweenAsync({ targets: this.elevatorHeart, alpha: open ? 0 : 1, duration: 120 }) ]) } setElevatorY(y) { this.elevatorCarBack.y = y this.elevatorDoorLeft.y = y this.elevatorDoorRight.y = y this.elevatorHeart.y = y } async beginElevatorJourney(targetRoom) { if (this.journeyActive || targetRoom.id === this.state.player.roomId) return const fromRoom = this.currentRoom() const fromEntry = elevatorEntry(fromRoom) const targetEntry = elevatorEntry(targetRoom) const carX = ELEVATOR_X + ELEVATOR_W / 2 this.journeyActive = true this.playerDragTarget = null this.playerState = PLAYER_STATE.GOING_TO_LIFT this.updateStatus(fromRoom, 'Going to lift…') this.animatePlayer('walk', 999) const toLiftDuration = Phaser.Math.Clamp( Phaser.Math.Distance.Between(this.player.x, this.player.y, fromEntry.x, fromEntry.y) * 2.0, 220, 760 ) await this.tweenAsync({ targets: this.player, x: fromEntry.x, y: fromEntry.y, duration: toLiftDuration, ease: 'Sine.easeInOut', onUpdate: () => { this.updatePlayerDepthAndScale(fromRoom) } }) this.setElevatorY(elevatorStopY(fromRoom)) await this.setElevatorDoors(true) this.playerState = PLAYER_STATE.ENTERING_LIFT await this.tweenAsync({ targets: this.player, x: carX, y: fromRoom.walkBand.homeY, duration: 220, ease: 'Sine.easeInOut' }) this.player.setDepth(200) this.animatePlayer('idle', 999) await this.setElevatorDoors(false) this.playerState = PLAYER_STATE.RIDING_LIFT this.updateStatus(fromRoom, 'Riding to ' + targetRoom.name + '…') const fromStop = elevatorStopY(fromRoom) const toStop = elevatorStopY(targetRoom) const floorDistance = Math.abs(targetRoom.floor - fromRoom.floor) if (floorDistance === 0) { await this.delayAsync(340) } else { const rideDuration = 280 + floorDistance * 210 await Promise.all([ this.tweenAsync({ targets: [ this.elevatorCarBack, this.elevatorDoorLeft, this.elevatorDoorRight, this.elevatorHeart ], y: toStop, duration: rideDuration, ease: 'Sine.easeInOut' }), this.tweenAsync({ targets: this.player, y: targetRoom.walkBand.homeY, duration: rideDuration, ease: 'Sine.easeInOut' }) ]) } await this.setElevatorDoors(true) this.playerState = PLAYER_STATE.EXITING_LIFT this.animatePlayer('walk', 999) await this.tweenAsync({ targets: this.player, x: targetEntry.x, y: targetEntry.y, duration: 240, ease: 'Sine.easeOut' }) this.state.player.roomId = targetRoom.id this.state.player.x = Math.round(this.player.x) this.state.player.y = Math.round(this.player.y) this.updatePlayerDepthAndScale(targetRoom) await this.setElevatorDoors(false) this.journeyActive = false this.playerState = PLAYER_STATE.IDLE this.updateStatus(targetRoom) const pointerRoom = this.latestPointer ? this.getRoomAt(this.latestPointer.x, this.latestPointer.y) : null if (this.playerDragging && pointerRoom?.id === targetRoom.id) { const targetX = Phaser.Math.Clamp( this.latestPointer.x, targetRoom.x + 26, targetRoom.x + targetRoom.width - 26 ) const targetY = Phaser.Math.Clamp( this.latestPointer.y + PLAYER_DRAG_OFFSET_Y, targetRoom.walkBand.minY, targetRoom.walkBand.maxY ) this.playerDragTarget = { x: targetX, y: targetY } this.playerState = PLAYER_STATE.WALKING this.animatePlayer('walk', 999) } else { this.playerDragTarget = null this.animatePlayer('idle', 999) } this.scheduleSave() } settlePlayer() { const room = this.currentRoom() const target = this.playerDragTarget ?? { x: this.player.x, y: this.player.y } this.tweens.add({ targets: this.player, x: Phaser.Math.Clamp(target.x, room.x + 26, room.x + room.width - 26), y: Phaser.Math.Clamp(target.y, room.walkBand.minY, room.walkBand.maxY), duration: 110, ease: 'Quad.out', onUpdate: () => this.updatePlayerDepthAndScale(room), onComplete: () => { this.playerDragTarget = null this.playerState = PLAYER_STATE.IDLE this.animatePlayer('idle', 999) this.capturePlayer() this.scheduleSave() } }) } capturePlayer() { if (!this.player) return this.state.player.x = Math.round(this.player.x) this.state.player.y = Math.round(this.player.y) } scheduleSave() { clearTimeout(this.saveTimer) this.saveTimer = setTimeout(() => this.persist(), 180) } async persist() { this.capturePlayer() await saveGameState(this.state) } update(_time, delta) { if (!this.player) return if (this.journeyActive) { if ( this.playerState === PLAYER_STATE.GOING_TO_LIFT || this.playerState === PLAYER_STATE.ENTERING_LIFT || this.playerState === PLAYER_STATE.EXITING_LIFT ) { this.animatePlayer('walk', delta) } else { this.animatePlayer('idle', delta) } return } const room = this.currentRoom() if (this.playerDragging && this.playerDragTarget) { const follow = 1 - Math.pow(1 - PLAYER_DRAG_FOLLOW, delta / 16.667) this.player.x = Phaser.Math.Linear( this.player.x, this.playerDragTarget.x, follow ) this.player.y = Phaser.Math.Linear( this.player.y, this.playerDragTarget.y, follow ) this.updatePlayerDepthAndScale(room) this.capturePlayer() this.animatePlayer('walk', delta) return } const left = this.cursors.left.isDown || this.keys.A.isDown const right = this.cursors.right.isDown || this.keys.D.isDown const up = this.cursors.up.isDown || this.keys.W.isDown const down = this.cursors.down.isDown || this.keys.S.isDown let dx = Number(right) - Number(left) let dy = Number(down) - Number(up) const isMoving = dx !== 0 || dy !== 0 this.animatePlayer(isMoving ? 'walk' : 'idle', delta) if (!isMoving) return const length = Math.hypot(dx, dy) || 1 dx /= length dy /= length this.player.x = Phaser.Math.Clamp( this.player.x + dx * 220 * delta / 1000, room.x + 26, room.x + room.width - 26 ) this.player.y = Phaser.Math.Clamp( this.player.y + dy * 84 * delta / 1000, room.walkBand.minY, room.walkBand.maxY ) if (Math.abs(dx) > 0.05) this.player.setFlipX(dx < 0) this.updatePlayerDepthAndScale(room) this.capturePlayer() this.scheduleSave() } } const game = new Phaser.Game({ type: Phaser.AUTO, parent: 'game', width: WIDTH, height: HEIGHT, backgroundColor: '#ccecff', render: { antialias: true, pixelArt: false }, scale: { mode: Phaser.Scale.NONE, autoCenter: Phaser.Scale.NO_CENTER, width: WIDTH, height: HEIGHT }, input: { activePointers: 4 }, scene: [HouseScene] }) window.__DOLLHOUSE__ = { version: '0.10.2', game, async resetAll() { await clearGameState() window.location.reload() } }