1649 lines
44 KiB
JavaScript
1649 lines
44 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 = 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()
|
||
}
|
||
}
|