feat(sasch40): polish retro game layout, clean tavern scene, and improve mobile HUD

- Hide GameBoy D-Pad and action buttons during RPG and dialogue modes
- Add prequel intro landing screen for QR code arrivals with Haumdaucher lore
- Clean baked-in text from tavern scene background and streamline navigation bar
- Add safe-zone margins and HTML HUD in platformer and party express acts
- Optimize Certificate modal layout and scoring breakdown for mobile screens
- Add automated end-to-end browser playtest script with Puppeteer
- Accelerate Docker build via native pre-compilation
This commit is contained in:
Moritz Graf
2026-09-24 07:55:42 +02:00
parent 5852479c21
commit f6d39f01da
13 changed files with 998 additions and 202 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.git
scripts
*.log
.DS_Store
+1 -13
View File
@@ -1,18 +1,6 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+9 -2
View File
@@ -5,13 +5,16 @@ PLATFORM ?= linux/amd64
IMAGE ?= $(REGISTRY)/$(IMAGE_NAME):$(TAG)
.PHONY: build tag push all dev
.PHONY: build tag push all dev compile deploy
all: build tag push
all: compile build tag push deploy
dev:
npm run dev
compile:
npm run build
build:
docker build --platform $(PLATFORM) -t $(IMAGE_NAME):$(TAG) .
@@ -20,3 +23,7 @@ tag:
push:
docker push $(IMAGE)
deploy:
kubectl rollout restart deployment/sasch40 -n web
kubectl rollout status deployment/sasch40 -n web
+341
View File
@@ -17,6 +17,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"puppeteer-core": "^25.12.0",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vue-tsc": "^2.2.0"
@@ -536,6 +537,35 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.3.tgz",
"integrity": "sha512-2Bt3m6dDAJqmZehn0wiXSYyQmuhyiHHZ7FjvrVaS1W2RoFGV6eCK71UvKYkNJjkwC1Q9nobjhvTMhLQztFVINA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"modern-tar": "^0.8.4",
"yargs": "^18.0.0"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1",
"yauzl": "^2.10.0 || ^3.4.0"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
},
"yauzl": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
@@ -1139,6 +1169,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1166,6 +1222,56 @@
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/chromium-bidi": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1179,6 +1285,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/devtools-protocol": {
"version": "0.0.1687809",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1687809.tgz",
"integrity": "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -1233,6 +1353,16 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -1272,6 +1402,29 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
@@ -1317,6 +1470,23 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/modern-tar": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1418,6 +1588,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/puppeteer-core": {
"version": "25.12.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.12.0.tgz",
"integrity": "sha512-z6LQUt5SH7jwGtGA+nMtJuEjZIsqPiwQ+EFHCYuVDQrJpvifNmmGuyDvTzAKPyFzihxgHc5f3aMzu2kn8Ps37A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.2.3",
"chromium-bidi": "17.0.2",
"devtools-protocol": "0.0.1687809",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.3",
"ws": "^8.21.3"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/rollup": {
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
@@ -1473,6 +1661,39 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1490,6 +1711,13 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1656,6 +1884,119 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+1
View File
@@ -18,6 +18,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"puppeteer-core": "^25.12.0",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vue-tsc": "^2.2.0"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 987 KiB

After

Width:  |  Height:  |  Size: 194 KiB

@@ -0,0 +1,288 @@
import puppeteer from 'puppeteer-core';
import path from 'path';
import fs from 'fs';
const ARTIFACTS_DIR = '/Users/moritz/.gemini/antigravity/brain/26049ae8-3106-42e0-922f-356dbbfe162a';
const SCREENSHOTS_DIR = path.join(ARTIFACTS_DIR, 'playtest_screenshots');
if (!fs.existsSync(SCREENSHOTS_DIR)) {
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
}
async function runPlaytest() {
console.log('🚀 Starting Chrome Playtest for Saschas Kronkorken Kreuzzug...');
const logs = [];
const errors = [];
const failedRequests = [];
const browser = await puppeteer.launch({
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--window-size=412,869'
]
});
const page = await browser.newPage();
await page.setViewport({
width: 412,
height: 869,
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true
});
page.on('console', msg => {
const text = msg.text();
logs.push(`[${msg.type().toUpperCase()}] ${text}`);
if (msg.type() === 'error') {
errors.push(`Console Error: ${text}`);
}
});
page.on('pageerror', err => {
errors.push(`Page Error: ${err.toString()}`);
});
page.on('requestfailed', req => {
failedRequests.push(`Failed Request: ${req.url()} (${req.failure()?.errorText || 'unknown'})`);
});
page.on('response', resp => {
if (resp.status() >= 400) {
failedRequests.push(`HTTP ${resp.status()}: ${resp.url()}`);
}
});
try {
console.log('🌐 Navigating to https://sasch40.haumdaucher.de...');
await page.goto('https://sasch40.haumdaucher.de', { waitUntil: 'networkidle0', timeout: 30000 });
// 1. INTRO SCREEN
console.log('📸 Capturing Intro Screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '01_intro_screen.png') });
// Inspect layout and controls presence in Intro
const introControlsVisible = await page.evaluate(() => {
const dpad = document.querySelector('.dpad');
const intro = document.querySelector('.intro-screen');
const chassis = document.querySelector('.gameboy-chassis');
return {
hasDpad: !!dpad,
dpadOffsetHeight: dpad ? (dpad.offsetHeight > 0) : false,
hasIntro: !!intro,
layoutClass: chassis ? chassis.className : ''
};
});
console.log('Intro Layout Check:', introControlsVisible);
// Click Start Button
console.log('🖱️ Clicking Start Button...');
await page.click('.start-btn');
await new Promise(r => setTimeout(r, 1000));
// 2. PROLOGUE SCREEN
console.log('📸 Capturing Prologue Screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02_prologue_screen.png') });
// Advance Prologue Dialogues
console.log('💬 Advancing Prologue Dialogues (clicking choice buttons)...');
// Click choice 1 on 'start'
await page.waitForSelector('.choice-btn', { timeout: 5000 });
const firstChoice = await page.$('.choice-btn');
if (firstChoice) {
await firstChoice.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 Capturing Mop Appears Prologue Dialogue...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02b_prologue_mop.png') });
}
// Click choice on 'mop_appears' -> triggers 'startPlatformer'
const mopChoice = await page.$('.choice-btn');
if (mopChoice) {
await mopChoice.click();
await new Promise(r => setTimeout(r, 1200));
}
// 3. PLATFORMER SCREEN
console.log('📸 Capturing Platformer (Jump & Run)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '03_platformer_screen.png') });
const platformerControls = await page.evaluate(() => {
const dpad = document.querySelector('.dpad');
const btnA = document.querySelector('.btn-a');
const canvas = document.querySelector('canvas');
return {
hasDpad: !!dpad,
hasBtnA: !!btnA,
hasCanvas: !!canvas,
canvasW: canvas?.width,
canvasH: canvas?.height
};
});
console.log('Platformer Controls Check:', platformerControls);
// Test Jump & Move
console.log('🎮 Testing Jump & Move Controls in Platformer...');
const btnA = await page.$('.btn-a');
if (btnA) {
await btnA.click();
await new Promise(r => setTimeout(r, 400));
await btnA.click();
await new Promise(r => setTimeout(r, 400));
}
const dpadRight = await page.$('.dpad-right');
if (dpadRight) {
await dpadRight.hover();
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '03b_platformer_jump.png') });
}
// Directly set act to 'tavern' to test Tavern Point & Click
console.log('🍺 Advancing to Tavern Act...');
await page.evaluate(() => {
const chassis = document.querySelector('.gameboy-chassis');
// access pinia store from window if exposed, or trigger level finish
// We can click the level finish or evaluate store change
window.dispatchEvent(new CustomEvent('test-set-act', { detail: 'tavern' }));
});
// Check if act switched, or use page.evaluate to trigger store change
await page.evaluate(() => {
// Find Pinia root if possible
const app = document.querySelector('#app')?.__vue_app__;
if (app && app.config?.globalProperties?.$pinia) {
const pinia = app.config.globalProperties.$pinia;
// find game store
for (const [id, store] of pinia._s.entries()) {
if (id === 'game') {
store.setAct('tavern');
}
}
}
});
await new Promise(r => setTimeout(r, 1000));
console.log('📸 Capturing Tavern Screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '04_tavern_screen.png') });
// Test Tavern Hotspots
console.log('👥 Testing Verena Hotspot...');
const verenaBtn = await page.$('.hotspot-verena');
if (verenaBtn) {
await verenaBtn.click();
await new Promise(r => setTimeout(r, 800));
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '05_tavern_verena_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 500));
}
console.log('🔨 Testing Armend Hotspot...');
const armendBtn = await page.$('.hotspot-armend');
if (armendBtn) {
await armendBtn.click();
await new Promise(r => setTimeout(r, 800));
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '06_tavern_armend_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 500));
}
console.log('🪞 Testing Zuluman Magic Mirror Hotspot...');
const mirrorBtn = await page.$('.hotspot-mirror');
if (mirrorBtn) {
await mirrorBtn.click();
await new Promise(r => setTimeout(r, 800));
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '07_tavern_zuluman_mirror.png') });
const closeBtn = await page.$('.mirror-box .action-btn');
if (closeBtn) await closeBtn.click();
await new Promise(r => setTimeout(r, 500));
}
console.log('⚔️ Testing Gstanzl-Duel...');
const duelBtn = await page.$('.duel-trigger');
if (duelBtn) {
await duelBtn.click();
await new Promise(r => setTimeout(r, 800));
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '08_tavern_gstanzl_duel.png') });
// Answer Gstanzl question
const correctOption = await page.$('.option-btn');
if (correctOption) {
await correctOption.click();
await new Promise(r => setTimeout(r, 600));
}
}
// 4. PARTY EXPRESS (Act IV)
console.log('🚂 Advancing to Party Express (Act IV)...');
await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
if (app && app.config?.globalProperties?.$pinia) {
const pinia = app.config.globalProperties.$pinia;
for (const [id, store] of pinia._s.entries()) {
if (id === 'game') store.setAct('party_express');
}
}
});
await new Promise(r => setTimeout(r, 1000));
console.log('📸 Capturing Party Express...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09_party_express.png') });
// 5. FINALE / CERTIFICATE (Act V)
console.log('👑 Advancing to Finale Certificate (Act V)...');
await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
if (app && app.config?.globalProperties?.$pinia) {
const pinia = app.config.globalProperties.$pinia;
for (const [id, store] of pinia._s.entries()) {
if (id === 'game') {
store.addKronkorken(25);
store.serveGuest();
store.serveGuest();
store.setAct('finale');
}
}
}
});
await new Promise(r => setTimeout(r, 1500));
console.log('📸 Capturing Certificate Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '10_certificate_screen.png') });
// Check images in document
const brokenImages = await page.evaluate(() => {
const imgs = Array.from(document.querySelectorAll('img'));
return imgs
.filter(img => !img.complete || img.naturalWidth === 0)
.map(img => ({ src: img.src, alt: img.alt }));
});
console.log('\n--- PLAYTEST REPORT ---');
console.log('Total Console Logs:', logs.length);
console.log('Console Errors:', errors);
console.log('Failed Requests:', failedRequests);
console.log('Broken Images on Certificate Page:', brokenImages);
const report = {
timestamp: new Date().toISOString(),
errors,
failedRequests,
brokenImages,
screenshots: fs.readdirSync(SCREENSHOTS_DIR)
};
fs.writeFileSync(path.join(ARTIFACTS_DIR, 'playtest_report.json'), JSON.stringify(report, null, 2));
} catch (err) {
console.error('Playtest error:', err);
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, 'error_state.png') }).catch(() => {});
} finally {
await browser.close();
console.log('🏁 Playtest browser closed.');
}
}
runPlaytest();
@@ -150,56 +150,56 @@ function printOrSave() {
height: 100%;
background: #1a0f0a;
overflow-y: auto;
padding: 12px;
padding: 8px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
gap: 8px;
}
.certificate-paper {
background: #fdf6e2;
border: 6px double #854d0e;
border-radius: 12px;
padding: 16px 14px;
border: 4px double #854d0e;
border-radius: 10px;
padding: 10px 10px;
width: 100%;
max-width: 330px;
text-align: center;
color: #451a03;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.7);
position: relative;
}
.royal-seal {
font-size: 16px;
font-size: 14px;
font-weight: bold;
color: #b45309;
margin-bottom: 4px;
margin-bottom: 2px;
}
.cert-title {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
font-size: 8px;
color: #78350f;
margin: 0 0 6px 0;
margin: 0 0 3px 0;
}
.cert-subtitle {
font-family: 'MedievalSharp', serif;
font-size: 17px;
font-size: 14.5px;
font-weight: bold;
color: #b91c1c;
margin: 0 0 10px 0;
margin: 0 0 6px 0;
}
.king-portrait-wrapper {
margin: 0 auto 10px auto;
width: 90px;
height: 90px;
border: 3px solid #d4af37;
margin: 0 auto 6px auto;
width: 68px;
height: 68px;
border: 2.5px solid #d4af37;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
box-shadow: 0 3px 8px rgba(0,0,0,0.3);
}
.king-portrait {
@@ -210,88 +210,88 @@ function printOrSave() {
.decree-text {
font-family: 'MedievalSharp', serif;
font-size: 13px;
margin: 4px 0;
font-size: 12px;
margin: 2px 0;
}
.honoree-name {
font-family: 'Press Start 2P', monospace;
font-size: 12px;
font-size: 11px;
color: #991b1b;
margin: 6px 0 2px 0;
margin: 4px 0 2px 0;
}
.honoree-title {
font-family: 'MedievalSharp', serif;
font-size: 14px;
font-size: 13px;
font-style: italic;
font-weight: bold;
color: #b45309;
margin: 0 0 6px 0;
margin: 0 0 4px 0;
}
.decree-details {
font-family: 'MedievalSharp', serif;
font-size: 13px;
line-height: 1.3;
margin: 0 0 10px 0;
font-size: 12px;
line-height: 1.25;
margin: 0 0 8px 0;
}
.cert-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
gap: 4px;
margin-bottom: 8px;
}
.stat-card {
background: rgba(217, 119, 6, 0.15);
border: 1px solid #d97706;
border-radius: 6px;
padding: 4px;
border-radius: 5px;
padding: 3px;
display: flex;
flex-direction: column;
}
.stat-val {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
font-size: 8.5px;
color: #92400e;
}
.stat-lbl {
font-size: 9px;
font-size: 8px;
color: #78350f;
font-weight: bold;
}
.score-banner {
background: #1e1b18;
border: 3px solid #ffd700;
border-radius: 8px;
padding: 10px 8px;
margin-bottom: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
border: 2px solid #ffd700;
border-radius: 6px;
padding: 6px 6px;
margin-bottom: 8px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
}
.score-rank-title {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
margin-bottom: 4px;
text-shadow: 0 0 8px currentColor;
font-size: 8px;
margin-bottom: 2px;
text-shadow: 0 0 6px currentColor;
}
.score-number {
font-family: 'Press Start 2P', monospace;
font-size: 14px;
font-size: 12px;
color: #fff;
letter-spacing: 1px;
margin: 4px 0;
letter-spacing: 0.5px;
margin: 2px 0;
}
.score-subtitle {
font-family: 'MedievalSharp', serif;
font-size: 13px;
font-size: 12px;
font-style: italic;
color: #fef08a;
}
@@ -300,21 +300,21 @@ function printOrSave() {
background: rgba(254, 243, 199, 0.7);
border: 1px solid #d97706;
border-radius: 6px;
padding: 8px;
margin-bottom: 10px;
padding: 6px;
margin-bottom: 8px;
font-family: 'MedievalSharp', serif;
font-size: 12px;
font-size: 11.5px;
text-align: left;
}
.breakdown-header {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
font-size: 7.5px;
color: #78350f;
margin-bottom: 6px;
margin-bottom: 4px;
text-align: center;
border-bottom: 1px dashed #b45309;
padding-bottom: 4px;
padding-bottom: 3px;
}
.breakdown-row {
@@ -144,8 +144,8 @@ function toggleInventory() {
</div>
</template>
<!-- Mini Bottom Status Bar for Adventure/Role Play Mode -->
<div v-else class="adventure-bottom-bar">
<!-- Mini Bottom Status Bar for Adventure/Role Play Mode (Hidden on Finale Certificate) -->
<div v-else-if="store.currentAct !== 'finale'" class="adventure-bottom-bar">
<div class="adv-stat">
<span class="adv-icon">👑</span>
<span class="adv-val">{{ store.kronkorken }} Korken</span>
@@ -8,12 +8,13 @@ const canvasRef = ref<HTMLCanvasElement | null>(null)
let animId: number | null = null
let wagonX = 60
let wagonY = 220
let wagonY = 330
let speed = 3.5
let distance = 0
const maxDistance = 2000
interface Guest {
id: string
x: number
name: string
served: boolean
@@ -21,23 +22,22 @@ interface Guest {
}
let guests: Guest[] = [
{ x: 300, name: 'Hansi 🧔', served: false, avatarColor: '#6366f1' },
{ x: 550, name: 'Viktor (Vampir) 🧛', served: false, avatarColor: '#881337' },
{ x: 800, name: 'Albin (Fitness) 🏋️', served: false, avatarColor: '#0284c7' },
{ x: 1050, name: 'Fabian (Brauer) 🍺', served: false, avatarColor: '#d97706' },
{ x: 1300, name: 'Maier Flo (Ton) 🎧', served: false, avatarColor: '#7c3aed' },
{ x: 1550, name: 'Guatemalteke 🌋', served: false, avatarColor: '#059669' },
{ x: 1800, name: 'Markus & Nathalie 🥂', served: false, avatarColor: '#db2777' }
{ id: 'hansi', x: 300, name: 'Hansi 🧔', served: false, avatarColor: '#6366f1' },
{ id: 'viktor', x: 550, name: 'Viktor (Vampir) 🧛', served: false, avatarColor: '#881337' },
{ id: 'albin', x: 800, name: 'Albin (Fitness) 🏋️', served: false, avatarColor: '#0284c7' },
{ id: 'fabian', x: 1050, name: 'Fabian (Brauer) 🍺', served: false, avatarColor: '#d97706' },
{ id: 'maierflo', x: 1300, name: 'Maier Flo (Ton) 🎧', served: false, avatarColor: '#7c3aed' },
{ id: 'guate', x: 1550, name: 'Guatemalteke 🌋', served: false, avatarColor: '#059669' },
{ id: 'markus_nat', x: 1800, name: 'Markus & Nathalie 🥂', served: false, avatarColor: '#db2777' }
]
function throwBeer() {
sound.playGulp()
// Check if any guest is near
guests.forEach(g => {
if (!g.served && Math.abs(distance + wagonX - g.x) < 80) {
if (!g.served && Math.abs(distance + wagonX - g.x) < 90) {
g.served = true
store.addBeer()
store.addKronkorken(3)
store.serveGuest()
}
})
}
@@ -56,58 +56,111 @@ function draw(ctx: CanvasRenderingContext2D, w: number, h: number) {
// Sunset Party Sky
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, '#4a154b')
grad.addColorStop(0.5, '#993355')
grad.addColorStop(1, '#ffaa44')
grad.addColorStop(0, '#31103f')
grad.addColorStop(0.5, '#7a2048')
grad.addColorStop(0.8, '#c2410c')
grad.addColorStop(1, '#f59e0b')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
// Distant party silhouettes with laser beams
const now = Date.now() * 0.003
ctx.strokeStyle = `hsl(${(now * 50) % 360}, 100%, 70%)`
ctx.lineWidth = 1.5
ctx.strokeStyle = `hsl(${(now * 60) % 360}, 100%, 75%)`
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(w / 2, 80)
ctx.lineTo(w * Math.sin(now), h)
ctx.moveTo(w / 2, 80)
ctx.lineTo(w * Math.cos(now), h)
ctx.moveTo(w / 2, 100)
ctx.lineTo(w * ((Math.sin(now) + 1) / 2), 350)
ctx.moveTo(w / 2, 100)
ctx.lineTo(w * ((Math.cos(now) + 1) / 2), 350)
ctx.stroke()
// Cobblestone road
ctx.fillStyle = '#292524'
ctx.fillRect(0, 240, w, 80)
ctx.fillRect(0, 350, w, 130)
ctx.fillStyle = '#44403c'
for (let r = 0; r < w; r += 30) {
ctx.fillRect((r - (distance % 30)), 240, 26, 4)
for (let r = 0; r < w + 30; r += 30) {
ctx.fillRect((r - (distance % 30)), 350, 24, 6)
}
// Draw Guests along the road
guests.forEach(g => {
const screenX = g.x - distance
if (screenX > -50 && screenX < w + 50) {
if (screenX > -60 && screenX < w + 60) {
// Custom sprite details per guest
if (g.id === 'viktor') {
// Vampire cape
ctx.fillStyle = '#4c0519'
ctx.fillRect(screenX - 4, 305, 28, 42)
}
// Guest body
ctx.fillStyle = g.avatarColor
ctx.fillRect(screenX, 195, 20, 45)
ctx.fillRect(screenX, 305, 20, 45)
// Head
ctx.fillStyle = '#fed7aa'
ctx.beginPath()
ctx.arc(screenX + 10, 185, 10, 0, Math.PI * 2)
ctx.arc(screenX + 10, 295, 10, 0, Math.PI * 2)
ctx.fill()
// Character specifics
if (g.id === 'hansi') {
// Moustache
ctx.fillStyle = '#451a03'
ctx.fillRect(screenX + 6, 296, 8, 2.5)
} else if (g.id === 'viktor') {
// Fangs
ctx.fillStyle = '#ffffff'
ctx.fillRect(screenX + 8, 297, 2, 3)
ctx.fillRect(screenX + 12, 297, 2, 3)
} else if (g.id === 'albin') {
// Gym Headband
ctx.fillStyle = '#38bdf8'
ctx.fillRect(screenX + 2, 288, 16, 3)
} else if (g.id === 'fabian') {
// Beer mug in hand
ctx.fillStyle = '#f59e0b'
ctx.fillRect(screenX + 20, 312, 8, 10)
ctx.fillStyle = '#ffffff'
ctx.fillRect(screenX + 19, 309, 10, 3)
} else if (g.id === 'maierflo') {
// Headphones
ctx.strokeStyle = '#c084fc'
ctx.lineWidth = 2.5
ctx.beginPath()
ctx.arc(screenX + 10, 295, 12, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = '#9333ea'
ctx.fillRect(screenX - 3, 293, 4, 6)
ctx.fillRect(screenX + 19, 293, 4, 6)
} else if (g.id === 'guate') {
// Bushy dark beard
ctx.fillStyle = '#1c1917'
ctx.fillRect(screenX + 4, 297, 12, 9)
} else if (g.id === 'markus_nat') {
// Second head for couple
ctx.fillStyle = '#fbcfe8'
ctx.beginPath()
ctx.arc(screenX + 24, 296, 9, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#db2777'
ctx.fillRect(screenX + 16, 307, 16, 43)
}
// Name & icon tag
ctx.fillStyle = '#fff'
ctx.font = 'bold 9px MedievalSharp, serif'
ctx.textAlign = 'center'
ctx.fillText(g.name, screenX + 10, 168)
ctx.fillText(g.name, screenX + 10, 278)
if (g.served) {
ctx.fillStyle = '#22c55e'
ctx.font = 'bold 12px sans-serif'
ctx.fillText('🍻 PROST!', screenX + 10, 154)
ctx.font = 'bold 11px sans-serif'
ctx.fillText('🍻 PROST!', screenX + 10, 264)
} else {
ctx.fillStyle = '#fbbf24'
ctx.font = '10px monospace'
ctx.fillText('DURST!', screenX + 10, 154)
ctx.font = '9px "Press Start 2P", monospace'
ctx.fillText('DURST!', screenX + 10, 264)
}
}
})
@@ -119,55 +172,85 @@ function draw(ctx: CanvasRenderingContext2D, w: number, h: number) {
// Wagon wooden bed
ctx.fillStyle = '#78350f'
ctx.fillRect(wx - 20, wy + bob, 90, 24)
ctx.fillRect(wx - 20, wy + bob, 95, 24)
ctx.fillStyle = '#b45309'
ctx.fillRect(wx - 18, wy + bob + 3, 86, 18)
ctx.fillRect(wx - 18, wy + bob + 3, 91, 18)
// Wheels
// Wooden spoke wheels
const wheelSpin = (distance * 0.1) % (Math.PI * 2)
;[-6, 60].forEach(wheelOffset => {
ctx.save()
ctx.translate(wx + wheelOffset, wy + 24)
ctx.rotate(wheelSpin)
ctx.fillStyle = '#451a03'
ctx.beginPath()
ctx.arc(wx - 6, wy + 24, 10, 0, Math.PI * 2)
ctx.arc(wx + 54, wy + 24, 10, 0, Math.PI * 2)
ctx.arc(0, 0, 11, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 2
ctx.lineWidth = 2.5
ctx.stroke()
// Spokes
ctx.strokeStyle = '#b45309'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(-11, 0); ctx.lineTo(11, 0)
ctx.moveTo(0, -11); ctx.lineTo(0, 11)
ctx.stroke()
ctx.restore()
})
// Sascha standing on wagon with crown, sunglasses & beer mug
ctx.fillStyle = '#dc2626' // Cape
ctx.fillRect(wx + 10, wy - 30 + bob, 22, 34)
ctx.fillRect(wx + 12, wy - 30 + bob, 22, 34)
ctx.fillStyle = '#1d4ed8' // Tunic
ctx.fillRect(wx + 14, wy - 28 + bob, 16, 22)
ctx.fillRect(wx + 16, wy - 28 + bob, 16, 22)
ctx.fillStyle = '#fcd34d' // Face
ctx.fillRect(wx + 15, wy - 42 + bob, 14, 14)
ctx.fillRect(wx + 17, wy - 42 + bob, 14, 14)
// Sunglasses
ctx.fillStyle = '#0f172a'
ctx.fillRect(wx + 17, wy - 38 + bob, 5, 3)
ctx.fillRect(wx + 23, wy - 38 + bob, 5, 3)
ctx.fillRect(wx + 19, wy - 38 + bob, 5, 3)
ctx.fillRect(wx + 25, wy - 38 + bob, 5, 3)
// Crown
ctx.fillStyle = '#facc15'
ctx.fillRect(wx + 14, wy - 48 + bob, 16, 6)
ctx.fillRect(wx + 16, wy - 48 + bob, 16, 6)
// Giant Bass Speaker on the back of the wagon
ctx.fillStyle = '#18181b'
ctx.fillRect(wx - 16, wy - 34 + bob, 24, 34)
ctx.fillStyle = '#f43f5e'
ctx.beginPath()
ctx.arc(wx - 4, wy - 18 + bob, 8 + Math.abs(Math.sin(Date.now() * 0.02) * 3), 0, Math.PI * 2)
const bassPulse = Math.abs(Math.sin(Date.now() * 0.02) * 4)
ctx.arc(wx - 4, wy - 18 + bob, 8 + bassPulse, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 1.5
ctx.stroke()
// DJ Console in front
ctx.fillStyle = '#27272a'
ctx.fillRect(wx + 44, wy - 14 + bob, 26, 16)
// Turntable
ctx.fillStyle = '#09090b'
ctx.beginPath()
ctx.arc(wx + 57, wy - 6 + bob, 6, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ef4444'
ctx.fillRect(wx + 56, wy - 7 + bob, 2, 2)
// Hof-DJs Banner
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 9px "Press Start 2P", monospace'
ctx.textAlign = 'left'
ctx.fillText('135 BPM EXPRESS', 10, 25)
ctx.font = 'bold 8.5px "Press Start 2P", monospace'
ctx.textAlign = 'center'
ctx.fillText('🎧 135 BPM PARTY EXPRESS 🎧', w / 2, 24)
// Progress Bar
const progress = Math.min(1, distance / maxDistance)
ctx.fillStyle = 'rgba(0,0,0,0.5)'
ctx.fillRect(10, 35, w - 20, 8)
const barMargin = 24
const barWidth = w - barMargin * 2
ctx.fillStyle = 'rgba(0,0,0,0.6)'
ctx.fillRect(barMargin, 34, barWidth, 8)
ctx.fillStyle = '#22c55e'
ctx.fillRect(10, 35, (w - 20) * progress, 8)
ctx.fillRect(barMargin, 34, barWidth * progress, 8)
}
function loop() {
@@ -190,7 +273,7 @@ onUnmounted(() => {
<template>
<div class="express-container">
<canvas ref="canvasRef" width="360" height="320" class="game-canvas"></canvas>
<canvas ref="canvasRef" width="360" height="480" class="game-canvas"></canvas>
<div class="express-controls">
<button class="throw-beer-btn" @click="throwBeer">
@@ -214,7 +297,7 @@ onUnmounted(() => {
.game-canvas {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
@@ -35,7 +35,7 @@ interface Particle {
let player = {
x: 60,
y: 200,
y: 350,
w: 32,
h: 48,
vx: 0,
@@ -62,33 +62,33 @@ let keys = {
function initLevel() {
platforms = [
// Ground base
{ x: 0, y: 280, w: 700, h: 50, type: 'roof' },
{ x: 780, y: 280, w: 600, h: 50, type: 'roof' },
{ x: 1460, y: 280, w: 700, h: 50, type: 'roof' },
{ x: 2240, y: 280, w: 600, h: 50, type: 'roof' },
{ x: 0, y: 400, w: 700, h: 80, type: 'roof' },
{ x: 780, y: 400, w: 600, h: 80, type: 'roof' },
{ x: 1460, y: 400, w: 700, h: 80, type: 'roof' },
{ x: 2240, y: 400, w: 600, h: 80, type: 'roof' },
// Platforms
{ x: 180, y: 210, w: 100, h: 18, type: 'wood' },
{ x: 340, y: 160, w: 120, h: 18, type: 'wood' },
{ x: 520, y: 210, w: 90, h: 18, type: 'barrel' },
{ x: 180, y: 330, w: 100, h: 18, type: 'wood' },
{ x: 340, y: 260, w: 120, h: 18, type: 'wood' },
{ x: 520, y: 330, w: 90, h: 18, type: 'barrel' },
{ x: 860, y: 200, w: 110, h: 18, type: 'wood' },
{ x: 1040, y: 150, w: 130, h: 18, type: 'wood' },
{ x: 1220, y: 210, w: 80, h: 18, type: 'barrel' },
{ x: 860, y: 310, w: 110, h: 18, type: 'wood' },
{ x: 1040, y: 240, w: 130, h: 18, type: 'wood' },
{ x: 1220, y: 320, w: 80, h: 18, type: 'barrel' },
{ x: 1560, y: 190, w: 120, h: 18, type: 'wood' },
{ x: 1740, y: 140, w: 140, h: 18, type: 'wood' },
{ x: 1950, y: 200, w: 90, h: 18, type: 'barrel' },
{ x: 1560, y: 290, w: 120, h: 18, type: 'wood' },
{ x: 1740, y: 220, w: 140, h: 18, type: 'wood' },
{ x: 1950, y: 300, w: 90, h: 18, type: 'barrel' },
{ x: 2320, y: 190, w: 130, h: 18, type: 'wood' },
{ x: 2500, y: 140, w: 160, h: 18, type: 'wood' }
{ x: 2320, y: 280, w: 130, h: 18, type: 'wood' },
{ x: 2500, y: 220, w: 160, h: 18, type: 'wood' }
]
items = []
// Scatter 35 bottle caps and 5 beers
for (let i = 0; i < 35; i++) {
const px = 120 + i * 72
const py = 120 + (i % 3) * 45
const py = 220 + (i % 3) * 60
items.push({
x: px,
y: py,
@@ -102,7 +102,7 @@ function initLevel() {
;[450, 1100, 1800, 2450].forEach(bx => {
items.push({
x: bx,
y: 110,
y: 190,
type: 'beer',
collected: false,
bobOffset: 0
@@ -185,8 +185,8 @@ function updatePhysics() {
}
// Prevent falling to oblivion (respawn smoothly with cheer)
if (player.y > 360) {
player.y = 150
if (player.y > 460) {
player.y = 250
player.vy = 0
player.x = Math.max(60, player.x - 120)
}
@@ -254,9 +254,9 @@ function drawGame(ctx: CanvasRenderingContext2D, width: number, height: number)
for (let m = -1; m < 5; m++) {
const mx = m * 240 - (cameraX * 0.2) % 240
ctx.beginPath()
ctx.moveTo(mx, 260)
ctx.lineTo(mx + 120, 110)
ctx.lineTo(mx + 240, 260)
ctx.moveTo(mx, 400)
ctx.lineTo(mx + 120, 200)
ctx.lineTo(mx + 240, 400)
ctx.fill()
}
@@ -264,11 +264,11 @@ function drawGame(ctx: CanvasRenderingContext2D, width: number, height: number)
ctx.fillStyle = 'rgba(44, 24, 16, 0.65)'
for (let c = -1; c < 4; c++) {
const cx = c * 320 - (cameraX * 0.35) % 320
ctx.fillRect(cx + 60, 120, 50, 140)
ctx.fillRect(cx + 60, 220, 50, 180)
ctx.beginPath()
ctx.moveTo(cx + 50, 120)
ctx.lineTo(cx + 85, 80)
ctx.lineTo(cx + 120, 120)
ctx.moveTo(cx + 50, 220)
ctx.lineTo(cx + 85, 160)
ctx.lineTo(cx + 120, 220)
ctx.fill()
}
@@ -352,13 +352,13 @@ function drawGame(ctx: CanvasRenderingContext2D, width: number, height: number)
// Draw Goal Gate: Taverne 'Zum wummernden Ritter'
ctx.fillStyle = '#3e2723'
ctx.fillRect(levelWidth - 140, 160, 110, 120)
ctx.fillRect(levelWidth - 140, 270, 110, 130)
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 9px MedievalSharp, serif'
ctx.fillText('ZUM WUMMERNDEN RITTER', levelWidth - 135, 175)
ctx.fillText('ZUM WUMMERNDEN RITTER', levelWidth - 135, 285)
// Glowing door
ctx.fillStyle = '#f59e0b'
ctx.fillRect(levelWidth - 105, 200, 40, 80)
ctx.fillRect(levelWidth - 105, 320, 40, 80)
// Draw Player: König Sascha (Pixel King with sunglasses, crown & ermine coat)
const px = player.x
@@ -424,18 +424,7 @@ function drawGame(ctx: CanvasRenderingContext2D, width: number, height: number)
ctx.arc(px + 24, py + 16, 4, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
// On-screen HUD
ctx.fillStyle = 'rgba(26, 15, 10, 0.75)'
ctx.fillRect(8, 8, 160, 28)
ctx.strokeStyle = '#d4af37'
ctx.lineWidth = 1.5
ctx.strokeRect(8, 8, 160, 28)
ctx.fillStyle = '#ffd700'
ctx.font = '10px "Press Start 2P", monospace'
ctx.fillText(`KRONEN: ${store.kronkorken}`, 16, 26)
ctx.restore() // End camera translation
}
function gameLoop() {
@@ -484,16 +473,19 @@ onUnmounted(() => {
<canvas
ref="canvasRef"
width="360"
height="320"
height="480"
class="game-canvas"
@touchstart.prevent="onJump"
></canvas>
<div class="platformer-hud-top">
<div class="hud-korken-badge">👑 {{ store.kronkorken }} KORKEN</div>
<div class="level-overlay">
<span class="level-title">AKTE II: DIE HOPFEN-PFADE</span>
<span class="level-hint">Tippe oder drücke SPRUNG zum Hüpfen</span>
</div>
</div>
</div>
</template>
<style scoped>
@@ -511,19 +503,38 @@ onUnmounted(() => {
.game-canvas {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.level-overlay {
.platformer-hud-top {
position: absolute;
top: 6px;
right: 8px;
top: 8px;
left: 10px;
right: 10px;
display: flex;
justify-content: space-between;
align-items: flex-start;
pointer-events: none;
z-index: 10;
}
.hud-korken-badge {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #ffd700;
background: rgba(26, 15, 10, 0.85);
border: 1.5px solid #d4af37;
border-radius: 4px;
padding: 4px 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.8);
}
.level-overlay {
display: flex;
flex-direction: column;
align-items: flex-end;
pointer-events: none;
}
.level-title {
@@ -33,13 +33,16 @@ const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['
<template>
<div class="prologue-container">
<!-- Balcony Visual Scene -->
<!-- Balcony Visual Scene with Regal Portrait Frame -->
<div class="scene-visual">
<div class="balcony-backdrop"></div>
<div class="speaker-frame">
<img
:src="currentSpeaker().portrait"
:alt="currentSpeaker().name"
class="speaker-visual"
/>
</div>
<div class="scene-tag">
<span class="scene-title">SCHLOSS HAUMDAUCHER – DER BALKON</span>
</div>
@@ -86,21 +89,43 @@ const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['
.scene-visual {
position: relative;
flex: 1;
min-height: 140px;
min-height: 160px;
max-height: 200px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
background: #2b1810;
background: radial-gradient(circle at 50% 40%, #4a2818 0%, #1a0f0a 100%);
border-bottom: 2px solid #d4af37;
}
.balcony-backdrop {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 0, 0, 0.3) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.3) 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.5;
}
.speaker-frame {
position: relative;
width: 140px;
height: 140px;
border-radius: 12px;
border: 3px double #ffd700;
box-shadow: 0 0 16px rgba(0, 0, 0, 0.8), 0 0 10px rgba(212, 175, 55, 0.4);
overflow: hidden;
background: #2b1810;
z-index: 1;
}
.speaker-visual {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
object-position: center top;
image-rendering: crisp-edges;
}
@@ -120,6 +120,20 @@ function openCharacterDialog(charId: string) {
activeDialog.value = null
}
}
} else if (charId === 'nat') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Servus Sascha! Schau dir meinen Wildschwein-Harnisch an – der glänzt wie ein Kronjuwel! Ich hab dir schon 3 kühle Helle und 5 Kronkorken gesichert, bevor die Meute alles wegsäuft!',
actionLabel: 'Sauber Josef! (+5 Kronkorken & Helles)',
actionFn: () => {
store.addKronkorken(5)
store.addBeer()
activeDialog.value = null
}
}
}
}
@@ -162,7 +176,7 @@ function proceedToExpress() {
<!-- Interactive Hotspots on the visual scene with Avatar Thumbnails -->
<button class="hotspot hotspot-verena" @click="openCharacterDialog('verena')">
<img :src="CHARACTERS.verena.portrait" class="hotspot-thumb" alt="Verena" />
<img :src="CHARACTERS.verena.portrait" class="hotspot-thumb" alt="Königin Verena" />
<span class="hotspot-badge">Verena 👑</span>
</button>
@@ -196,6 +210,11 @@ function proceedToExpress() {
<span class="hotspot-badge">Mane 🥾</span>
</button>
<button class="hotspot hotspot-nat" @click="openCharacterDialog('nat')">
<img :src="CHARACTERS.nat.portrait" class="hotspot-thumb" alt="NAT" />
<span class="hotspot-badge">NAT 🐗</span>
</button>
<button class="hotspot hotspot-mario" @click="openCharacterDialog('mario')">
<img :src="CHARACTERS.mario.portrait" class="hotspot-thumb" alt="Mario" />
<span class="hotspot-badge">Mario 🤹</span>
@@ -203,6 +222,8 @@ function proceedToExpress() {
<!-- Bottom Scene Navigation & Duel -->
<div class="scene-bottom-nav">
<div class="tavern-bar-title">🍻 TAVERNE ZUM WUMMERNDEN RITTER 🍻</div>
<div class="tavern-nav-buttons">
<button class="duel-trigger" @click="startGstanzlDuel">
⚔️ Gstanzl-Fechten
</button>
@@ -212,6 +233,7 @@ function proceedToExpress() {
</button>
</div>
</div>
</div>
<!-- Monkey Island Dialog Modal -->
<div v-if="activeDialog" class="dialog-overlay" @click="activeDialog = null">
@@ -353,14 +375,15 @@ function proceedToExpress() {
white-space: nowrap;
}
.hotspot-verena { top: 72%; left: 54%; }
.hotspot-verena { top: 15%; left: 80%; }
.hotspot-armend { top: 62%; left: 24%; }
.hotspot-lutz { top: 38%; left: 22%; }
.hotspot-mirror { top: 30%; left: 63%; }
.hotspot-amy { top: 52%; left: 84%; }
.hotspot-pue { top: 16%; left: 35%; }
.hotspot-mane { top: 44%; left: 68%; }
.hotspot-mario { top: 74%; left: 86%; }
.hotspot-lutz { top: 38%; left: 20%; }
.hotspot-mirror { top: 32%; left: 63%; }
.hotspot-amy { top: 52%; left: 82%; }
.hotspot-pue { top: 20%; left: 35%; }
.hotspot-mane { top: 45%; left: 66%; }
.hotspot-nat { top: 63%; left: 85%; }
.hotspot-mario { top: 36%; left: 90%; }
.pulse {
animation: pulseAnim 1.5s infinite;
@@ -373,13 +396,37 @@ function proceedToExpress() {
.scene-bottom-nav {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
bottom: 0;
left: 0;
right: 0;
height: 84px;
background: linear-gradient(180deg, #451a03 0%, #26140b 100%);
border-top: 3px solid #b45309;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
z-index: 15;
}
.tavern-bar-title {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #fef08a;
text-shadow: 1px 1px 0 #000;
letter-spacing: 0.5px;
text-align: center;
}
.tavern-nav-buttons {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
z-index: 15;
}
.duel-trigger {