From c8fe90bb47a89e5c38a615baf697b7c828dd021d Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 21 Sep 2026 22:23:59 +0000 Subject: [PATCH] Build Phaser PWA prototype with IndexedDB saves --- .dockerignore | 4 + Dockerfile | 12 ++ README.md | 30 ++- index.html | 24 +++ nginx.conf | 32 ++++ package.json | 18 ++ public/icon.svg | 9 + src/db.js | 66 +++++++ src/main.js | 479 ++++++++++++++++++++++++++++++++++++++++++++++++ src/style.css | 41 +++++ vite.config.js | 38 ++++ 11 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 public/icon.svg create mode 100644 src/db.js create mode 100644 src/main.js create mode 100644 src/style.css create mode 100644 vite.config.js 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 + + +
+
+ +
+ + + \ 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}'] + } + }) + ] +})