dollhouse-pwa/src/main.js

1649 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 96,
y: HOUSE_Y_OFFSET + 34,
width: 930,
height: 174
}
const ROOF_LIFT_STOP_Y = ROOF_ROOM_BOUNDS.y + ROOF_ROOM_BOUNDS.height - 28
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: 'Roof',
floor: -1,
side: 'center',
isRoof: true,
color: 0xf1e4ff,
x,
y,
width,
height,
walkBand: {
minY: y + Math.round(height * 0.70),
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 floorLiftStopY(slot) {
return slot.y + Math.round(slot.h * 0.84)
}
function elevatorStopY(room) {
if (room.isRoof) return ROOF_LIFT_STOP_Y
return room.walkBand.homeY
}
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(floorLiftStopY)
]
const openings = stopYs.map((stopY) => {
const centerY = stopY - ELEVATOR_SHAFT_TOP
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="26"
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="4"
y="1"
width="${ELEVATOR_W - 8}"
height="${shaftHeight - 2}"
rx="14"
fill="#f7fbff"
stroke="#9aaabd"
stroke-width="4"
/>
<rect
x="12"
y="8"
width="${ELEVATOR_W - 24}"
height="${shaftHeight - 16}"
rx="10"
fill="#fffafb"
stroke="#c6d1de"
stroke-width="2"
/>
${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('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.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)
}
}
}
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)
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) 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.24, 0.29, 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) 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 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.updateStatus(targetRoom, 'Entering ' + targetRoom.name + '…')
// Store where the player returns to in the dollhouse hub.
this.state.player.roomId = targetRoom.id
this.state.player.x = Math.round(targetEntry.x)
this.state.player.y = Math.round(targetEntry.y)
// Keep the live sprite on the saved hub position so shutdown persistence
// cannot overwrite the target with the elevator-car coordinates.
this.player.x = targetEntry.x
this.player.y = targetEntry.y
await saveGameState(this.state)
this.cameras.main.fadeOut(220, 255, 247, 251)
await this.delayAsync(240)
this.journeyActive = false
if (targetRoom.isRoof) {
this.scene.start('RoofScene', { roomId: targetRoom.id })
return
}
this.scene.start('RoomScene', { roomId: targetRoom.id })
}
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()
}
}
class PlayableLevelScene extends Phaser.Scene {
constructor(key) {
super(key)
this.playerDragging = false
this.playerDragTargetX = null
this.lastDragX = null
this.playerAnimMode = 'idle'
this.playerAnimFrame = 0
this.playerAnimClock = 0
this.walkBounds = null
this.groundY = 0
this.exiting = false
}
createLevelPlayer(x, groundY, bounds, scale = 0.66) {
this.walkBounds = bounds
this.groundY = groundY
this.player = this.add.image(x, groundY, 'player-idle-0')
.setOrigin(0.5, 1)
.setScale(scale)
.setDepth(300)
.setData('kind', 'level-player')
.setInteractive({ cursor: 'grab' })
this.input.setDraggable(this.player)
this.input.on('dragstart', (_pointer, object) => {
if (object !== this.player || this.exiting) return
this.playerDragging = true
this.lastDragX = object.x
this.animatePlayer('walk', 999)
})
this.input.on('drag', (pointer, object) => {
if (object !== this.player || this.exiting) return
const targetX = Phaser.Math.Clamp(
pointer.worldX,
this.walkBounds.left,
this.walkBounds.right
)
const previousX = this.lastDragX ?? this.player.x
if (Math.abs(targetX - previousX) > 1) {
this.player.setFlipX(targetX < previousX)
}
this.lastDragX = targetX
this.playerDragTargetX = targetX
})
this.input.on('dragend', (_pointer, object) => {
if (object !== this.player) return
this.playerDragging = false
this.lastDragX = null
this.playerDragTargetX = null
if (!this.exiting) this.animatePlayer('idle', 999)
})
this.cursors = this.input.keyboard.createCursorKeys()
this.keys = this.input.keyboard.addKeys('A,D')
}
configureSideScroller(worldWidth, worldHeight = HEIGHT) {
this.cameras.main.setBounds(0, 0, worldWidth, worldHeight)
this.cameras.main.startFollow(this.player, true, 0.12, 0.12)
this.cameras.main.setDeadzone(300, HEIGHT)
this.cameras.main.setFollowOffset(0, 0)
}
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)
}
addHouseExit(x, groundY, width = 136, height = 260) {
const y = groundY - height / 2
const door = this.add.rectangle(x, y, width, height, 0xfff6fa, 1)
.setStrokeStyle(6, 0xd094af)
.setDepth(80)
.setInteractive({ useHandCursor: true })
this.add.line(
x,
y - height * 0.18,
-width * 0.18,
0,
-width * 0.18,
height * 0.36,
0xc0809e,
1
).setLineWidth(4).setDepth(81)
this.add.line(
x,
y - height * 0.18,
width * 0.18,
0,
width * 0.18,
height * 0.36,
0xc0809e,
1
).setLineWidth(4).setDepth(81)
this.add.text(x, y, '♥', {
fontFamily: 'Arial',
fontSize: '34px',
color: '#c95b88',
fontStyle: 'bold'
}).setOrigin(0.5).setDepth(82)
const label = this.add.text(x, groundY + 34, 'House', {
fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif',
fontSize: '22px',
color: '#6d4c61',
backgroundColor: '#fff3f8',
padding: { x: 14, y: 8 }
}).setOrigin(0.5).setDepth(82).setInteractive({ useHandCursor: true })
const exit = () => this.returnToHouse()
door.on('pointerdown', exit)
label.on('pointerdown', exit)
}
addLevelHud(title, subtitle = 'Drag left or right to explore') {
this.add.text(42, 48, title, {
fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif',
fontSize: '42px',
color: '#684b5d',
fontStyle: 'bold',
backgroundColor: 'rgba(255,247,251,0.88)',
padding: { x: 14, y: 8 }
}).setScrollFactor(0).setDepth(1000)
this.add.text(54, 108, subtitle, {
fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif',
fontSize: '19px',
color: '#7d6574',
backgroundColor: 'rgba(255,247,251,0.82)',
padding: { x: 10, y: 6 }
}).setScrollFactor(0).setDepth(1000)
}
returnToHouse() {
if (this.exiting) return
this.exiting = true
this.playerDragging = false
this.playerDragTargetX = null
this.cameras.main.stopFollow()
this.cameras.main.fadeOut(180, 255, 247, 251)
this.time.delayedCall(200, () => {
this.scene.start('House')
})
}
update(_time, delta) {
if (!this.player || !this.walkBounds || this.exiting) return
if (this.playerDragging && this.playerDragTargetX !== null) {
const follow = 1 - Math.pow(1 - PLAYER_DRAG_FOLLOW, delta / 16.667)
this.player.x = Phaser.Math.Linear(
this.player.x,
this.playerDragTargetX,
follow
)
this.player.y = this.groundY
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 dx = Number(right) - Number(left)
this.animatePlayer(dx !== 0 ? 'walk' : 'idle', delta)
if (dx === 0) return
this.player.x = Phaser.Math.Clamp(
this.player.x + dx * 360 * delta / 1000,
this.walkBounds.left,
this.walkBounds.right
)
this.player.y = this.groundY
this.player.setFlipX(dx < 0)
}
}
class RoomScene extends PlayableLevelScene {
constructor() {
super('RoomScene')
this.roomId = 'living'
}
init(data) {
this.roomId = data?.roomId ?? 'living'
}
create() {
const room = ROOM_BY_ID.get(this.roomId) ?? ROOM_BY_ID.get('living')
const skin = ROOM_SKINS[room.id] ?? ROOM_SKINS.living
const worldWidth = 3100
const wallTop = 170
const floorTop = 1015
const floorBottom = 1515
const groundY = 1410
this.cameras.main.setBackgroundColor('#ccecff')
this.cameras.main.fadeIn(220, 255, 247, 251)
const graphics = this.add.graphics().setDepth(0)
// Long interior wall.
graphics.fillStyle(0xfffbfd, 1)
graphics.fillRect(0, 110, worldWidth, floorBottom - 110)
this.add.tileSprite(
worldWidth / 2,
wallTop + (floorTop - wallTop) / 2,
worldWidth,
floorTop - wallTop,
skin.wallpaper
).setDepth(1)
// Long floor strip.
this.add.tileSprite(
worldWidth / 2,
floorTop + (floorBottom - floorTop) / 2,
worldWidth,
floorBottom - floorTop,
skin.floor
).setDepth(2)
// Rear skirting and front floor edge run the whole stage.
this.add.image(worldWidth / 2, floorTop, 'architecture-skirting')
.setDisplaySize(worldWidth, 28)
.setDepth(6)
this.add.image(worldWidth / 2, floorBottom, 'architecture-floorEdge')
.setDisplaySize(worldWidth, 30)
.setDepth(6)
this.add.image(worldWidth / 2, wallTop, 'architecture-ceilingTrim')
.setDisplaySize(worldWidth, 32)
.setDepth(6)
// Break the long room into visible bays without blocking movement.
graphics.lineStyle(5, 0xd7b4c6, 0.42)
for (let x = 760; x < worldWidth; x += 760) {
graphics.lineBetween(x, wallTop + 20, x, floorTop - 10)
}
// Left/right end caps.
graphics.lineStyle(8, 0xd5b8c6, 1)
graphics.lineBetween(22, wallTop, 22, floorBottom)
graphics.lineBetween(worldWidth - 22, wallTop, worldWidth - 22, floorBottom)
// Elevator/House exit lives at the beginning of every room level.
this.addHouseExit(170, groundY)
// A couple of simple depth markers so scrolling reads immediately.
graphics.fillStyle(0xffffff, 0.30)
graphics.fillRoundedRect(1040, 760, 360, 190, 24)
graphics.fillRoundedRect(2150, 690, 420, 250, 24)
this.createLevelPlayer(
470,
groundY,
{ left: 110, right: worldWidth - 110 },
0.68
)
this.configureSideScroller(worldWidth)
this.addLevelHud(room.name)
// Start framed near the elevator rather than centred on the whole stage.
this.cameras.main.scrollX = 0
}
}
class RoofScene extends PlayableLevelScene {
constructor() {
super('RoofScene')
}
create() {
const worldWidth = 2900
const groundY = 1370
const roofTop = 830
const roofBottom = 1515
this.cameras.main.setBackgroundColor('#ccecff')
this.cameras.main.fadeIn(220, 255, 247, 251)
const graphics = this.add.graphics().setDepth(0)
// Long open sky.
graphics.fillStyle(0xccecff, 1)
graphics.fillRect(0, 0, worldWidth, HEIGHT)
// Repeat the approved pink roof language across a side-scrolling stage.
for (let x = 0; x < worldWidth; x += 1060) {
this.add.image(x + 530, 215, 'shell-roof')
.setOrigin(0.5, 0)
.setDisplaySize(1060, 228)
.setDepth(1)
}
graphics.fillStyle(0xf5a8c3, 1)
graphics.fillRect(0, roofTop, worldWidth, roofBottom - roofTop)
graphics.lineStyle(8, 0xb96f8e, 1)
graphics.lineBetween(0, roofTop, worldWidth, roofTop)
graphics.lineBetween(0, roofBottom, worldWidth, roofBottom)
// Subtle roof seams to give motion/parallax reference while walking.
graphics.lineStyle(4, 0xffd2df, 0.72)
for (let x = 320; x < worldWidth; x += 420) {
graphics.lineBetween(x, roofTop, x + 150, roofBottom)
}
this.addHouseExit(180, groundY)
this.createLevelPlayer(
500,
groundY,
{ left: 120, right: worldWidth - 120 },
0.64
)
this.configureSideScroller(worldWidth)
this.addLevelHud('Roof')
this.cameras.main.scrollX = 0
}
}
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.NO_CENTER,
width: WIDTH,
height: HEIGHT
},
input: {
activePointers: 4
},
scene: [HouseScene, RoomScene, RoofScene]
})
window.__DOLLHOUSE__ = {
version: '0.12.0',
game,
async resetAll() {
await clearGameState()
window.location.reload()
}
}