diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c6590c8
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.git
+.DS_Store
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..673d4bb
--- /dev/null
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index f57dd31..a46c185 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,29 @@
-# dollhouse-pwa
+# My Dollhouse
-Phaser.js PWA dollhouse game with IndexedDB local saves and auto-updating service worker.
\ No newline at end of file
+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.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..27c24f9
--- /dev/null
+++ b/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ My Dollhouse
+
+
+
+
+
+ Install Dollhouse
+
+
+
+
+
\ No newline at end of file
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..9c53d8d
--- /dev/null
+++ b/nginx.conf
@@ -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;
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1fbee36
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/public/icon.svg b/public/icon.svg
new file mode 100644
index 0000000..1028972
--- /dev/null
+++ b/public/icon.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/db.js b/src/db.js
new file mode 100644
index 0000000..e27f0ea
--- /dev/null
+++ b/src/db.js
@@ -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)
+ }
+}
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..f09c9d0
--- /dev/null
+++ b/src/main.js
@@ -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()
+ }
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..3777c6e
--- /dev/null
+++ b/src/style.css
@@ -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%);
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..0bbcae3
--- /dev/null
+++ b/vite.config.js
@@ -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}']
+ }
+ })
+ ]
+})