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 = 960 // Match the Phaser logical height to the usable browser/PWA viewport. // This keeps the same 960-wide room geometry while allowing the five // floors to expand vertically on phones instead of letterboxing a 4:3 game. const VIEWPORT_W = Math.max( 320, Math.round(window.visualViewport?.width ?? window.innerWidth) ) const VIEWPORT_H = Math.max( 480, Math.round(window.visualViewport?.height ?? window.innerHeight) ) const HEIGHT = Math.max( 720, Math.min(1600, Math.round(WIDTH * VIEWPORT_H / VIEWPORT_W)) ) const HOUSE_TOP = 74 const BOTTOM_UI_H = 72 const HOUSE_BOTTOM_PAD = 28 const LEFT_X = 50 const BANK_W = 350 const ELEVATOR_GAP = 20 const ELEVATOR_W = 120 const ELEVATOR_X = LEFT_X + BANK_W + ELEVATOR_GAP const RIGHT_X = ELEVATOR_X + ELEVATOR_W + ELEVATOR_GAP const FLOOR_GAP = 8 const FLOOR_COUNT = 5 const FLOOR_H = Math.floor( ( HEIGHT - HOUSE_TOP - BOTTOM_UI_H - HOUSE_BOTTOM_PAD - FLOOR_GAP * (FLOOR_COUNT - 1) ) / FLOOR_COUNT ) const STATUS_Y = HEIGHT - 36 const ELEVATOR_CAR_W = 82 const ELEVATOR_CAR_H = Math.min(104, Math.max(74, Math.round(FLOOR_H * 0.36))) const ELEVATOR_OPENING_W = 72 const ELEVATOR_OPENING_H = Math.min(96, Math.max(68, Math.round(FLOOR_H * 0.34))) const PLAYER_DRAG_FOLLOW = 0.18 const PLAYER_DRAG_OFFSET_Y = 42 const ROOM_HOLD_MS = 520 const ROOM_HOLD_MOVE_TOLERANCE = 18 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 HOUSE_TOP + floor * (FLOOR_H + FLOOR_GAP) } function makeRoom(id, name, floor, side, color) { const x = side === 'left' ? LEFT_X : RIGHT_X const y = floorY(floor) return { id, name, floor, side, color, x, y, width: BANK_W, height: FLOOR_H, walkBand: { minY: y + Math.round(FLOOR_H * 0.62), maxY: y + FLOOR_H - 10, homeY: y + Math.round(FLOOR_H * 0.82) } } } const ROOMS = [ 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) { return room.y + Math.round(FLOOR_H / 2) } function elevatorEntry(room) { 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 = FLOOR_COUNT * FLOOR_H + (FLOOR_COUNT - 1) * FLOOR_GAP const openings = Array.from({ length: FLOOR_COUNT }, (_, floor) => { const centerY = floor * (FLOOR_H + FLOOR_GAP) + FLOOR_H / 2 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( '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.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 }) } drawHouse() { const graphics = this.add.graphics().setDepth(0) graphics.fillStyle(0xfffbfd, 1) graphics.fillRoundedRect( 28, HOUSE_TOP - 18, 904, FLOOR_COUNT * FLOOR_H + (FLOOR_COUNT - 1) * FLOOR_GAP + 36, 24 ) for (const room of ROOMS) { const skin = ROOM_SKINS[room.id] const inset = 7 const wallTop = room.y + 8 const wallBottom = room.walkBand.minY const wallHeight = Math.max(36, wallBottom - wallTop) const floorTop = wallBottom - 1 const floorHeight = room.y + room.height - floorTop - 7 // Base room backing gives clean rounded corners behind all textures. graphics.fillStyle(room.color, 1) graphics.fillRoundedRect(room.x, room.y, room.width, room.height, 14) // Wallpaper layer. const wallpaper = this.add.tileSprite( room.x + inset, wallTop, room.width - inset * 2, wallHeight, skin.wallpaper ) .setOrigin(0, 0) .setDepth(1) wallpaper.setTileScale(0.72, 0.72) // Separate floor layer so the character always stands on a readable surface. const floor = this.add.tileSprite( room.x + inset, floorTop, room.width - inset * 2, floorHeight, skin.floor ) .setOrigin(0, 0) .setDepth(2) floor.setTileScale(0.62, 0.62) // Shared architecture layer sits above both wallpaper and floor. this.add.image( room.x + room.width / 2, room.y + 10, 'architecture-ceilingTrim' ) .setDisplaySize(room.width - 12, 18) .setDepth(5) this.add.image( room.x + room.width / 2, floorTop, 'architecture-skirting' ) .setDisplaySize(room.width - 12, 14) .setDepth(5) this.add.image( room.x + room.width / 2, room.y + room.height - 7, 'architecture-floorEdge' ) .setDisplaySize(room.width - 10, 16) .setDepth(5) 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.72)', padding: { x: 6, y: 3 } }).setDepth(8) if (room.id === 'disco') { this.add.text(room.x + room.width - 48, room.y + 18, '♪ ✦', { fontSize: '18px', color: '#9b65c6' }).setDepth(8) } if (room.id === 'garage') { this.add.text(room.x + room.width - 44, room.y + 18, '🚗', { fontSize: '18px' }).setDepth(8) } } this.add.text( WIDTH / 2, 48, 'Drag her to walk • press & hold a room to ride the lift', { fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif', fontSize: '15px', color: '#6d4c61' } ).setOrigin(0.5).setDepth(100) } buildElevator() { const shaftHeight = FLOOR_COUNT * FLOOR_H + (FLOOR_COUNT - 1) * FLOOR_GAP this.add.image(ELEVATOR_X + ELEVATOR_W / 2, HOUSE_TOP, 'elevator-shaft') .setOrigin(0.5, 0) .setDisplaySize(ELEVATOR_W, shaftHeight) .setDepth(20) 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(892, 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(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 + '…') this.roomHoldTimer = this.time.delayedCall(ROOM_HOLD_MS, () => { 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 ) this.player.setScale(Phaser.Math.Linear(0.29, 0.34, 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 * 190 * delta / 1000, room.x + 26, room.x + room.width - 26 ) this.player.y = Phaser.Math.Clamp( this.player.y + dy * 72 * 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.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: WIDTH, height: HEIGHT }, input: { activePointers: 4 }, scene: [HouseScene] }) window.__DOLLHOUSE__ = { version: '0.5.0', game, async resetAll() { await clearGameState() window.location.reload() } }