Build Phaser PWA prototype with IndexedDB saves
This commit is contained in:
parent
fe15916a79
commit
c8fe90bb47
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD wget -qO- http://127.0.0.1/health || exit 1
|
||||||
30
README.md
30
README.md
@ -1,3 +1,29 @@
|
|||||||
# dollhouse-pwa
|
# My Dollhouse
|
||||||
|
|
||||||
Phaser.js PWA dollhouse game with IndexedDB local saves and auto-updating service worker.
|
A small Phaser.js PWA built around the dollhouse concept art created in ChatGPT.
|
||||||
|
|
||||||
|
## Current prototype
|
||||||
|
|
||||||
|
- Four playable rooms: bedroom, bathroom, kitchen and living room
|
||||||
|
- Keyboard and touch movement
|
||||||
|
- Furniture/prop dragging in Decorate mode
|
||||||
|
- Add/reset room controls
|
||||||
|
- Room layout and player position persisted in IndexedDB
|
||||||
|
- Offline-capable PWA
|
||||||
|
- Service worker configured to take new versions automatically
|
||||||
|
- Runtime crops extracted from the production concept sheet so gameplay code is independent from future final sprite atlases
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Dockerfile builds the Vite app and serves it with nginx on port 80. `/health` is provided for Coolify health checks.
|
||||||
|
|||||||
24
index.html
Normal file
24
index.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#f7a9c4" />
|
||||||
|
<meta name="description" content="A cozy little offline dollhouse game." />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="My Dollhouse" />
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||||
|
<title>My Dollhouse</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app">
|
||||||
|
<div id="game"></div>
|
||||||
|
<div id="install-wrap" hidden>
|
||||||
|
<button id="install-button" type="button">Install Dollhouse</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
nginx.conf
Normal file
32
nginx.conf
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location = /health {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sw.js {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:js|css|png|svg|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "dollhouse-pwa",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"phaser": "^3.90.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-pwa": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
public/icon.svg
Normal file
9
public/icon.svg
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#fff2f7"/>
|
||||||
|
<path d="M94 246 256 98l162 148v170a30 30 0 0 1-30 30H124a30 30 0 0 1-30-30V246Z" fill="#f6a4c1" stroke="#8b4b6b" stroke-width="18" stroke-linejoin="round"/>
|
||||||
|
<path d="M70 252 256 80l186 172" fill="none" stroke="#e65d94" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="205" y="292" width="102" height="154" rx="24" fill="#9ddad1" stroke="#8b4b6b" stroke-width="14"/>
|
||||||
|
<circle cx="281" cy="368" r="9" fill="#8b4b6b"/>
|
||||||
|
<path d="M180 230h152" stroke="#fff" stroke-width="15" stroke-linecap="round"/>
|
||||||
|
<path d="M256 178v104" stroke="#fff" stroke-width="15" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
66
src/db.js
Normal file
66
src/db.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
const DB_NAME = 'my-dollhouse'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE = 'state'
|
||||||
|
const ROOT_KEY = 'game'
|
||||||
|
|
||||||
|
function openDb() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE)
|
||||||
|
}
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGameState() {
|
||||||
|
try {
|
||||||
|
const db = await openDb()
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readonly')
|
||||||
|
const request = tx.objectStore(STORE).get(ROOT_KEY)
|
||||||
|
request.onsuccess = () => resolve(request.result ?? null)
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
tx.oncomplete = () => db.close()
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('IndexedDB load failed; using defaults.', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGameState(state) {
|
||||||
|
try {
|
||||||
|
const db = await openDb()
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite')
|
||||||
|
tx.objectStore(STORE).put(structuredClone(state), ROOT_KEY)
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('IndexedDB save failed.', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearGameState() {
|
||||||
|
try {
|
||||||
|
const db = await openDb()
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite')
|
||||||
|
tx.objectStore(STORE).delete(ROOT_KEY)
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('IndexedDB reset failed.', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
479
src/main.js
Normal file
479
src/main.js
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
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
|
||||||
|
const HEIGHT = 640
|
||||||
|
const ROOM_X = 30
|
||||||
|
const ROOM_Y = 78
|
||||||
|
const ROOM_W = 900
|
||||||
|
const ROOM_H = 470
|
||||||
|
const FLOOR_TOP = 385
|
||||||
|
const FLOOR_BOTTOM = 525
|
||||||
|
|
||||||
|
const ROOM_CROPS = {
|
||||||
|
bedroom: { x: 15, y: 425, w: 248, h: 182 },
|
||||||
|
bathroom: { x: 270, y: 425, w: 241, h: 182 },
|
||||||
|
kitchen: { x: 521, y: 425, w: 239, h: 182 },
|
||||||
|
living: { x: 774, y: 425, w: 241, h: 182 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSET_CROPS = {
|
||||||
|
player: { x: 17, y: 128, w: 63, h: 102 },
|
||||||
|
bed: { x: 15, y: 671, w: 79, h: 113 },
|
||||||
|
couch: { x: 540, y: 675, w: 101, h: 99 },
|
||||||
|
armchair: { x: 644, y: 680, w: 67, h: 91 },
|
||||||
|
table: { x: 708, y: 694, w: 68, h: 78 },
|
||||||
|
bookcase: { x: 847, y: 675, w: 61, h: 106 },
|
||||||
|
teddy: { x: 16, y: 839, w: 49, h: 53 },
|
||||||
|
ball: { x: 224, y: 842, w: 46, h: 49 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOM_LABELS = {
|
||||||
|
bedroom: 'Bedroom',
|
||||||
|
bathroom: 'Bathroom',
|
||||||
|
kitchen: 'Kitchen',
|
||||||
|
living: 'Living Room'
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE = {
|
||||||
|
version: 1,
|
||||||
|
activeRoom: 'bedroom',
|
||||||
|
player: { x: 480, y: 492 },
|
||||||
|
rooms: {
|
||||||
|
bedroom: [
|
||||||
|
{ id: 'bed-1', type: 'bed', x: 260, y: 460, scale: 1.15 },
|
||||||
|
{ id: 'bookcase-1', type: 'bookcase', x: 745, y: 445, scale: 1.05 },
|
||||||
|
{ id: 'teddy-1', type: 'teddy', x: 600, y: 500, scale: 1.15 }
|
||||||
|
],
|
||||||
|
bathroom: [
|
||||||
|
{ id: 'ball-1', type: 'ball', x: 700, y: 500, scale: 1.15 }
|
||||||
|
],
|
||||||
|
kitchen: [
|
||||||
|
{ id: 'table-1', type: 'table', x: 575, y: 470, scale: 1.2 }
|
||||||
|
],
|
||||||
|
living: [
|
||||||
|
{ id: 'couch-1', type: 'couch', x: 310, y: 465, scale: 1.15 },
|
||||||
|
{ id: 'armchair-1', type: 'armchair', x: 650, y: 475, scale: 1.15 },
|
||||||
|
{ id: 'table-2', type: 'table', x: 480, y: 500, scale: 1.0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneDefaultState() {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULT_STATE))
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedButton(scene, x, y, width, height, label, onClick, options = {}) {
|
||||||
|
const bg = scene.add.rectangle(x, y, width, height, options.fill ?? 0xffeef5, 1)
|
||||||
|
.setStrokeStyle(2, options.stroke ?? 0xe790b3)
|
||||||
|
.setInteractive({ useHandCursor: true })
|
||||||
|
.setDepth(500)
|
||||||
|
const text = scene.add.text(x, y, label, {
|
||||||
|
fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif',
|
||||||
|
fontSize: options.fontSize ?? '17px',
|
||||||
|
color: options.color ?? '#6b3b59',
|
||||||
|
fontStyle: 'bold'
|
||||||
|
}).setOrigin(0.5).setDepth(501)
|
||||||
|
bg.on('pointerdown', () => {
|
||||||
|
scene.tweens.add({ targets: [bg, text], scaleX: 0.95, scaleY: 0.95, duration: 55, yoyo: true })
|
||||||
|
onClick()
|
||||||
|
})
|
||||||
|
return { bg, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCropTexture(scene, key, rect, removeBackground = false) {
|
||||||
|
if (scene.textures.exists(key)) return
|
||||||
|
const source = scene.textures.get('production').getSourceImage()
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = rect.w
|
||||||
|
canvas.height = rect.h
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: removeBackground })
|
||||||
|
ctx.drawImage(source, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.h)
|
||||||
|
|
||||||
|
if (removeBackground) {
|
||||||
|
const imageData = ctx.getImageData(0, 0, rect.w, rect.h)
|
||||||
|
const data = imageData.data
|
||||||
|
const w = rect.w
|
||||||
|
const h = rect.h
|
||||||
|
const seen = new Uint8Array(w * h)
|
||||||
|
const stack = []
|
||||||
|
|
||||||
|
const candidate = (idx) => {
|
||||||
|
const i = idx * 4
|
||||||
|
const r = data[i]
|
||||||
|
const g = data[i + 1]
|
||||||
|
const b = data[i + 2]
|
||||||
|
const max = Math.max(r, g, b)
|
||||||
|
const min = Math.min(r, g, b)
|
||||||
|
return max >= 206 && max - min <= 48
|
||||||
|
}
|
||||||
|
|
||||||
|
const push = (x, y) => {
|
||||||
|
if (x < 0 || y < 0 || x >= w || y >= h) return
|
||||||
|
const idx = y * w + x
|
||||||
|
if (seen[idx] || !candidate(idx)) return
|
||||||
|
seen[idx] = 1
|
||||||
|
stack.push(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
push(x, 0)
|
||||||
|
push(x, h - 1)
|
||||||
|
}
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
push(0, y)
|
||||||
|
push(w - 1, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
const idx = stack.pop()
|
||||||
|
data[idx * 4 + 3] = 0
|
||||||
|
const x = idx % w
|
||||||
|
const y = Math.floor(idx / w)
|
||||||
|
push(x + 1, y)
|
||||||
|
push(x - 1, y)
|
||||||
|
push(x, y + 1)
|
||||||
|
push(x, y - 1)
|
||||||
|
}
|
||||||
|
ctx.putImageData(imageData, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.textures.addCanvas(key, canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
class HouseScene extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super('House')
|
||||||
|
this.state = cloneDefaultState()
|
||||||
|
this.decorateMode = false
|
||||||
|
this.roomObjects = []
|
||||||
|
this.roomButtons = []
|
||||||
|
this.moveIntent = { left: false, right: false, up: false, down: false }
|
||||||
|
this.saveTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
preload() {
|
||||||
|
this.load.image('production', '/assets/production-sheet.png')
|
||||||
|
}
|
||||||
|
|
||||||
|
async create() {
|
||||||
|
Object.entries(ROOM_CROPS).forEach(([key, rect]) => makeCropTexture(this, 'room-' + key, rect))
|
||||||
|
Object.entries(ASSET_CROPS).forEach(([key, rect]) => makeCropTexture(this, key, rect, true))
|
||||||
|
|
||||||
|
const saved = await loadGameState()
|
||||||
|
if (saved?.version === 1) this.state = saved
|
||||||
|
|
||||||
|
this.cameras.main.setBackgroundColor('#fff4f9')
|
||||||
|
this.buildChrome()
|
||||||
|
this.buildControls()
|
||||||
|
this.bindInput()
|
||||||
|
this.renderRoom()
|
||||||
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.persist())
|
||||||
|
window.addEventListener('pagehide', () => this.persist(), { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
buildChrome() {
|
||||||
|
this.add.rectangle(480, 32, 960, 64, 0xffe7f0).setDepth(450)
|
||||||
|
this.add.text(24, 17, 'My Dollhouse', {
|
||||||
|
fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif',
|
||||||
|
fontSize: '28px',
|
||||||
|
color: '#c84f82',
|
||||||
|
fontStyle: 'bold'
|
||||||
|
}).setDepth(500)
|
||||||
|
|
||||||
|
;['bedroom', 'bathroom', 'kitchen', 'living'].forEach((room, index) => {
|
||||||
|
const button = roundedButton(
|
||||||
|
this,
|
||||||
|
400 + index * 132,
|
||||||
|
32,
|
||||||
|
120,
|
||||||
|
38,
|
||||||
|
ROOM_LABELS[room],
|
||||||
|
() => this.switchRoom(room),
|
||||||
|
{ fontSize: '14px' }
|
||||||
|
)
|
||||||
|
button.room = room
|
||||||
|
this.roomButtons.push(button)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.modeButton = roundedButton(this, 820, 588, 220, 52, '✨ Decorate', () => {
|
||||||
|
this.decorateMode = !this.decorateMode
|
||||||
|
this.updateMode()
|
||||||
|
}, { fill: 0xf9bfd3, stroke: 0xdc6d9b })
|
||||||
|
|
||||||
|
this.addButton = roundedButton(this, 594, 588, 190, 52, '+ Add something', () => this.addSomething(), {
|
||||||
|
fontSize: '15px'
|
||||||
|
})
|
||||||
|
this.resetButton = roundedButton(this, 480, 588, 82, 52, 'Reset', () => this.resetRoom(), {
|
||||||
|
fontSize: '14px',
|
||||||
|
fill: 0xffffff
|
||||||
|
})
|
||||||
|
|
||||||
|
this.roomTitle = this.add.text(50, 92, '', {
|
||||||
|
fontFamily: 'Arial Rounded MT Bold, ui-rounded, sans-serif',
|
||||||
|
fontSize: '24px',
|
||||||
|
color: '#663d59',
|
||||||
|
fontStyle: 'bold'
|
||||||
|
}).setDepth(50)
|
||||||
|
|
||||||
|
this.helpText = this.add.text(50, 120, '', {
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#7d5a70'
|
||||||
|
}).setDepth(50)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildControls() {
|
||||||
|
const makePad = (x, y, label, direction) => {
|
||||||
|
const circle = this.add.circle(x, y, 29, 0xffffff, 0.88)
|
||||||
|
.setStrokeStyle(2, 0xd989ac)
|
||||||
|
.setInteractive()
|
||||||
|
.setDepth(520)
|
||||||
|
this.add.text(x, y - 1, label, {
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
fontSize: '26px',
|
||||||
|
color: '#b95b84',
|
||||||
|
fontStyle: 'bold'
|
||||||
|
}).setOrigin(0.5).setDepth(521)
|
||||||
|
circle.on('pointerdown', () => { this.moveIntent[direction] = true })
|
||||||
|
circle.on('pointerup', () => { this.moveIntent[direction] = false })
|
||||||
|
circle.on('pointerout', () => { this.moveIntent[direction] = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
makePad(72, 588, '◀', 'left')
|
||||||
|
makePad(136, 588, '▶', 'right')
|
||||||
|
makePad(200, 588, '▲', 'up')
|
||||||
|
makePad(264, 588, '▼', 'down')
|
||||||
|
}
|
||||||
|
|
||||||
|
bindInput() {
|
||||||
|
this.cursors = this.input.keyboard.createCursorKeys()
|
||||||
|
this.keys = this.input.keyboard.addKeys('W,A,S,D')
|
||||||
|
this.input.on('drag', (_pointer, object, dragX, dragY) => {
|
||||||
|
if (!this.decorateMode || object.getData('kind') !== 'furniture') return
|
||||||
|
object.x = Phaser.Math.Clamp(dragX, ROOM_X + 35, ROOM_X + ROOM_W - 35)
|
||||||
|
object.y = Phaser.Math.Clamp(dragY, 215, FLOOR_BOTTOM)
|
||||||
|
object.setDepth(Math.floor(object.y))
|
||||||
|
})
|
||||||
|
this.input.on('dragend', (_pointer, object) => {
|
||||||
|
if (object.getData('kind') !== 'furniture') return
|
||||||
|
const item = this.state.rooms[this.state.activeRoom].find((entry) => entry.id === object.getData('id'))
|
||||||
|
if (!item) return
|
||||||
|
item.x = Math.round(object.x)
|
||||||
|
item.y = Math.round(object.y)
|
||||||
|
this.scheduleSave()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRoom() {
|
||||||
|
this.roomObjects.forEach((object) => object.destroy())
|
||||||
|
this.roomObjects = []
|
||||||
|
this.background?.destroy()
|
||||||
|
this.player?.destroy()
|
||||||
|
|
||||||
|
this.background = this.add.image(ROOM_X, ROOM_Y, 'room-' + this.state.activeRoom)
|
||||||
|
.setOrigin(0)
|
||||||
|
.setDisplaySize(ROOM_W, ROOM_H)
|
||||||
|
.setDepth(0)
|
||||||
|
|
||||||
|
const border = this.add.rectangle(ROOM_X + ROOM_W / 2, ROOM_Y + ROOM_H / 2, ROOM_W, ROOM_H)
|
||||||
|
.setFillStyle(0xffffff, 0)
|
||||||
|
.setStrokeStyle(5, 0xffffff, 0.75)
|
||||||
|
.setDepth(2)
|
||||||
|
this.roomObjects.push(border)
|
||||||
|
|
||||||
|
const items = this.state.rooms[this.state.activeRoom] ?? []
|
||||||
|
items.forEach((item) => this.spawnItem(item))
|
||||||
|
|
||||||
|
const playerPos = this.state.player
|
||||||
|
this.player = this.add.image(playerPos.x, playerPos.y, 'player')
|
||||||
|
.setOrigin(0.5, 1)
|
||||||
|
.setScale(1.35)
|
||||||
|
.setDepth(Math.floor(playerPos.y) + 10)
|
||||||
|
|
||||||
|
this.roomTitle.setText(ROOM_LABELS[this.state.activeRoom])
|
||||||
|
this.updateMode()
|
||||||
|
this.updateRoomButtons()
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnItem(item) {
|
||||||
|
if (!this.textures.exists(item.type)) return
|
||||||
|
const object = this.add.image(item.x, item.y, item.type)
|
||||||
|
.setOrigin(0.5, 1)
|
||||||
|
.setScale(item.scale ?? 1)
|
||||||
|
.setDepth(Math.floor(item.y))
|
||||||
|
.setData('kind', 'furniture')
|
||||||
|
.setData('id', item.id)
|
||||||
|
object.setInteractive({ cursor: 'grab', pixelPerfect: false })
|
||||||
|
this.input.setDraggable(object)
|
||||||
|
this.roomObjects.push(object)
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
switchRoom(room) {
|
||||||
|
if (room === this.state.activeRoom) return
|
||||||
|
this.capturePlayer()
|
||||||
|
this.state.activeRoom = room
|
||||||
|
this.state.player = { x: 480, y: 492 }
|
||||||
|
this.persist()
|
||||||
|
this.renderRoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRoomButtons() {
|
||||||
|
this.roomButtons.forEach((button) => {
|
||||||
|
const active = button.room === this.state.activeRoom
|
||||||
|
button.bg.setFillStyle(active ? 0xf39abc : 0xfff3f7)
|
||||||
|
button.text.setColor(active ? '#ffffff' : '#6b3b59')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMode() {
|
||||||
|
const label = this.decorateMode ? '✓ Done decorating' : '✨ Decorate'
|
||||||
|
this.modeButton.text.setText(label)
|
||||||
|
this.helpText.setText(
|
||||||
|
this.decorateMode
|
||||||
|
? 'Drag the furniture around. Add more things whenever you like.'
|
||||||
|
: 'Walk with the arrows, WASD, or the big buttons below.'
|
||||||
|
)
|
||||||
|
this.addButton.bg.setAlpha(this.decorateMode ? 1 : 0.55)
|
||||||
|
this.addButton.text.setAlpha(this.decorateMode ? 1 : 0.55)
|
||||||
|
this.player?.setAlpha(this.decorateMode ? 0.72 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSomething() {
|
||||||
|
if (!this.decorateMode) {
|
||||||
|
this.decorateMode = true
|
||||||
|
this.updateMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const choicesByRoom = {
|
||||||
|
bedroom: ['teddy', 'bookcase', 'bed'],
|
||||||
|
bathroom: ['ball', 'armchair'],
|
||||||
|
kitchen: ['table', 'bookcase'],
|
||||||
|
living: ['armchair', 'table', 'bookcase', 'teddy']
|
||||||
|
}
|
||||||
|
const choices = choicesByRoom[this.state.activeRoom]
|
||||||
|
const type = choices[Math.floor(Math.random() * choices.length)]
|
||||||
|
const item = {
|
||||||
|
id: type + '-' + Date.now(),
|
||||||
|
type,
|
||||||
|
x: 480 + Phaser.Math.Between(-130, 130),
|
||||||
|
y: 470 + Phaser.Math.Between(-10, 35),
|
||||||
|
scale: type === 'teddy' || type === 'ball' ? 1.25 : 1
|
||||||
|
}
|
||||||
|
this.state.rooms[this.state.activeRoom].push(item)
|
||||||
|
this.spawnItem(item)
|
||||||
|
this.scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetRoom() {
|
||||||
|
const defaults = cloneDefaultState()
|
||||||
|
this.state.rooms[this.state.activeRoom] = defaults.rooms[this.state.activeRoom]
|
||||||
|
this.state.player = { x: 480, y: 492 }
|
||||||
|
await this.persist()
|
||||||
|
this.renderRoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
capturePlayer() {
|
||||||
|
if (!this.player) return
|
||||||
|
this.state.player = {
|
||||||
|
x: Math.round(this.player.x),
|
||||||
|
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 || this.decorateMode) return
|
||||||
|
|
||||||
|
const left = this.cursors.left.isDown || this.keys.A.isDown || this.moveIntent.left
|
||||||
|
const right = this.cursors.right.isDown || this.keys.D.isDown || this.moveIntent.right
|
||||||
|
const up = this.cursors.up.isDown || this.keys.W.isDown || this.moveIntent.up
|
||||||
|
const down = this.cursors.down.isDown || this.keys.S.isDown || this.moveIntent.down
|
||||||
|
|
||||||
|
let dx = Number(right) - Number(left)
|
||||||
|
let dy = Number(down) - Number(up)
|
||||||
|
if (dx === 0 && dy === 0) return
|
||||||
|
|
||||||
|
const length = Math.hypot(dx, dy) || 1
|
||||||
|
dx /= length
|
||||||
|
dy /= length
|
||||||
|
|
||||||
|
const speed = 220
|
||||||
|
const depthSpeed = 105
|
||||||
|
this.player.x = Phaser.Math.Clamp(this.player.x + dx * speed * delta / 1000, ROOM_X + 38, ROOM_X + ROOM_W - 38)
|
||||||
|
this.player.y = Phaser.Math.Clamp(this.player.y + dy * depthSpeed * delta / 1000, FLOOR_TOP, FLOOR_BOTTOM)
|
||||||
|
this.player.setFlipX(dx < 0)
|
||||||
|
this.player.setDepth(Math.floor(this.player.y) + 10)
|
||||||
|
|
||||||
|
const depthScale = Phaser.Math.Linear(1.18, 1.42, (this.player.y - FLOOR_TOP) / (FLOOR_BOTTOM - FLOOR_TOP))
|
||||||
|
this.player.setScale(depthScale)
|
||||||
|
this.scheduleSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const game = new Phaser.Game({
|
||||||
|
type: Phaser.AUTO,
|
||||||
|
parent: 'game',
|
||||||
|
width: WIDTH,
|
||||||
|
height: HEIGHT,
|
||||||
|
backgroundColor: '#fff4f9',
|
||||||
|
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.1.0',
|
||||||
|
game,
|
||||||
|
async resetAll() {
|
||||||
|
await clearGameState()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/style.css
Normal file
41
src/style.css
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
:root {
|
||||||
|
font-family: ui-rounded, "SF Pro Rounded", "Nunito", system-ui, sans-serif;
|
||||||
|
color: #4d3150;
|
||||||
|
background: #fff7fb;
|
||||||
|
font-synthesis: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app, #game {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
#install-wrap {
|
||||||
|
position: fixed;
|
||||||
|
right: max(14px, env(safe-area-inset-right));
|
||||||
|
bottom: max(14px, env(safe-area-inset-bottom));
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
#install-button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f27eaa;
|
||||||
|
color: white;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 12px 18px;
|
||||||
|
box-shadow: 0 6px 18px rgb(122 61 91 / 24%);
|
||||||
|
}
|
||||||
38
vite.config.js
Normal file
38
vite.config.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: null,
|
||||||
|
includeAssets: ['assets/production-sheet.png', 'icon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'My Dollhouse',
|
||||||
|
short_name: 'Dollhouse',
|
||||||
|
description: 'A cozy little dollhouse game for decorating and play.',
|
||||||
|
theme_color: '#f7a9c4',
|
||||||
|
background_color: '#fff7fb',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'landscape',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
skipWaiting: true,
|
||||||
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||||
|
globPatterns: ['**/*.{js,css,html,png,svg}']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user