dollhouse-pwa/src/main.js

1261 lines
34 KiB
JavaScript

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 HOUSE_SHELL_CROPS = {
roof: { x: 0, y: 0, w: 1122, h: 245 },
leftPost: { x: 24, y: 240, w: 76, h: 150 },
rightPost: { x: 1020, y: 240, w: 76, h: 150 },
beamLeft: { x: 72, y: 397, w: 400, h: 64 },
beamRight: { x: 638, y: 397, w: 408, h: 64 },
foundation: { x: 0, y: 1250, w: 1122, h: 152 }
}
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 `
<rect
x="${(ELEVATOR_W - ELEVATOR_OPENING_W) / 2}"
y="${y}"
width="${ELEVATOR_OPENING_W}"
height="${ELEVATOR_OPENING_H}"
rx="10"
fill="#fff3f8"
stroke="#c896ad"
stroke-width="3"
/>
<text
x="${ELEVATOR_W / 2}"
y="${centerY + 2}"
text-anchor="middle"
dominant-baseline="middle"
font-family="Arial, sans-serif"
font-size="28"
fill="#f28fb3"
>♥</text>
`
}).join('')
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${ELEVATOR_W}"
height="${shaftHeight}"
viewBox="0 0 ${ELEVATOR_W} ${shaftHeight}"
>
<rect
x="5"
y="2"
width="${ELEVATOR_W - 10}"
height="${shaftHeight - 4}"
rx="16"
fill="#dfe8f1"
stroke="#9aaabd"
stroke-width="5"
/>
<rect
x="16"
y="10"
width="${ELEVATOR_W - 32}"
height="${shaftHeight - 20}"
rx="12"
fill="#f7fbff"
stroke="#c6d1de"
stroke-width="3"
/>
${openings}
</svg>
`
}
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('house-shell-sheet', '/assets/house-shell-overlay.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)
})
Object.entries(HOUSE_SHELL_CROPS).forEach(([key, rect]) => {
cropTexture(this, 'house-shell-sheet', 'house-shell-' + 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.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.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 sideInset = Math.round(room.width * 0.20)
const wallTop = room.y + 9
const backTop = room.y + Math.max(14, Math.round(FLOOR_H * 0.08))
const horizonY = room.y + Math.round(FLOOR_H * 0.60)
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)
}
}
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)
}
buildHouseShell() {
const shellDepth = 7
const stackHeight = FLOOR_COUNT * FLOOR_H + (FLOOR_COUNT - 1) * FLOOR_GAP
const stackBottom = HOUSE_TOP + stackHeight
// Decorative roof/cap. It is intentionally compressed vertically so it
// fits above the responsive room stack without stealing playable space.
this.add.image(WIDTH / 2, HOUSE_TOP + 10, 'house-shell-roof')
.setOrigin(0.5, 1)
.setDisplaySize(1000, 96)
.setDepth(shellDepth)
// Continuous outer posts make the ten room openings read as one house.
this.add.image(LEFT_X - 17, HOUSE_TOP + stackHeight / 2, 'house-shell-leftPost')
.setDisplaySize(46, stackHeight + 14)
.setDepth(shellDepth)
this.add.image(
RIGHT_X + BANK_W + 17,
HOUSE_TOP + stackHeight / 2,
'house-shell-rightPost'
)
.setDisplaySize(46, stackHeight + 14)
.setDepth(shellDepth)
// Reuse the generated decorative floor beam at every storey boundary.
for (let floor = 0; floor < FLOOR_COUNT - 1; floor += 1) {
const beamY = floorY(floor) + FLOOR_H + FLOOR_GAP / 2
this.add.image(
LEFT_X + BANK_W / 2,
beamY,
'house-shell-beamLeft'
)
.setDisplaySize(BANK_W + 26, 28)
.setDepth(shellDepth)
this.add.image(
RIGHT_X + BANK_W / 2,
beamY,
'house-shell-beamRight'
)
.setDisplaySize(BANK_W + 26, 28)
.setDepth(shellDepth)
}
// Bottom plinth/foundation ties both banks and the lift together.
this.add.image(WIDTH / 2, stackBottom - 3, 'house-shell-foundation')
.setOrigin(0.5, 0)
.setDisplaySize(1000, 52)
.setDepth(shellDepth)
}
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.7.0',
game,
async resetAll() {
await clearGameState()
window.location.reload()
}
}