Compare commits

..
11 Commits
Author SHA1 Message Date
Moritz Graf e45dbc2e19 feat(sasch40): improve Act III, add state persistence and fix portraits
- Fix character portrait crops and version assets to portrait_v2, ensuring correct identities (resolving Nat/Verena mismatch)
- Update Schankraum scene image and configure Nginx cache-control to prevent stale asset caching
- Make secret beer tap completely invisible in tavern until clicked
- Reposition room hotspots in Act III to eliminate overlapping click targets
- Add exit button [✕] to Bavarian Gstanzl duel minigame
- Implement localStorage persistence in gameStore to maintain progress across page reloads
- Enable clean game state and storage reset when clicking "Nochmal spielen"
- Expand automated playtest script to cover Nat dialogue, duel exit, reload persistence, and game restart
2026-09-24 11:03:22 +02:00
Moritz Graf 7a7038e2c2 feat(sasch40): multi-room tavern, authentic character portraits, pre-level start screens & bollerwagen redesign 2026-09-24 10:02:57 +02:00
Moritz Graf 4488749d6f docs(sasch40): document architecture decisions, agent rules, and character system in AGENTS.md 2026-09-24 07:59:01 +02:00
Moritz Graf f6d39f01da 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
2026-09-24 07:55:42 +02:00
Moritz Graf 5852479c21 feat(sasch40): add retro birthday game subproject and k8s deployment
- Add Vue 3 + Vite + TypeScript retro game: 'Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft'
- Add dynamic GameBoy layout: controls visible only during arcade modes, fullscreen during dialogue and tavern
- Add QR code welcome & prequel screen with lore, feature preview, and audio hints
- Add competitive party highscore and ranking breakdown on royal certificate
- Add dedicated individual 1:1 character portraits for Sascha, Verena, NAT, Hansi, Mop, Lutz, Armend, Amy, Mane, Pü, and Mario
- Add Dockerfile, Makefile, and K8s deployment at sasch40.haumdaucher.de
- Update openclaw timeout configuration
2026-09-23 21:56:34 +02:00
Moritz Graf d11a84a0d3 fix(openclaw): use base64 patch for garmin_mcp workouts normalization in init script 2026-09-20 08:09:58 +02:00
Moritz Graf f2ef5a8834 fix(openclaw): enforce sportType integrity on Garmin workout uploads 2026-09-20 08:06:06 +02:00
Moritz Graf 80a056f720 docs(bootstrap): document single-node cluster upgrade procedures and modernize init.sh
- Update init.sh with smart checkout, patch application, sync-inventory alias, and runbook output
- Rewrite bootstrap/README.md with step-by-step upgrade guide, architectural invariants, and troubleshooting
- Update bootstrap/AGENTS.md with single-node operational rules and OS constraints
2026-09-19 22:58:22 +02:00
Moritz Graf 87649e6e22 chore(bootstrap): upgrade kubespray to release-2.28 and kubernetes to v1.32.13
- Bump Kubespray baseline to release-2.28 (last release supporting Ubuntu 20.04)
- Upgrade Kubernetes control plane and node to v1.32.13
- Configure single-node cluster settings (drain_nodes: false)
- Add patch for Kubespray v1beta4 kubeadm preflight error handling
- Update init.sh, AGENTS.md, and README.md with two-phase upgrade instructions
2026-09-19 22:57:19 +02:00
Moritz Graf c9e21c082d chore(wiki): update knowledge-wiki submodule pointer 2026-09-19 21:06:42 +02:00
Moritz Graf a7b93ca34a feat(openclaw): add chart-to-chat skill to prompt and update PATH for dct 2026-09-19 21:06:07 +02:00
95 changed files with 7996 additions and 171 deletions
+6
View File
@@ -16,6 +16,7 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
* **Purpose**: Deploying applications and services to the cluster. * **Purpose**: Deploying applications and services to the cluster.
* **Method**: GitOps-style directory structure (Namespace = Folder). * **Method**: GitOps-style directory structure (Namespace = Folder).
* **Constraints**: Enforces `ingress-nginx`, `cert-manager` (Let's Encrypt), and `git-crypt` for secrets. * **Constraints**: Enforces `ingress-nginx`, `cert-manager` (Let's Encrypt), and `git-crypt` for secrets.
* **OpenClaw**: For OpenClaw AI agent deployment and troubleshooting, see [k8s/openclaw/AGENTS.md](./k8s/openclaw/AGENTS.md) and [k8s/openclaw/TROUBLESHOOTING.md](./k8s/openclaw/TROUBLESHOOTING.md).
* **Read**: [k8s/AGENTS.md](./k8s/AGENTS.md) * **Read**: [k8s/AGENTS.md](./k8s/AGENTS.md)
### 3. Terraform (`terraform/`) ### 3. Terraform (`terraform/`)
@@ -65,6 +66,11 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
* [ADR 0002: OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics](docs/adr/0002-openclaw-model-selection-tiering-and-economics.md) * [ADR 0002: OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics](docs/adr/0002-openclaw-model-selection-tiering-and-economics.md)
* [ADR 0003: Everything as Code (EaC) and Reproducible Declarative Infrastructure](docs/adr/0003-everything-as-code-and-reproducible-infrastructure.md) * [ADR 0003: Everything as Code (EaC) and Reproducible Declarative Infrastructure](docs/adr/0003-everything-as-code-and-reproducible-infrastructure.md)
### 7. [Saschas Kronkorken Kreuzzug](./projects/sasch40/AGENTS.md) (`projects/sasch40/`)
* **Purpose**: Mobile-first 2.5D retro birthday adventure game for King Sascha's 40th jubilee (`https://sasch40.haumdaucher.de`).
* **Key Rules**: Prequel landing screen for QR codes, dynamic GameBoy arcade vs RPG layouts, real-photo character portraits, pure Web Audio synthesizer, competitive certificate scoring.
* **Read**: [projects/sasch40/AGENTS.md](./projects/sasch40/AGENTS.md)
## Global Constraints ## Global Constraints
* **Secrets**: `git-crypt` is used extensively. Ensure you have the unlock key before editing `*.secret` files. * **Secrets**: `git-crypt` is used extensively. Ensure you have the unlock key before editing `*.secret` files.
* **Identity**: * **Identity**:
+2
View File
@@ -1,2 +1,4 @@
kubespray kubespray
venv venv
credentials/
inventory/**/credentials/
+61 -27
View File
@@ -1,49 +1,83 @@
# AGENTS.md # AGENTS.md
> [!NOTE] > [!NOTE]
> This directory handles the **bootstrapping and provisioning** of the Haumdaucher Kubernetes cluster using **Kubespray**. > This directory handles the **bootstrapping, provisioning, and in-place upgrades** of the Haumdaucher Kubernetes cluster using **Kubespray**.
## Project Overview ## Project Overview
* **Tool**: [Kubespray](https://github.com/kubernetes-sigs/kubespray) (Ansible-based). * **Tool**: [Kubespray](https://github.com/kubernetes-sigs/kubespray) (Ansible-based).
* **Method**: The local `inventory/` is the source of truth, which is synced into a checked-out Kubespray repository. * **Topology**: **Single-node cluster** (`haumdaucher`, `136.243.23.215`). The single node acts simultaneously as control plane, etcd member, and worker.
* **Idempotency**: The process is designed to be repeatable. The `kubespray` folder is treated as ephemeral and is re-created by `init.sh`. * **Host OS**: Ubuntu 20.04.6 LTS.
* **Method**: The local `inventory/` is the source of truth, synced into the checked-out Kubespray repository.
* **Idempotency & Patching**: The `kubespray/` directory is ephemeral and managed by `init.sh`. Any essential Kubespray fixes are persisted in `patches/` and applied automatically.
---
## Architectural Invariants for Single-Node Upgrades
> [!IMPORTANT]
> The single-node topology imposes strict operational constraints that must be observed by all agents and operators:
1. **NO DRAIN / NO CORDON**:
- Draining or cordoning a single-node cluster evicts all workloads with nowhere to schedule them, causing cascading failures and pending pods.
- `drain_nodes: false`, `upgrade_node_always_cordon: false`, and `upgrade_node_fail_if_drain_fails: false` MUST remain configured in [k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml).
- Use the in-place upgrade mechanism: `ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml`. This invokes `kubeadm-upgrade.yml` without cordon/drain overhead.
2. **VERSION STRING FORMAT**:
- In `inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`, `kube_version` must be specified **without** the leading `v` (e.g., `1.32.13`, NOT `v1.32.13`), as Kubespray 2.28 interpolates `v{{ kube_version }}` in download URLs and package names.
3. **UBUNTU 20.04 OS CONSTRAINT**:
- Kubespray `release-2.28` is the **final release line** supporting Ubuntu 20.04 LTS. Upstream `release-2.29`+ drops Ubuntu 20.04 support. Do NOT bump `VERSION` in `init.sh` beyond `release-2.28` until the host operating system has been upgraded to Ubuntu 22.04 or 24.04.
4. **ETCD BACKUPS**:
- Always take a manual etcd snapshot before initiating any cluster upgrade.
---
## Workflow & Scripts ## Workflow & Scripts
The core workflow is encapsulated in `init.sh`.
### `init.sh` ### 1. Source `init.sh`
**Purpose**: Prepares the environment and Kubespray for deployment.
**Actions**:
1. **Clean Slate**: Deletes existing `kubespray/` directory.
2. **Clone**: Clones Kubespray (version defined in variable `VERSION`, e.g., `release-2.27`).
3. **Environment**: Sets up Python virtualenv via `pyenv` and installs `requirements.txt`.
4. **Sync**: Copies local `./inventory/` configurations into `./kubespray/inventory/`.
### Usage
1. **Source the script**:
```bash ```bash
source init.sh source init.sh
``` ```
2. **Deploy / Upgrade**: **Actions performed**:
After sourcing, go to the `kubespray` directory and run the Ansible playbooks as instructed by the script output. - Clones / checks out the Kubespray branch defined by `VERSION` (`release-2.28`).
* **Standard Run**: - Applies monorepo patches in `patches/*.patch` (e.g., propagating `--ignore-preflight-errors` for `v1beta4` kubeadm configs).
- Activates the `infrapuzzle-bootstrap` pyenv virtualenv and verifies Python requirements.
- Syncs `./inventory/` to `./kubespray/inventory/`.
- Exports the `sync-inventory` alias.
- Changes working directory to `./kubespray`.
### 2. Upgrading the Single-Node Cluster (Two-Phase Decoupled Workflow)
#### Phase 1: Reconcile Kubespray Tooling Baseline (Keep existing k8s version)
```bash ```bash
cd kubespray cd kubespray
ansible-playbook -i inventory/prod/inventory.ini cluster.yml ansible-playbook -i inventory/prod/inventory.ini cluster.yml
``` ```
* **Forced Upgrade**: *Verification*: Check node readiness and that no application pods crashed.
#### Phase 2: Upgrade Kubernetes Version (e.g. to 1.32.13)
1. Edit `bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`:
```yaml
kube_version: 1.32.13
```
2. Re-sync inventory from `bootstrap/` or `bootstrap/kubespray/`:
```bash
sync-inventory
```
3. Run the in-place upgrade:
```bash ```bash
cd kubespray cd kubespray
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
``` ```
4. Verify the cluster:
```bash
kubectl version
kubectl get nodes -o wide
kubectl get pods -A | grep -v -E "Running|Completed"
```
---
## Directory Structure ## Directory Structure
* `init.sh`: The entry point script. **Source of truth for Kubespray version.** * `init.sh`: The entrypoint script (sources environment, applies patches, sets up inventory).
* `inventory/`: Contains cluster inventory configurations (hosts, variables). **Edit this, not the one in `kubespray/`**. * `patches/`: Monorepo patches applied automatically to ephemeral `kubespray/`.
* `kubespray/`: (Ignored/Ephemeral) The checked-out Kubespray repository. **Do not edit files here directly**; they will be overwritten. * `inventory/`: Source of truth for cluster inventory and variables.
* `kubespray/`: (Ephemeral / Gitignored) Cloned Kubespray repository.
## Configuration Updates
To upgrade Kubespray or change cluster config:
1. **Version Upgrade**: Update `VERSION` in `init.sh` (e.g., to `release-2.28`).
2. **Config Changes**: Modify files in `./inventory/`.
3. **Apply**: Run `source init.sh` then execute the Ansible playbook.
+121 -70
View File
@@ -1,114 +1,165 @@
# Bootstrap # Bootstrap
The following lines document how to initalize a fresh cluster. On a real cluster, or using Vagrant. It therefore assumes to clone kubespray to this folder. It will be excluded in *.gitignore* and all files are in this folder. This directory contains the automation, configurations, and playbooks used to provision and upgrade the single-node Kubernetes cluster **`haumdaucher`** (`136.243.23.215`) using **[Kubespray](https://github.com/kubernetes-sigs/kubespray)**.
The version to be initialized in configured in the `init.sh` script. ---
## Architecture & Single-Node Cluster Constraints
`haumdaucher` is a **single-node cluster** (control plane, etcd, and worker all run on the same machine). This topology requires specific configuration choices during cluster maintenance:
> [!IMPORTANT]
> ### Crucial Single-Node Invariants
> 1. **Never Cordon or Drain the Node**:
> Draining a single-node cluster evicts all workloads with nowhere to schedule them, causing pods to stay in `Pending` state and breaking critical cluster services (such as DNS, CNI, ingress, and local storage).
> In [inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml), the following settings are enforced:
> ```yaml
> drain_nodes: false
> upgrade_node_always_cordon: false
> upgrade_node_fail_if_drain_fails: false
> ```
> 2. **In-Place Upgrades via `cluster.yml`**:
> Instead of `upgrade-cluster.yml` (which defaults to draining nodes), single-node upgrades should use the in-place upgrade mechanism:
> ```sh
> ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
> ```
> 3. **Version Number Formatting**:
> Kubespray 2.28 interpolates `v{{ kube_version }}` across many internal tasks. Therefore, `kube_version` in `k8s-cluster.yml` MUST NOT include a leading `v`:
> ```yaml
> kube_version: 1.32.13 # Correct
> # kube_version: v1.32.13 # INCORRECT (leads to v1.32.13 -> vv1.32.13 errors)
> ```
> 4. **Host OS Lifecycle Constraint (Ubuntu 20.04 LTS)**:
> Kubespray `release-2.28` is the **final release line supporting Ubuntu 20.04**. Kubespray `release-2.29`+ drops Ubuntu 20.04 support and requires Ubuntu 22.04 or 24.04. Do not bump `VERSION` in `init.sh` beyond `release-2.28` until the host OS is upgraded.
---
## Quickstart
### 1. Environment Initialization
Source `init.sh` from this directory. It clones Kubespray, applies patches from `patches/`, configures the Python virtualenv, and syncs `./inventory/` into `./kubespray/inventory/`:
```sh ```sh
. ./init.sh source ./init.sh
``` ```
See [kubespray.io](https://kubespray.io/) on detailed information about kubespray. Though it seems to be a littlebit outdated. *(Note: `init.sh` also exports the `sync-inventory` alias to quickly sync local `./inventory/` modifications into `./kubespray/inventory/` without re-cloning).*
## Vagrant ---
## Step-by-Step Upgrade Runbook
Upgrades are conducted in two decoupled phases:
1. **Phase 1**: Upgrade the Kubespray tooling baseline while keeping the Kubernetes version unchanged $\rightarrow$ Apply $\rightarrow$ Verify.
2. **Phase 2**: Upgrade Kubernetes components (API server, controller manager, scheduler, kubelet, kube-proxy) $\rightarrow$ Apply $\rightarrow$ Verify.
### Step 0: Pre-Flight Checks & Backups
1. **Verify SSH and sudo access**:
```sh ```sh
cd kubespray ssh moritz@136.243.23.215 "sudo kubectl get nodes -o wide"
vagrant up
# up and abkle to ssh
vagrant ssh k8s-1
``` ```
## Prod 2. **Check Pod Health**:
Prepare server:
* deactivate swap!
* `moritz username ALL=(ALL) NOPASSWD:ALL`
* `
```sh ```sh
ssh centos@<ip> ssh moritz@136.243.23.215 "sudo kubectl get pods -A | grep -v -E 'Running|Completed'"
# auth via pw
sudo su - root
adduser moritz
visudo # add as sudo user
su - moritz
sudo yum -y install vim python3
ssh-keygen
vim .ssh/authorized_users # paste key
chmod 644 .ssh/authorized_keys
# check whether login works with ssh key
sudo vim /etc/ssh/sshd_config # remove pw auth & root login
sudo yum upgrade -y && sudo reboot
``` ```
Install Kubernetes: 3. **Take a Manual etcd Snapshot**:
```sh
ssh moritz@136.243.23.215 "sudo etcdctl \
--cacert=/etc/ssl/etcd/ssl/ca.pem \
--cert=/etc/ssl/etcd/ssl/node-haumdaucher.pem \
--key=/etc/ssl/etcd/ssl/node-haumdaucher-key.pem \
snapshot save /var/backups/etcd/etcd-pre-upgrade-\$(date +%Y%m%d%H%M%S).db"
```
---
### Step 1: Reconcile Kubespray Baseline (Phase 1)
1. Verify `VERSION` in `init.sh` matches the target Kubespray branch (e.g. `release-2.28`).
2. Run `source ./init.sh` to initialize the environment.
3. Apply the baseline configuration:
```sh ```sh
. ./init.sh
# follow instructions from output, sth like:
cd kubespray cd kubespray
ansible-playbook -i inventory/prod/inventory.ini cluster.yml ansible-playbook -i inventory/prod/inventory.ini cluster.yml
``` ```
4. Verify cluster health:
And get credentials:
```sh ```sh
ssh <ip> kubectl get nodes -o wide
sudo su - root kubectl get pods -n kube-system
cd
cp -r .kube /home/moritz/
chown -R moritz. /home/moritz/.kube
#ctrl + d
kubectl get ns # test connection
#ctrl + d
scp haumdaucher.de:/home/moritz/.kube/config .kube/config
``` ```
Foreward in k8s-directory. ---
## Upgrade cluster ### Step 2: Upgrade Kubernetes Version (Phase 2)
Check the current default value of `kube_version` in cloned repository. 1. In `bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`, update `kube_version`:
```yaml
kube_version: 1.32.13
```
**FOR K8S VERSION - Edit /Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml** 2. Sync the configuration into the `kubespray/` workspace:
```sh
sync-inventory
```
3. Run the in-place upgrade playbook:
```sh ```sh
cd kubespray cd kubespray
ansible-playbook -i inventory/prod/inventory.ini -e kube_version=v1.22.10 -e upgrade_cluster_setup=true cluster.yml
# or just the newest version
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
# upgrade to specific calico version (did not trigger/ failed)
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true -e calico_version=v3.15.2 cluster.yml --tags=network
``` ```
History: ---
* 2020-04-18 kube_version=v1.16.8 kubespray_branch=release-2.12 ### Step 3: Post-Upgrade Verification
## Add node
See [documentation](https://github.com/kubernetes-sigs/kubespray/blob/master/docs/nodes.md).
Note: This was more or less a trial and error approach. Running different playbooks over and over again got it right at some point.
1. **Check component versions**:
```sh ```sh
ansible-playbook -i inventory/prod/inventory.ini --limit=ns3088070.ip-37-59-40.eu,ns3100058.ip-37-59-61.eu scale.yml kubectl version
ansible-playbook -i inventory/prod/inventory.ini --limit=etcd,kube-master -e ignore_assert_errors=yes cluster.yml
``` ```
Ensure both `Client Version` and `Server Version` report `v1.32.13`.
This runs everything and is kind of idempotent: 2. **Verify Node status**:
```sh ```sh
ansible-playbook -i inventory/prod/inventory.ini cluster.yml kubectl get nodes -o wide
```
Ensure status is `Ready` and version matches `v1.32.13`.
3. **Verify System Pods**:
```sh
kubectl get pods -n kube-system
```
All control plane components (`kube-apiserver`, `kube-controller-manager`, `kube-scheduler`, `coredns`, `kube-flannel`, `kube-proxy`, `traefik`) should be `1/1 Running`.
4. **Verify Workloads**:
```sh
kubectl get pods -A | grep -v -E "Running|Completed"
``` ```
## Upgrade to 2.31.3 ---
Required to execute: ## Troubleshooting & Edge Cases
``` ### Kubeadm Preflight `CreateJob` Failure
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true -e drain_nodes=false upgrade-cluster.yml Kubeadm 1.31+ preflight executes an ephemeral `upgrade-health-check-...` batch job with a 15-second timeout. On single-node clusters or under load, this may exceed 15s. To prevent the upgrade from halting:
- `CreateJob` is included in `kubeadm_ignore_preflight_errors` in [k8s-cluster.yml](file:///Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml).
- The patch `patches/kubeadm-upgrade-ignore-preflight.patch` ensures Kubespray's `v1beta4` kubeadm configuration propagates this flag during `upgrade apply`.
### Kubelet Housekeeping Stalls / Static Pod Timeout
If `kubeadm upgrade apply` times out waiting for static pod hash changes:
- Check `journalctl -u kubelet -n 100` for `Housekeeping took longer than expected` or `orphaned pod ... failed to rmdir()`.
- If an old container unmount left a stale bind-mount busy, lazily detach it:
```sh
sudo umount -l /var/lib/kubelet/pods/<orphaned-pod-uid>/volumes/...
sudo systemctl restart kubelet
``` ```
+ set a feature flag: https://github.com/kubernetes-sigs/kubespray/issues/11887 ---
## Directory Layout
* `init.sh`: Environment bootstrapping script and upgrade instructions.
* `inventory/`: Source-of-truth inventory and variable files.
* `patches/`: Git patches applied to upstream Kubespray on checkout.
* `kubespray/`: (Ephemeral) Cloned Kubespray repository.
+65 -47
View File
@@ -1,60 +1,78 @@
#!/bin/sh #!/bin/sh
# check whether script is sourced # Ensure script is sourced, not executed directly
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "script is executed, but needs to be sourced" && exit 1 [[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "Error: init.sh must be sourced: source ./init.sh (or . ./init.sh)" && exit 1
echo "######################################################################################" BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "## Reinit repository" VERSION="release-2.28" # Note: release-2.28 is the final release line supporting Ubuntu 20.04 LTS
rm -rf kubespray
VERSION="release-2.27"
git clone --branch $VERSION https://github.com/kubernetes-sigs/kubespray.git
echo "######################################################################################" echo "======================================================================================"
echo "## Activating pyenv venv" echo "## [1/4] Checking / Cloning Kubespray ($VERSION)"
echo "======================================================================================"
if [ ! -d "$BOOTSTRAP_DIR/kubespray/.git" ]; then
rm -rf "$BOOTSTRAP_DIR/kubespray"
git clone --branch "$VERSION" --depth 1 https://github.com/kubernetes-sigs/kubespray.git "$BOOTSTRAP_DIR/kubespray"
else
echo "Existing kubespray checkout detected. Ensuring correct branch..."
git -C "$BOOTSTRAP_DIR/kubespray" fetch origin "$VERSION" --depth 1
git -C "$BOOTSTRAP_DIR/kubespray" checkout "$VERSION"
fi
if [ -d "$BOOTSTRAP_DIR/patches" ]; then
for p in "$BOOTSTRAP_DIR"/patches/*.patch; do
if [ -f "$p" ]; then
echo "## Applying patch $(basename "$p")..."
git -C "$BOOTSTRAP_DIR/kubespray" apply --check "$p" 2>/dev/null && git -C "$BOOTSTRAP_DIR/kubespray" apply "$p" || echo "Patch $(basename "$p") already applied or skipped."
fi
done
fi
echo "======================================================================================"
echo "## [2/4] Activating Python Virtualenv"
echo "======================================================================================"
eval "$(pyenv init -)" eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)" eval "$(pyenv virtualenv-init -)"
pyenv virtualenv 3.10.8 infrapuzzle-bootstrap pyenv virtualenvs --bare | grep -q "^infrapuzzle-bootstrap$" || pyenv virtualenv 3.10.8 infrapuzzle-bootstrap
pyenv activate infrapuzzle-bootstrap pyenv activate infrapuzzle-bootstrap
python -m pip install -r kubespray/requirements.txt python -m pip install -q -r "$BOOTSTRAP_DIR/kubespray/requirements.txt"
echo "======================================================================================"
echo "## [3/4] Syncing Inventory Configuration"
echo "======================================================================================"
rsync -a "$BOOTSTRAP_DIR/inventory/" "$BOOTSTRAP_DIR/kubespray/inventory/"
echo "######################################################################################" # Helper function for re-syncing changes made in ./inventory to ephemeral ./kubespray/inventory
echo "## Customizing vagrant dev env" sync-inventory() {
mkdir -p kubespray/vagrant rsync -av "$BOOTSTRAP_DIR/inventory/" "$BOOTSTRAP_DIR/kubespray/inventory/"
cat << EOF > kubespray/vagrant/config.rb }
\$instance_name_prefix = "k8s" export -f sync-inventory 2>/dev/null || true
\$vm_cpus = 4 alias sync-inventory="rsync -av '$BOOTSTRAP_DIR/inventory/' '$BOOTSTRAP_DIR/kubespray/inventory/'"
\$num_instances = 1
\$os = "centos"
\$subnet = "10.0.20"
\$network_plugin = "calico"
\$shared_folders = { 'temp/docker_rpms' => "/var/cache/yum/x86_64/7/docker-ce/packages" }
\$kube_node_instances_with_disks_number = 0
EOF
# make the rpm cache echo "======================================================================================"
mkdir -p kubespray/temp/docker_rpms echo "## [4/4] Environment Ready! Single-Node Cluster Upgrade Workflow"
echo "======================================================================================"
echo "###############"
echo "Now cd to kubespray and 'vagrant up'"
echo "" echo ""
echo "export KUBECONFIG=\"$( pwd )/kubespray/inventory/sample/artifacts/admin.conf\"" echo "Current directory changed to: $BOOTSTRAP_DIR/kubespray"
cd "$BOOTSTRAP_DIR/kubespray"
echo ""
echo "######################################################################################" echo "=== PRE-UPGRADE CHECKLIST ==="
echo "## * syncing config in kubespray dir" echo "1. Verify SSH connection and sudo access:"
rsync -a ./inventory/ ./kubespray/inventory/ echo " ssh moritz@136.243.23.215 'sudo kubectl get nodes -o wide'"
echo "## * changing into kubespray dir" echo "2. Take manual etcd snapshot before upgrading:"
cd kubespray echo " ssh moritz@136.243.23.215 'sudo etcdctl --cacert=/etc/ssl/etcd/ssl/ca.pem --cert=/etc/ssl/etcd/ssl/node-haumdaucher.pem --key=/etc/ssl/etcd/ssl/node-haumdaucher-key.pem snapshot save /var/backups/etcd/etcd-pre-upgrade-\$(date +%Y%m%d%H%M%S).db'"
echo "## * execute the following command to force new settings" echo ""
echo "=== TWO-PHASE UPGRADE WORKFLOW ==="
echo "Phase 1: Reconcile Kubespray baseline (keeps current k8s version):"
echo " ansible-playbook -i inventory/prod/inventory.ini cluster.yml" echo " ansible-playbook -i inventory/prod/inventory.ini cluster.yml"
echo "## * execute the following command to force new settings with forced cluster upgrade" echo ""
echo "Phase 2: Upgrade Kubernetes version (e.g. to 1.32.13):"
echo " a) Update 'kube_version: <target>' in ../inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml"
echo " b) Sync inventory: sync-inventory"
echo " c) Run in-place single-node upgrade:"
echo " ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml" echo " ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml"
echo ""
# cp -r kubespray/inventory/sample kubespray/inventory/prod echo "=== POST-UPGRADE VERIFICATION ==="
# rm kubespray/inventory/prod/inventory.ini echo " kubectl version"
# cp ./prod.ini kubespray/inventory/prod/inventory.ini echo " kubectl get nodes -o wide"
#gsed -i "s/kube_network_plugin: .*/kube_network_plugin: flannel/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml echo " kubectl get pods -A | grep -v -E 'Running|Completed'"
# gsed -i "s/# calico_iptables_backend: \"Legacy\"/calico_iptables_backend: \"NFT\"/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-calico.yml echo "======================================================================================"
#echo 'calico_iptables_backend: "NFT"' >> ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-cluster.yml
#gsed -i "s/metrics_server_enabled: .*/metrics_server_enabled: true/" ./kubespray/inventory/prod/group_vars/k8s-cluster/addons.yml
@@ -27,7 +27,17 @@ kube_kubeadm_apiserver_extra_args:
kube_api_anonymous_auth: true kube_api_anonymous_auth: true
## Change this to use another Kubernetes version, e.g. a current beta release ## Change this to use another Kubernetes version, e.g. a current beta release
kube_version: v1.31.9 kube_version: 1.32.13
# Single-node cluster settings: prevent draining or cordoning the only node
drain_nodes: false
upgrade_node_always_cordon: false
upgrade_node_fail_if_drain_fails: false
kubeadm_ignore_preflight_errors:
- ImagePull
- SystemVerification
- FileContent--proc-sys-net-bridge-bridge-nf-call-iptables
- CreateJob
# kubernetes image repo define # kubernetes image repo define
#kube_image_repo: "k8s.gcr.io" #kube_image_repo: "k8s.gcr.io"
@@ -0,0 +1,20 @@
diff --git a/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml b/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
index 523956d1d..75723a6e0 100644
--- a/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
+++ b/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
@@ -17,6 +17,7 @@
--force
{%- else %}
--config={{ kube_config_dir }}/kubeadm-config.yaml
+ --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
{%- endif %}
{%- if kube_version is version('1.32.0', '>=') %}
--skip-phases={{ kubeadm_init_phases_skip | join(',') }}
@@ -38,6 +39,7 @@
{% if kubeadm_patches | length > 0 %}--patches={{ kubeadm_patches_dir }}{% endif %}
{%- else %}
--config={{ kube_config_dir }}/kubeadm-config.yaml
+ --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
{%- endif %}
--skip-phases={{ kubeadm_upgrade_node_phases_skip | join(',') }}
register: kubeadm_upgrade
+5 -2
View File
@@ -82,9 +82,12 @@ OpenClaw connects to three foundational Git repositories:
--- ---
## 🚨 Troubleshooting & Live Verification ## 🚨 Troubleshooting & Incident Playbook
If the agent is offline or failing: > [!IMPORTANT]
> **Mandatory Bug & Incident Documentation**: Whenever a bug, outage, schema error, or operational regression is fixed in OpenClaw, **always document the incident in [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)** with symptom, root cause, resolution, and how to avoid recurrence.
For known issues, resolved bugs, and specific failure playbooks, consult [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).
### 1. Verify Deployment Health ### 1. Verify Deployment Health
Ensure that the `OpenClawInstance` is successfully reconciled: Ensure that the `OpenClawInstance` is successfully reconciled:
+90
View File
@@ -0,0 +1,90 @@
# OpenClaw Troubleshooting & Incident Playbook
This document captures fixed issues, operational quirks, and incident root-cause analyses for the **OpenClaw** deployment on Kubernetes.
> [!IMPORTANT]
> **Rule for Agents & Operators**: Whenever you fix a bug, diagnose an operational failure, or resolve a configuration pitfall in OpenClaw, **document it here immediately**. This prevents recurring regressions and serves as the single source of truth for troubleshooting.
---
## 📋 Catalog of Incidents & Fixed Issues
| Date | Category | Summary / Symptom | Status | Key Resolution |
| :--- | :--- | :--- | :---: | :--- |
| **2026-09-20** | **Timeout & Execution** | Telegram `#IT Guy` (Topic 119) and `#Recherche` (Topic 117) request timed out before response generated (`timeoutSeconds` exceeded) | `RESOLVED` | Raised global `agents.defaults.timeoutSeconds` from 600 to 1800 (30 min). |
| **2026-09-20** | **Config Schema Validation** | Pod crash with `CrashLoopBackOff`: `agents.entries.<id>: Unrecognized key: "timeoutSeconds"` | `RESOLVED` | `timeoutSeconds` is strictly global under `agents.defaults.timeoutSeconds`; do not place it under `agents.entries.*`. |
| **2026-09-24** | **Database & Startup Latency** | Gateway crash/restart during initialization: `slow OpenClaw agent database open` leading to Startup/Liveness probe failures | `RESOLVED` | Probe budget vs WAL checkpointing on large SQLite files (~1.5GB across subagents). |
---
## 🔍 Detailed Incident Records
### Issue 1: Telegram Agent Request Timeout (`agents.defaults.timeoutSeconds`)
- **Date**: 2026-09-20
- **Severity**: High (User-facing timeout in Telegram topics `#IT Guy` and `#Recherche`)
- **Symptom**:
Telegram replies with error message:
```text
Request timed out before a response was generated. Please try again, or increase agents.defaults.timeoutSeconds in your config.
```
- **Root Cause**:
The IT Guy agent (`itguy`) performs deep, sequential DevOps investigations (multiple `exec` / `kubectl` calls, file writes, and PR operations) using `deepseek/deepseek-v4.1-flash`. Similarly, `#Recherche` runs deep research chains. The global default was set to `timeoutSeconds: 600` (10 minutes), causing active, non-stuck runs to be killed mid-flight.
- **Resolution**:
In [`k8s/openclaw/openclaw-instance.secret.yaml`](file:///Users/moritz/src/infrapuzzle/k8s/openclaw/openclaw-instance.secret.yaml), configure:
```yaml
spec:
config:
raw:
agents:
defaults:
timeoutSeconds: 1800 # 30 minutes
```
- **How to Avoid Recurrence**:
Always verify timeout requirements against the longest-running task types (multi-step K8s audits and deep research). Never assume 10 minutes is sufficient for multi-turn tool loops.
---
### Issue 2: Schema Crash on Per-Agent Timeout Override
- **Date**: 2026-09-20
- **Severity**: Critical (Deployment blocked with `CrashLoopBackOff`)
- **Symptom**:
Gateway container fails during startup:
```text
Gateway failed to start: Invalid config at /home/openclaw/.openclaw/openclaw.json:
openclaw.json:169 — agents.entries.itguy: Unrecognized key: "timeoutSeconds"
```
- **Root Cause**:
OpenClaw’s JSON schema strict validator does not permit `timeoutSeconds` inside individual agent entries under `agents.entries.<name>`.
- **Resolution**:
Remove `timeoutSeconds` from `agents.entries.*`. Configure it strictly at the root of `agents.defaults.timeoutSeconds`.
- **How to Avoid Recurrence**:
Before adding new keys to agent configurations in `openclaw-instance.secret.yaml`, inspect the upstream OpenClaw schema or test locally to ensure the key is recognized under that exact path.
---
### Issue 3: Startup Probe Failures due to Slow SQLite Database Initialization
- **Date**: 2026-09-24
- **Severity**: High (Intermittent container kills on gateway restart)
- **Symptom**:
Container `openclaw` fails startup or liveness probes, causing kubelet to restart the container:
```text
Liveness probe failed: Get "http://...:18790/healthz": context deadline exceeded
Startup probe failed: context deadline exceeded / connection reset by peer
```
Logs display repeated warnings:
```text
[state/agent-db] slow OpenClaw agent database open
```
- **Root Cause**:
OpenClaw maintains separate SQLite databases for each specialist subagent (`agents/*/agent/openclaw-agent.sqlite`). Over time, databases and uncommitted WAL logs accumulate significant size:
- `agents/main/agent/openclaw-agent.sqlite`: >220 MB
- `agents/fitness/`, `agents/itguy/`, `agents/hebamme/`, `agents/recherche/`: >100 MB each + multi-megabyte WAL files.
On startup, SQLite WAL recovery and integrity passes across 10+ databases on single-node hostpath storage can take several minutes. If this exceeds the `startupProbe` limit (`failureThreshold * periodSeconds`), Kubernetes kills the container before it finishes opening databases.
- **Resolution**:
1. Ensure `probes.startup.failureThreshold` in `openclaw-instance.secret.yaml` allows adequate startup headroom (e.g. at least 120 attempts with 5s period = 600s).
2. Periodically ensure SQLite VACUUM / WAL checkpoints run during maintenance windows or let the database open without interrupting it.
- **How to Avoid Recurrence**:
Monitor SQLite database sizes under `/home/openclaw/.openclaw/agents/*/agent/` and avoid overly aggressive liveness/startup probe kill thresholds during gateway initialization.
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sasch40
labels:
app: sasch40
namespace: web
spec:
selector:
matchLabels:
app: sasch40
replicas: 1
template:
metadata:
labels:
app: sasch40
spec:
imagePullSecrets:
- name: "registry-haumdaucher-de"
containers:
- image: registry.haumdaucher.de/sasch40:latest
imagePullPolicy: Always
name: sasch40
ports:
- containerPort: 80
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: sasch40
namespace: web
spec:
ports:
- port: 80
targetPort: 80
protocol: TCP
selector:
app: sasch40
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sasch40
namespace: web
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- "sasch40.haumdaucher.de"
secretName: sasch40-haumdaucher-de-tls
rules:
- host: sasch40.haumdaucher.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sasch40
port:
number: 80
+151
View File
@@ -0,0 +1,151 @@
# AGENTS.md — Saschas Kronkorken Kreuzzug (sasch40)
> [!NOTE]
> **Domain**: Mobile-first 2.5D retro birthday adventure game for King Sascha I.'s 40th jubilee.
> **Live Production URL**: `https://sasch40.haumdaucher.de`
> **Kubernetes Manifest**: `k8s/web/sasch40.yaml` (Namespace: `web`)
> **Source Code**: `projects/sasch40/frontend/`
> **Reference Photos**: `projects/sasch40/source_photos/`
---
## 🏛️ Architectural Decisions & Non-Negotiable Rules
This document records the foundational design decisions, their rationale ("Why"), and constraints for future AI coding agents. **Do not regress or remove these patterns.**
### 1. QR-Code Landing & Atmospheric Prequel (`IntroScreen.vue`)
* **The Decision**: The application MUST ALWAYS start with an informative, atmospheric intro/lore screen before any gameplay begins.
* **Why**: Party guests scan a physical QR code on beer bottles or tables with their smartphones. They do not know what the link opens. Without an introductory landing page, users are confused when suddenly dropped into an arcade mini-game.
* **Content Requirements**:
* Title: *„Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft“*.
* Royal Avatar: King Sascha I. with sunglasses and golden crown.
* Lore: Explains the 40-year reign over Haumdaucher, 135 BPM techno culture, the theft of the 40 sacred Kronkorken, and a preview of the upcoming acts.
* CTA: Prominent button *„SPIEL STARTEN / RETTE DAS JUBILÄUM!“*.
* Audio prompt: *„Smartphone-Lautstärke aufdrehen für echten 135-BPM-Mittelalter-Techno!“*.
---
### 2. Dual Game Modes & Dynamic Chassis Layout (`GameBoyFrame.vue`)
* **The Decision**: The GameBoy physical controls (D-Pad and A/B action buttons) MUST dynamically hide and show based on the active game mode.
* **Why**: The game alternates between arcade mini-games (Jump & Run, Party Express) and point-and-click / visual-novel adventure scenes (Prologue, Tavern, Finale Certificate). Displaying a chunky D-Pad and large A/B buttons during dialogue or tavern exploration wastes mobile screen space and ruins immersion.
* **The Rule**:
* **Arcade Acts** (`currentAct === 'platformer'` or `'party_express'`):
* Show the GameBoy D-Pad and A/B action buttons (`arcade-layout`).
* Display the arcade HUD with Korken, Helles, and 135 BPM counter.
* **Adventure / Dialogue Acts** (`currentAct === 'intro'`, `'prologue'`, `'tavern'`, or `'finale'`):
* Hide the D-Pad and A/B buttons completely (`adventure-layout`).
* Display a sleek mini bottom bar with current Korken, Helles, and an inventory drawer button (*„🎒 Beutel“*).
* On `currentAct === 'finale'`, hide even the mini bar so the certificate occupies the full screen.
---
### 3. Protagonist System & Extensibility (`characters.ts`)
* **The Decision**: All character portraits are strictly 1:1 square pixel-art illustrations based directly on real-life friends and family photos, maintained in a centralized registry.
* **Why**: Guests at the party must immediately recognize themselves and their friends. Previous attempts at generic AI portraits caused confusion because hair, beards, and styles did not match the actual people.
* **Reference Photos**:
* Stored in `projects/sasch40/source_photos/` (`sascha_*.jpg`, `nat.jpg`, `lutz*.jpg`, `amelie.jpg`, `armend.jpg`, `moritz2.jpg`, etc.).
* **In-Game Portraits**:
* Stored in `projects/sasch40/frontend/public/assets/characters/<id>/portrait.png`.
* **How to Add or Modify Protagonists**:
1. Add a 1:1 square pixel-art portrait into `public/assets/characters/<id>/portrait.png`.
2. Register the protagonist in `projects/sasch40/frontend/src/config/characters.ts`:
```typescript
export const CHARACTERS: Record<string, CharacterConfig> = {
neuer_gast: {
id: 'neuer_gast',
name: 'Name (Spitzname)',
title: 'Königlicher Hof-Titel',
portrait: '/assets/characters/neuer_gast/portrait.png',
quote: 'Charakteristischer Spruch'
}
}
```
3. Add an interactive hotspot or convoy sprite in `TavernView.vue` or `PartyExpressView.vue`.
* **Portrait Display Rule**: Always use `aspect-ratio: 1 / 1` and `object-fit: cover` with rounded borders (`border-radius: 8px` or `50%` for badges). Never allow portrait stretching or horizontal letterboxing.
---
### 4. Background Art Integrity & Cleanliness
* **The Decision**: Background scenes must remain clean of any AI-hallucinated pseudo-English text or stray UI elements.
* **Why**: AI image generators often bake random adventure-game dialogue boxes or text (e.g. `[O] I have to tell you, the glow-wurst is excellent!`) into the scene.
* **The Rule**:
* `public/assets/scenes/tavern.jpg`: The bottom area is cleaned with a seamless dark oak wood beam and floor texture.
* Any scene footer navigation (like `.scene-bottom-nav`) must be styled as natural wooden wainscoting with clear German labels.
---
### 5. Mobile Canvas Safe-Zones & Responsive HUDs
* **The Decision**: Critical game HUD elements (score, act title, instructions) must either be drawn as HTML/CSS overlays or placed within a minimum safe margin of 24px on canvas.
* **Why**: Smartphones vary widely in screen aspect ratio (16:9, 19.5:9, 20:9). When a 360x480 canvas is rendered with `object-fit: cover`, the left and right (or top and bottom) edges can be cropped by 10–25px on narrow screens.
* **The Rule**:
* In `PlatformerView.vue`: The Korken counter and level title are rendered via an absolute HTML overlay (`.platformer-hud-top`) so they are 100% immune to canvas cropping.
* In `PartyExpressView.vue`: The banner title is centered (`ctx.textAlign = 'center'`), and the progress bar has symmetrical 24px padding.
---
### 6. Royal Certificate & Competitive Highscore (`CertificateModal.vue`)
* **The Decision**: The game concludes with a royal certificate featuring a competitive point breakdown and rank.
* **Why**: Party guests compare scores at the bar to determine who is the ultimate party knight.
* **Score Breakdown**:
* Kronkorken collected: `100 pts` each.
* Helles tapped: `250 pts` each.
* Guests served in Party Express: `300 pts` each.
* Gstanzl duel victory: `1,500 pts`.
* Discovered secrets (Bridgerton dialogue, Zuluman mirror): `500 pts` each.
* **Ranks**:
* `S-Rang (Legendär)`: >= 11,000 points (*„Unsterblicher Großfürst des Basses“*).
* `A-Rang (Erhaben)`: >= 9,500 points (*„Meister-Brauer der königlichen Garde“*).
* `B-Rang (Ritterlich)`: >= 7,500 points (*„Ehren-Ritter der Hopfen-Manufaktur“*).
* `C-Rang (Trinkfest)`: < 7,500 points (*„Kneipen-Knappe im Ausnüchterungs-Dienst“*).
* **Call-to-Action**: Includes a screenshot / print button (*„📸 URKUNDE DRUCKEN / SCREENSHOT“*) and Queen Verena's secret postscript.
---
### 7. Zero-Dependency Web Audio Synthesizer (`sound.ts`)
* **The Decision**: All sound effects, fanfares, and the 135 BPM techno bassline are synthesized in pure code using the Web Audio API.
* **Why**: External audio files (.mp3/.wav) take megabytes of bandwidth, stutter on mobile cellular networks, and trigger browser autoplay blocks.
* **The Rule**: The `SoundService` initializes on the first user interaction (touch/click). Sound effects use oscillators and gain envelopes; 135 BPM kick and sub-bass use recurring periodic intervals.
---
## 🛠️ Build, Test & Deployment Workflow
### Fast Docker Packaging (Local ARM64 Compile)
To avoid 10-minute QEMU emulation hangs when building `linux/amd64` images on Apple Silicon Macs, the frontend is compiled locally on the host machine before Docker copies the static output:
```bash
cd projects/sasch40/frontend
# 1. Compile natively (< 2 seconds)
npm run build
# 2. Package, push & deploy to k8s (< 10 seconds total)
make all
```
The `Makefile` targets:
* `compile`: Runs `npm run build` (`vue-tsc -b && vite build`).
* `build`: Runs `docker build --platform linux/amd64 -t sasch40:latest .` (copies `dist/` directly into `nginx:alpine`).
* `tag`: Tags as `registry.haumdaucher.de/sasch40:latest`.
* `push`: Pushes to the Haumdaucher registry.
* `deploy`: Executes `kubectl rollout restart deployment/sasch40 -n web` and monitors rollout status.
---
### Automated Headless Chrome Playtest
Before considering any change complete, run the automated Puppeteer playtest script:
```bash
cd projects/sasch40/frontend
node scripts/playtest.mjs
```
This script:
1. Navigates to `https://sasch40.haumdaucher.de`.
2. Verifies the Intro Screen and clicks Start.
3. Simulates dialogue options on the Castle Balcony (Prologue).
4. Tests movement and jump controls in Act II (Hopfen-Pfade).
5. Interacts with hotspots, dialogue modals, and the Gstanzl duel in Act III (Taverne).
6. Advances through the Party Express and reaches the Royal Certificate.
7. Captures 10 screenshots to verify zero visual regressions.
8. Asserts zero console errors, zero failed HTTP requests, and zero broken images.
+60
View File
@@ -0,0 +1,60 @@
# Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft 👑🍺🔊
Ein mobiles 2.5D Pixel-Art Web-Game zum 40. Geburtstag von König Sascha I.
Gehostet unter: `https://sasch40.haumdaucher.de`
---
## 🌟 Spielinhalt & Akte
1. **Prolog (Schloss Haumdaucher Balkon)**: NAT berichtet als Hof-Chronist (mit Bridgerton-Referenzen) vom Verlust der 40 Kronkorken des Reiches. Mop der Erschaffer schaltet die Sprungfedern frei.
2. **Level 1 (Die Hopfen-Pfade)**: 60fps Casual-Jump&Run auf Dächern und Fässern zum Sammeln von Kronkorken und kühlem Hellen.
3. **Die Taverne 'Zum wummernden Ritter'**: Point & Click Adventure mit interaktiven Charakteren:
- Verena (*„Ich putze nach der Party fei ned!“*)
- Armend (schmiedet Kronkorken zum goldenen Subwoofer)
- Lutz im ZTUL-Shirt (Schornsteinfeger)
- Amy in Latzhose (*„Rave the Kingdom!“*)
- Mane (Wander- und Spieletipps)
- Pü (Lichtmeister mit Stroboskop-Fackeln)
- Der Zuluman-Zauberspiegel (Urlaubs-Easter-Egg)
- Bayerisches Beleidigungs-Gstanzl-Fechten
4. **Level 2 (Der Fest-Konvoi)**: Speed-Runner auf dem Bollerwagen mit 135-BPM-Techno und dem jubelnden Haumdaucher Gefolge (Hansi, Viktor, Albin, Fabian, Maier Flo, Guatemalteke, Markus & Nathalie).
5. **Finale & Krönung**: Thronsaal mit Konfetti-Explosion, Auswertung und druck-/screenshotbarer königlicher Urkunde.
---
## 🖼️ Hot-Swappable Asset-Pipeline (Bilder austauschen)
Alle Charakter-Bilder und Sprites sind vollständig vom Code entkoppelt:
* **Bilder-Ordner**: `projects/sasch40/frontend/public/assets/characters/`
* `sascha/portrait.jpg` (oder `.png`)
* `nat/portrait.jpg`
* `verena/portrait.jpg`
* `mop/portrait.jpg`
* `lutz/portrait.jpg`
* `armend/portrait.jpg`
* `amy/portrait.jpg`
* **Originalfotos**: Liegen in `projects/sasch40/source_photos/`
* **Zentrales Register**: Falls ein Dateipfad oder Name angepasst werden soll, geschieht das einfach in `projects/sasch40/frontend/src/config/characters.ts`.
---
## 🚀 Lokale Entwicklung
```bash
cd projects/sasch40/frontend
npm install
npm run dev
# Browser öffnen unter: http://localhost:3000
```
---
## 🐳 Docker & Kubernetes Deployment
```bash
cd projects/sasch40/frontend
make all # Baut und pusht registry.haumdaucher.de/sasch40:latest
# Ingress & Deployment im Cluster anwenden:
kubectl apply -f ../../../k8s/web/sasch40.yaml
```
+5
View File
@@ -0,0 +1,5 @@
node_modules
.git
scripts
*.log
.DS_Store
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
*.local
+8
View File
@@ -0,0 +1,8 @@
FROM nginx:alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+29
View File
@@ -0,0 +1,29 @@
REGISTRY ?= registry.haumdaucher.de
IMAGE_NAME ?= sasch40
TAG ?= latest
PLATFORM ?= linux/amd64
IMAGE ?= $(REGISTRY)/$(IMAGE_NAME):$(TAG)
.PHONY: build tag push all dev compile deploy
all: compile build tag push deploy
dev:
npm run dev
compile:
npm run build
build:
docker build --platform $(PLATFORM) -t $(IMAGE_NAME):$(TAG) .
tag:
docker tag $(IMAGE_NAME):$(TAG) $(IMAGE)
push:
docker push $(IMAGE)
deploy:
kubectl rollout restart deployment/sasch40 -n web
kubectl rollout status deployment/sasch40 -n web
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/ui/crown.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#2b1810" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft - Das epische Geburtstagsspiel" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
<title>Saschas Kronkorken Kreuzzug – 40 Jahre Bass & Gerstensaft</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
location / {
try_files $uri $uri/ /index.html;
}
# Static unhashed images, audio, and media - never cache to allow instant updates
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|mp3|wav|ogg)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires -1;
}
# Vite bundled hashed JS/CSS assets
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "sasch40-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"canvas-confetti": "^1.9.4",
"lucide-vue-next": "^0.468.0",
"pinia": "^2.3.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@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.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="20" fill="#2b1810"/>
<path d="M20 75 L80 75 L85 40 L65 55 L50 25 L35 55 L15 40 Z" fill="#ffd700" stroke="#b8860b" stroke-width="3"/>
<circle cx="50" cy="25" r="5" fill="#e63946"/>
<circle cx="15" cy="40" r="4" fill="#457b9d"/>
<circle cx="85" cy="40" r="4" fill="#457b9d"/>
<!-- Sunglasses -->
<rect x="32" y="60" width="16" height="10" rx="3" fill="#111"/>
<rect x="52" y="60" width="16" height="10" rx="3" fill="#111"/>
<rect x="48" y="63" width="4" height="3" fill="#111"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

@@ -0,0 +1,18 @@
{
"name": "Saschas Kronkorken Kreuzzug",
"short_name": "Sasch40",
"description": "40 Jahre Bass & Gerstensaft – Das epische Thronjubiläum",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#1a0f0a",
"theme_color": "#d4af37",
"icons": [
{
"src": "/assets/ui/crown.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
@@ -0,0 +1,390 @@
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 Comprehensive 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: 35000 });
// 1. INTRO / PREQUEL SCREEN
console.log('📸 01: Capturing Intro Screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '01_intro_screen.png') });
// Click Start Button
console.log('🖱️ Clicking Start Button...');
await page.click('.start-btn');
await new Promise(r => setTimeout(r, 1000));
// 2. PROLOGUE SCREEN (Balcony Scene)
console.log('📸 02: Capturing Prologue Screen (Verifying banner/portrait alignment)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02_prologue_screen.png') });
// Advance Prologue Dialogues
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('📸 02b: Capturing Prologue Mop Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02b_prologue_mop.png') });
}
// Advance to Platformer
const mopChoice = await page.$('.choice-btn');
if (mopChoice) {
await mopChoice.click();
await new Promise(r => setTimeout(r, 1000));
}
// 3. PLATFORMER PRE-LEVEL START SCREEN (Act II)
console.log('📸 03: Capturing Platformer Start Screen Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '03_platformer_start_modal.png') });
// Click Start Act Button on the modal
const startPlatformerBtn = await page.$('.start-act-btn');
if (startPlatformerBtn) {
await startPlatformerBtn.click();
await new Promise(r => setTimeout(r, 800));
}
// 4. PLATFORMER GAMEPLAY
console.log('📸 04: Capturing Platformer Gameplay...');
const btnA = await page.$('.btn-a');
if (btnA) {
await btnA.click();
await new Promise(r => setTimeout(r, 300));
}
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '04_platformer_gameplay.png') });
// 5. TAVERN: SCHANKRAUM (Gaststube)
console.log('🍺 Advancing to Tavern Act (Schankraum)...');
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('tavern');
}
}
});
await new Promise(r => setTimeout(r, 1000));
console.log('📸 05: Capturing Tavern Gaststube (Schankraum)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '05_tavern_schankraum.png') });
// Test Verena Hotspot
console.log('👥 Testing Verena Dialog...');
const verenaBtn = await page.$('.hotspot-verena');
if (verenaBtn) {
await verenaBtn.click();
await new Promise(r => setTimeout(r, 700));
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '06_tavern_verena_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// Test Armend Hotspot
console.log('🔨 Testing Armend Dialog...');
const armendBtn = await page.$('.hotspot-armend');
if (armendBtn) {
await armendBtn.click();
await new Promise(r => setTimeout(r, 700));
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '07_tavern_armend_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// 6. TAVERN: MARKTPLATZ (Mario with spinning chainsaws)
console.log('🎪 Navigating to Marktplatz...');
const marktplatzTab = await page.$('.room-tab:nth-child(2)');
if (marktplatzTab) {
await marktplatzTab.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 08: Capturing Marktplatz (Mario chainsaws)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '08_tavern_marktplatz.png') });
// Click Mario
const marioBtn = await page.$('.hotspot-mario');
if (marioBtn) {
await marioBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 09: Capturing Mario Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09_tavern_mario_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// Click NAT (Josef)
const natBtn = await page.$('.hotspot-nat');
if (natBtn) {
await natBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 09b: Capturing NAT (Josef) Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09b_tavern_nat_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
}
// 7. TAVERN: HINTERLAND (Mane hiking)
console.log('🌲 Navigating to Hinterland...');
const hinterlandTab = await page.$('.room-tab:nth-child(3)');
if (hinterlandTab) {
await hinterlandTab.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 10: Capturing Hinterland (Mane hiking)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '10_tavern_hinterland.png') });
// Click Mane
const maneBtn = await page.$('.hotspot-mane');
if (maneBtn) {
await maneBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 11: Capturing Mane Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11_tavern_mane_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
}
// 8. TAVERN: SECRET HINTERZIMMER (VIP Rave via Secret Tap)
console.log('🗝️ Returning to Schankraum to check Secret Tap (initially invisible)...');
const schankraumTab = await page.$('.room-tab:nth-child(1)');
if (schankraumTab) {
await schankraumTab.click();
await new Promise(r => setTimeout(r, 600));
}
console.log('🪩 Triggering Secret Tap Easter Egg...');
const secretTap = await page.$('.secret-tap-hotspot');
if (secretTap) {
await secretTap.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 12: Capturing Secret VIP Hinterzimmer...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '12_tavern_hinterzimmer_vip.png') });
// Click Pü in Hinterzimmer
const pueBtn = await page.$('.hotspot-pue');
if (pueBtn) {
await pueBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 13: Capturing Pü Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '13_tavern_pue_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
}
// 9. ZULUMAN MAGIC MIRROR & GSTANZL DUEL
console.log('🪞 Testing Zuluman Magic Mirror...');
const backToGaststube = await page.$('.hotspot-door-back-vip');
if (backToGaststube) {
await backToGaststube.click();
await new Promise(r => setTimeout(r, 600));
}
const mirrorBtn = await page.$('.hotspot-mirror');
if (mirrorBtn) {
await mirrorBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 14: Capturing Zuluman Mirror...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '14_tavern_zuluman_mirror.png') });
const closeBtn = await page.$('.mirror-box .action-btn');
if (closeBtn) await closeBtn.click();
await new Promise(r => setTimeout(r, 400));
}
console.log('⚔️ Testing Gstanzl-Duel and [✕] Exit Button...');
const duelBtn = await page.$('.duel-trigger');
if (duelBtn) {
await duelBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 15: Capturing Gstanzl Duel with [✕] button...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '15_tavern_gstanzl_duel.png') });
// Test the [✕] close button
const closeDuelBtn = await page.$('.duel-close-btn');
if (closeDuelBtn) {
console.log('Testing duel exit via [✕] button...');
await closeDuelBtn.click();
await new Promise(r => setTimeout(r, 600));
const duelClosed = await page.$('.duel-box') === null;
console.log('Duel modal closed successfully by [✕]:', duelClosed);
}
}
// Test reload persistence in Tavern Act
console.log('💾 Testing game state reload persistence...');
const stateBeforeReload = await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
const pinia = app?.config?.globalProperties?.$pinia;
const s = pinia?._s.get('game');
return s ? { currentAct: s.currentAct, kronkorken: s.kronkorken, beers: s.beers } : null;
});
console.log('State before reload:', stateBeforeReload);
await page.reload({ waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 1000));
const stateAfterReload = await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
const pinia = app?.config?.globalProperties?.$pinia;
const s = pinia?._s.get('game');
return s ? { currentAct: s.currentAct, kronkorken: s.kronkorken, beers: s.beers } : null;
});
console.log('State after reload:', stateAfterReload);
// 10. 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('📸 16: Capturing Party Express Start Screen Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '16_party_express_start_modal.png') });
// Start Party Express
const startExpressBtn = await page.$('.start-act-btn');
if (startExpressBtn) {
await startExpressBtn.click();
await new Promise(r => setTimeout(r, 800));
}
console.log('📸 17: Capturing Party Express Gameplay (Redesigned Bollerwagen)...');
// Serve beer
const throwBeerBtn = await page.$('.throw-beer-btn');
if (throwBeerBtn) {
await throwBeerBtn.click();
await new Promise(r => setTimeout(r, 400));
}
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '17_party_express_gameplay.png') });
// 11. 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(35);
store.serveGuest();
store.serveGuest();
store.setAct('finale');
}
}
}
});
await new Promise(r => setTimeout(r, 1500));
console.log('📸 18: Capturing Certificate Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '18_certificate_screen.png') });
// Test NOCHMAL SPIELEN button
console.log('🔄 Testing NOCHMAL SPIELEN button...');
const restartBtn = await page.$('.restart-btn');
if (restartBtn) {
await restartBtn.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 19: Capturing post-restart Intro screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '19_post_restart_intro.png') });
}
// Verify all images loaded properly
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:', 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();
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import GameBoyFrame from './components/GameBoyFrame.vue'
</script>
<template>
<main class="app-root">
<GameBoyFrame />
</main>
</template>
<style scoped>
.app-root {
width: 100vw;
height: 100dvh;
display: flex;
justify-content: center;
align-items: center;
background: #0f0a07;
overflow: hidden;
}
</style>
@@ -0,0 +1,292 @@
// Procedural 8-bit chiptune & medieval techno sound engine using Web Audio API
class SoundEngine {
private ctx: AudioContext | null = null
private isMuted: boolean = false
private bgmPlaying: boolean = false
private bgmTimer: number | null = null
private step: number = 0
private initCtx() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
this.ctx = new AudioCtx()
}
if (this.ctx.state === 'suspended') {
this.ctx.resume()
}
}
public toggleMute(): boolean {
this.isMuted = !this.isMuted
if (this.isMuted) {
this.stopBGM()
} else {
this.startBGM()
}
return this.isMuted
}
public getMuted(): boolean {
return this.isMuted
}
public setMuted(val: boolean) {
this.isMuted = val
if (this.isMuted) {
this.stopBGM()
}
}
// SFX: Kronkorken Collect (bright double ding)
public playCoin() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(987.77, now) // B5
osc.frequency.setValueAtTime(1318.51, now + 0.08) // E6
gain.gain.setValueAtTime(0.2, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.35)
}
// SFX: Sprung / Jump (arcade sweep up)
public playJump() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'square'
osc.frequency.setValueAtTime(150, now)
osc.frequency.exponentialRampToValueAtTime(600, now + 0.15)
gain.gain.setValueAtTime(0.15, now)
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.15)
}
// SFX: Beer Gulp / Power-up
public playGulp() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(400, now)
osc.frequency.exponentialRampToValueAtTime(180, now + 0.25)
gain.gain.setValueAtTime(0.2, now)
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.25)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.25)
}
// SFX: Dialog blip (Monkey Island text chatter)
public playDialogueBlip() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'sine'
const freqs = [320, 360, 420, 480]
const randomFreq = freqs[Math.floor(Math.random() * freqs.length)]
osc.frequency.setValueAtTime(randomFreq, now)
gain.gain.setValueAtTime(0.08, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.05)
}
// SFX: Fanfare / Royal Crowning
public playFanfare() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const notes = [261.63, 329.63, 392.00, 523.25] // C, E, G, C
notes.forEach((freq, idx) => {
if (!this.ctx) return
const now = this.ctx.currentTime + idx * 0.12
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(freq, now)
gain.gain.setValueAtTime(0.25, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + (idx === 3 ? 0.8 : 0.2))
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + (idx === 3 ? 0.8 : 0.25))
})
}
// 135 BPM Medieval Techno Chiptune Beat
public startBGM() {
if (this.isMuted || this.bgmPlaying) return
this.initCtx()
this.bgmPlaying = true
// 135 BPM = 135 beats/min = 2.25 beats/sec -> 16th note = 60 / (135 * 4) = 0.111s
const stepDuration = (60 / 135) / 2 // 8th note = 0.222s
this.step = 0
// Medieval Dorian melody scale (D, E, F, G, A, B, C)
const melody = [
293.66, 0, 349.23, 392.00, // D4, -, F4, G4
440.00, 0, 392.00, 349.23, // A4, -, G4, F4
392.00, 440.00, 523.25, 440.00, // G4, A4, C5, A4
349.23, 0, 293.66, 0 // F4, -, D4, -
]
const bassline = [
146.83, 146.83, 146.83, 146.83, // D3
174.61, 174.61, 174.61, 174.61, // F3
196.00, 196.00, 196.00, 196.00, // G3
146.83, 146.83, 220.00, 146.83 // D3, A3
]
const playLoop = () => {
if (!this.bgmPlaying || !this.ctx) return
const now = this.ctx.currentTime
// 4-to-the-floor Techno Kick on every even step (quarter note)
if (this.step % 2 === 0) {
const kickOsc = this.ctx.createOscillator()
const kickGain = this.ctx.createGain()
kickOsc.frequency.setValueAtTime(140, now)
kickOsc.frequency.exponentialRampToValueAtTime(35, now + 0.1)
kickGain.gain.setValueAtTime(0.35, now)
kickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18)
kickOsc.connect(kickGain)
kickGain.connect(this.ctx.destination)
kickOsc.start(now)
kickOsc.stop(now + 0.18)
}
// Hi-Hat / Shaker on offbeats
if (this.step % 2 === 1) {
const hatNode = this.ctx.createBufferSource()
const bufferSize = this.ctx.sampleRate * 0.04
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1
}
hatNode.buffer = buffer
const hatFilter = this.ctx.createBiquadFilter()
hatFilter.type = 'highpass'
hatFilter.frequency.value = 7000
const hatGain = this.ctx.createGain()
hatGain.gain.setValueAtTime(0.08, now)
hatGain.gain.exponentialRampToValueAtTime(0.001, now + 0.04)
hatNode.connect(hatFilter)
hatFilter.connect(hatGain)
hatGain.connect(this.ctx.destination)
hatNode.start(now)
}
// Bassline (sawtooth filtered)
const bassFreq = bassline[this.step % bassline.length]
if (bassFreq > 0) {
const bassOsc = this.ctx.createOscillator()
const bassGain = this.ctx.createGain()
const bassFilter = this.ctx.createBiquadFilter()
bassOsc.type = 'sawtooth'
bassOsc.frequency.setValueAtTime(bassFreq, now)
bassFilter.type = 'lowpass'
bassFilter.frequency.setValueAtTime(450, now)
bassGain.gain.setValueAtTime(0.12, now)
bassGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2)
bassOsc.connect(bassFilter)
bassFilter.connect(bassGain)
bassGain.connect(this.ctx.destination)
bassOsc.start(now)
bassOsc.stop(now + 0.2)
}
// Medieval Lute Lead Melody
const melFreq = melody[this.step % melody.length]
if (melFreq > 0) {
const melOsc = this.ctx.createOscillator()
const melGain = this.ctx.createGain()
melOsc.type = 'square'
melOsc.frequency.setValueAtTime(melFreq, now)
melGain.gain.setValueAtTime(0.08, now)
melGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18)
melOsc.connect(melGain)
melGain.connect(this.ctx.destination)
melOsc.start(now)
melOsc.stop(now + 0.18)
}
this.step++
this.bgmTimer = window.setTimeout(playLoop, stepDuration * 1000)
}
playLoop()
}
public stopBGM() {
this.bgmPlaying = false
if (this.bgmTimer) {
clearTimeout(this.bgmTimer)
this.bgmTimer = null
}
}
}
export const sound = new SoundEngine()
@@ -0,0 +1,434 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useGameStore } from '../stores/gameStore'
import confetti from 'canvas-confetti'
const store = useGameStore()
const certificateRef = ref<HTMLDivElement | null>(null)
onMounted(() => {
// Fire celebratory party confetti
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 }
})
})
function restart() {
store.resetGame()
}
function printOrSave() {
window.print()
}
</script>
<template>
<div class="certificate-container">
<div ref="certificateRef" class="certificate-paper">
<div class="royal-seal">👑 40 👑</div>
<h1 class="cert-title">KÖNIGLICHE THRON-URKUNDE</h1>
<h2 class="cert-subtitle">Gratulation zu 40 Jahren Regentschaft!</h2>
<div class="king-portrait-wrapper">
<img
src="/assets/characters/sascha/portrait_v2.jpg"
alt="König Sascha I."
class="king-portrait"
/>
</div>
<!-- Party Competition Highscore Banner -->
<div class="score-banner" :style="{ borderColor: store.scoreRank.color }">
<div class="score-rank-title" :style="{ color: store.scoreRank.color }">
★ {{ store.scoreRank.rank }} ★
</div>
<div class="score-number">
{{ store.totalScore.toLocaleString('de-DE') }} PUNKTE
</div>
<div class="score-subtitle">
„{{ store.scoreRank.title }}“
</div>
</div>
<div class="cert-body">
<p class="decree-text">
Hiermit bekundet das gesamte Herzogtum Haumdaucher, dass
</p>
<h3 class="honoree-name">KÖNIG SASCHA I.</h3>
<p class="decree-details">
die 40 Kronkorken des Reiches glorreich vereint und den Bass auf unsterbliche 135 BPM gehoben hat!
</p>
</div>
<!-- Stats Grid -->
<div class="cert-stats">
<div class="stat-card">
<span class="stat-val">👑 {{ store.kronkorken }}</span>
<span class="stat-lbl">KRONKORKEN</span>
</div>
<div class="stat-card">
<span class="stat-val">🍻 {{ store.beers }}</span>
<span class="stat-lbl">KRÜGE HELLES</span>
</div>
<div class="stat-card">
<span class="stat-val">👥 {{ store.guestsServed }}</span>
<span class="stat-lbl">GEFOLGE VERSORGT</span>
</div>
<div class="stat-card">
<span class="stat-val">🔊 135</span>
<span class="stat-lbl">TECHNO BPM</span>
</div>
</div>
<!-- Point Breakdown Table -->
<div class="breakdown-card">
<div class="breakdown-header">📊 SCORE-AUFSCHLÜSSELUNG</div>
<div class="breakdown-row">
<span>Kronkorken ({{ store.kronkorken }}x)</span>
<span class="breakdown-pts">+{{ store.kronkorken * 100 }}</span>
</div>
<div class="breakdown-row">
<span>Helles gezapft ({{ store.beers }}x)</span>
<span class="breakdown-pts">+{{ store.beers * 250 }}</span>
</div>
<div class="breakdown-row">
<span>Gefolge glücklich ({{ store.guestsServed }}x)</span>
<span class="breakdown-pts">+{{ store.guestsServed * 300 }}</span>
</div>
<div class="breakdown-row">
<span>Gstanzl-Battle Sieg</span>
<span class="breakdown-pts">{{ store.gstanzlWon ? '+1500' : '0' }}</span>
</div>
<div class="breakdown-row">
<span>Geheimnisse ({{ store.unlockedEasterEggs.length }}x)</span>
<span class="breakdown-pts">+{{ store.unlockedEasterEggs.length * 500 }}</span>
</div>
<div class="breakdown-row total-row">
<span>GESAMT-ERGEBNIS</span>
<span class="total-pts">{{ store.totalScore.toLocaleString('de-DE') }}</span>
</div>
<div class="compare-callout">
📱 Zeig deinen Score an der Bar & vergleiche mit anderen Gästen!
</div>
</div>
<!-- Royal Postscript from Verena -->
<div class="queen-ps">
<span class="queen-signature">Königin Verena bemerkt:</span>
<p>„Ich hab dich unendlich lieb mein Schatz... aber geputzt wird nach der Party fei trotzdem ned!“ 🧹</p>
</div>
<!-- Easter Eggs Unlocked -->
<div v-if="store.unlockedEasterEggs.length" class="eggs-list">
<span>Gefundene Geheimnisse:</span>
<div class="egg-tags">
<span v-for="egg in store.unlockedEasterEggs" :key="egg" class="egg-badge">
🌟 {{ egg.toUpperCase() }}
</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="cert-actions">
<button class="save-btn" @click="printOrSave">
📸 URKUNDE DRUCKEN / SCREENSHOT
</button>
<button class="restart-btn" @click="restart">
🔄 NOCHMAL SPIELEN
</button>
</div>
</div>
</template>
<style scoped>
.certificate-container {
width: 100%;
height: 100%;
background: #1a0f0a;
overflow-y: auto;
padding: 8px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.certificate-paper {
background: #fdf6e2;
border: 4px double #854d0e;
border-radius: 10px;
padding: 10px 10px;
width: 100%;
max-width: 330px;
text-align: center;
color: #451a03;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.7);
position: relative;
}
.royal-seal {
font-size: 14px;
font-weight: bold;
color: #b45309;
margin-bottom: 2px;
}
.cert-title {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #78350f;
margin: 0 0 3px 0;
}
.cert-subtitle {
font-family: 'MedievalSharp', serif;
font-size: 14.5px;
font-weight: bold;
color: #b91c1c;
margin: 0 0 6px 0;
}
.king-portrait-wrapper {
margin: 0 auto 6px auto;
width: 68px;
height: 68px;
border: 2.5px solid #d4af37;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 3px 8px rgba(0,0,0,0.3);
}
.king-portrait {
width: 100%;
height: 100%;
object-fit: cover;
}
.decree-text {
font-family: 'MedievalSharp', serif;
font-size: 12px;
margin: 2px 0;
}
.honoree-name {
font-family: 'Press Start 2P', monospace;
font-size: 11px;
color: #991b1b;
margin: 4px 0 2px 0;
}
.honoree-title {
font-family: 'MedievalSharp', serif;
font-size: 13px;
font-style: italic;
font-weight: bold;
color: #b45309;
margin: 0 0 4px 0;
}
.decree-details {
font-family: 'MedievalSharp', serif;
font-size: 12px;
line-height: 1.25;
margin: 0 0 8px 0;
}
.cert-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 8px;
}
.stat-card {
background: rgba(217, 119, 6, 0.15);
border: 1px solid #d97706;
border-radius: 5px;
padding: 3px;
display: flex;
flex-direction: column;
}
.stat-val {
font-family: 'Press Start 2P', monospace;
font-size: 8.5px;
color: #92400e;
}
.stat-lbl {
font-size: 8px;
color: #78350f;
font-weight: bold;
}
.score-banner {
background: #1e1b18;
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: 8px;
margin-bottom: 2px;
text-shadow: 0 0 6px currentColor;
}
.score-number {
font-family: 'Press Start 2P', monospace;
font-size: 12px;
color: #fff;
letter-spacing: 0.5px;
margin: 2px 0;
}
.score-subtitle {
font-family: 'MedievalSharp', serif;
font-size: 12px;
font-style: italic;
color: #fef08a;
}
.breakdown-card {
background: rgba(254, 243, 199, 0.7);
border: 1px solid #d97706;
border-radius: 6px;
padding: 6px;
margin-bottom: 8px;
font-family: 'MedievalSharp', serif;
font-size: 11.5px;
text-align: left;
}
.breakdown-header {
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
color: #78350f;
margin-bottom: 4px;
text-align: center;
border-bottom: 1px dashed #b45309;
padding-bottom: 3px;
}
.breakdown-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
color: #451a03;
}
.breakdown-pts {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #15803d;
font-weight: bold;
}
.total-row {
border-top: 1px solid #b45309;
margin-top: 4px;
padding-top: 4px;
font-weight: bold;
}
.total-pts {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #b45309;
}
.compare-callout {
margin-top: 6px;
padding: 4px;
background: #fef08a;
border: 1px solid #eab308;
border-radius: 4px;
font-size: 10px;
color: #713f12;
text-align: center;
font-weight: bold;
}
.queen-ps {
background: #fef3c7;
border-left: 3px solid #ec4899;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 8px;
font-family: 'MedievalSharp', serif;
text-align: left;
}
.queen-signature {
font-size: 11px;
font-weight: bold;
color: #be185d;
}
.queen-ps p {
margin: 2px 0 0 0;
font-size: 12px;
color: #831843;
}
.eggs-list {
font-size: 11px;
color: #78350f;
margin-top: 6px;
}
.egg-tags {
display: flex;
justify-content: center;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.egg-badge {
background: #f59e0b;
color: #fff;
font-size: 8px;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Press Start 2P', monospace;
}
.cert-actions {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 330px;
}
.save-btn {
background: #15803d;
color: #fff;
border: 2px solid #86efac;
border-radius: 8px;
padding: 10px;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
}
.restart-btn {
background: #78350f;
color: #fef08a;
border: 1px solid #b45309;
border-radius: 8px;
padding: 8px;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
cursor: pointer;
}
</style>
@@ -0,0 +1,561 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { sound } from '../audio/synthAudio'
import IntroView from './IntroView.vue'
import PrologueView from './PrologueView.vue'
import PlatformerView from './PlatformerView.vue'
import TavernView from './TavernView.vue'
import PartyExpressView from './PartyExpressView.vue'
import CertificateModal from './CertificateModal.vue'
const store = useGameStore()
const platformerRef = ref<InstanceType<typeof PlatformerView> | null>(null)
const inventoryOpen = ref(false)
function handleJump() {
if (platformerRef.value) {
platformerRef.value.onJump()
}
}
function handleDpad(dir: 'left' | 'right', pressed: boolean) {
if (platformerRef.value) {
if (dir === 'left') platformerRef.value.onLeft(pressed)
if (dir === 'right') platformerRef.value.onRight(pressed)
}
}
function toggleAudio() {
store.toggleMute()
}
function toggleInventory() {
inventoryOpen.value = !inventoryOpen.value
}
</script>
<template>
<div class="gameboy-chassis" :class="{ 'arcade-layout': store.isArcadeMode, 'adventure-layout': !store.isArcadeMode }">
<!-- Top Royal Header -->
<div class="chassis-top">
<div class="speaker-grill-left">
<span></span><span></span><span></span>
</div>
<div class="chassis-brand">
<span class="brand-text">KÖNIG SASCHAS KRONKORKENKREUZZUG</span>
<span v-if="store.currentAct !== 'intro'" class="brand-act">
{{ store.currentAct === 'prologue' ? '• AKT I' : store.currentAct === 'platformer' ? '• AKT II' : store.currentAct === 'tavern' ? '• AKT III' : store.currentAct === 'party_express' ? '• AKT IV' : store.currentAct === 'finale' ? '• AKT V' : '' }}
</span>
</div>
<button class="sound-toggle" @click="toggleAudio">
{{ store.soundMuted ? '🔇 TON' : '🔊 135 BPM' }}
</button>
</div>
<!-- Active Screen Viewport (Takes full height in Adventure / Role Play mode!) -->
<div class="screen-bezel" :class="{ 'fullscreen-bezel': !store.isArcadeMode }">
<div class="screen-glass">
<IntroView v-if="store.currentAct === 'intro'" />
<PrologueView v-else-if="store.currentAct === 'prologue'" />
<PlatformerView v-else-if="store.currentAct === 'platformer'" ref="platformerRef" />
<TavernView v-else-if="store.currentAct === 'tavern'" />
<PartyExpressView v-else-if="store.currentAct === 'party_express'" />
<CertificateModal v-else-if="store.currentAct === 'finale'" />
</div>
</div>
<!-- ARCADE CONTROLS ONLY: Rendered during Jump&Run / Speed-Runner -->
<template v-if="store.isArcadeMode">
<!-- Medieval Stats Parchment Scroll -->
<div class="parchment-scroll">
<div class="parchment-content">
<span class="stat-badge">👑 KORKEN: <strong>{{ store.kronkorken }}</strong></span>
<span class="stat-divider">|</span>
<span class="stat-badge">🍻 HELLES: <strong>{{ store.beers }}</strong></span>
<span class="stat-divider">|</span>
<span class="stat-badge">🔊 <strong>135 BPM</strong></span>
</div>
</div>
<!-- Handheld Controller Lower Body -->
<div class="chassis-controls">
<!-- D-PAD -->
<div class="dpad-wrapper">
<div class="dpad">
<button class="dpad-btn dpad-up" disabled></button>
<button
class="dpad-btn dpad-left"
@touchstart.prevent="handleDpad('left', true)"
@touchend.prevent="handleDpad('left', false)"
@mousedown="handleDpad('left', true)"
@mouseup="handleDpad('left', false)"
>◀</button>
<div class="dpad-center"></div>
<button
class="dpad-btn dpad-right"
@touchstart.prevent="handleDpad('right', true)"
@touchend.prevent="handleDpad('right', false)"
@mousedown="handleDpad('right', true)"
@mouseup="handleDpad('right', false)"
>▶</button>
<button class="dpad-btn dpad-down" disabled></button>
</div>
<span class="control-label">D-PAD</span>
</div>
<!-- Center Logo & Utility Buttons -->
<div class="center-controls">
<div class="royal-crest">👑</div>
<div class="pill-buttons">
<button class="pill-btn" @click="toggleInventory">
<span class="pill-key">SELECT</span>
<span class="pill-sub">BEUTEL</span>
</button>
<button class="pill-btn" @click="toggleAudio">
<span class="pill-key">START</span>
<span class="pill-sub">BASS</span>
</button>
</div>
</div>
<!-- Action Buttons A & B -->
<div class="action-buttons-wrapper">
<div class="action-buttons">
<button
class="action-round-btn btn-b"
@click="store.addBeer"
>
<span class="btn-letter">B</span>
</button>
<button
class="action-round-btn btn-a"
@click="handleJump"
@touchstart.prevent="handleJump"
>
<span class="btn-letter">A</span>
</button>
</div>
<div class="action-labels">
<span>O'ZAPFT (B)</span>
<span>SPRUNG (A)</span>
</div>
</div>
</div>
</template>
<!-- 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>
</div>
<div class="adv-stat">
<span class="adv-icon">🍻</span>
<span class="adv-val">{{ store.beers }} Helles</span>
</div>
<button class="inv-mini-btn" @click="toggleInventory">
🎒 Beutel
</button>
</div>
<!-- Inventory Drawer Modal -->
<div v-if="inventoryOpen" class="inventory-drawer" @click="inventoryOpen = false">
<div class="inventory-card" @click.stop>
<h3>👑 KÖNIGLICHER LEERGUTSACK</h3>
<ul class="inventory-items">
<li v-for="item in store.inventory" :key="item" class="inv-item">
✨ {{ item }}
</li>
</ul>
<button class="close-inv-btn" @click="inventoryOpen = false">Schließen</button>
</div>
</div>
</div>
</template>
<style scoped>
.gameboy-chassis {
display: flex;
flex-direction: column;
width: 100%;
max-width: 440px;
height: 100dvh;
margin: 0 auto;
background: linear-gradient(180deg, #3d2214 0%, #26140b 100%);
border: 4px solid #b45309;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.9);
user-select: none;
overflow: hidden;
box-sizing: border-box;
}
/* Top Trim */
.chassis-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #2b1810;
border-bottom: 2px solid #78350f;
flex-shrink: 0;
}
.chassis-brand {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.brand-text {
font-family: 'Press Start 2P', monospace;
font-size: 6.8px;
color: #ffd700;
letter-spacing: 0.2px;
white-space: nowrap;
}
.brand-act {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
color: #f59e0b;
white-space: nowrap;
}
.speaker-grill-left {
display: flex;
gap: 3px;
}
.speaker-grill-left span {
width: 3px;
height: 12px;
background: #1a0f0a;
border-radius: 2px;
}
.sound-toggle {
background: #78350f;
color: #fef08a;
border: 1px solid #ffd700;
border-radius: 4px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
padding: 4px 6px;
cursor: pointer;
}
/* Screen Bezel */
.screen-bezel {
flex: 1;
background: #17100b;
margin: 6px 8px 4px 8px;
border-radius: 8px;
border: 3px solid #1a0f0a;
box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.9);
padding: 4px;
display: flex;
overflow: hidden;
position: relative;
}
.fullscreen-bezel {
margin: 4px 6px 0 6px;
border-radius: 6px;
}
.screen-glass {
width: 100%;
height: 100%;
background: #000;
border-radius: 4px;
overflow: hidden;
position: relative;
}
/* Adventure Bottom Bar */
.adventure-bottom-bar {
display: flex;
justify-content: space-around;
align-items: center;
background: #2b1810;
border-top: 2px solid #78350f;
padding: 6px 12px;
flex-shrink: 0;
}
.adv-stat {
display: flex;
align-items: center;
gap: 4px;
}
.adv-icon {
font-size: 14px;
}
.adv-val {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #ffd700;
}
.inv-mini-btn {
background: #78350f;
color: #fef08a;
border: 1px solid #b45309;
border-radius: 4px;
padding: 4px 8px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
cursor: pointer;
}
/* Parchment Status Bar */
.parchment-scroll {
background: #fdf6e2;
border: 2px solid #b45309;
border-radius: 4px;
margin: 4px 10px;
padding: 4px 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.parchment-content {
display: flex;
justify-content: space-around;
align-items: center;
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
color: #78350f;
}
.stat-divider {
color: #b45309;
}
/* Controls Section (Arcade Mode Only) */
.chassis-controls {
height: 130px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 12px 10px 12px;
flex-shrink: 0;
}
/* D-PAD */
.dpad-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.dpad {
position: relative;
width: 84px;
height: 84px;
}
.dpad-center {
position: absolute;
top: 28px;
left: 28px;
width: 28px;
height: 28px;
background: #78350f;
}
.dpad-btn {
position: absolute;
background: #854d0e;
border: 1.5px solid #d4af37;
color: #fef08a;
font-size: 11px;
font-weight: bold;
cursor: pointer;
}
.dpad-btn:active {
background: #b45309;
}
.dpad-up {
top: 0; left: 28px; width: 28px; height: 28px;
border-radius: 4px 4px 0 0;
}
.dpad-down {
bottom: 0; left: 28px; width: 28px; height: 28px;
border-radius: 0 0 4px 4px;
}
.dpad-left {
top: 28px; left: 0; width: 28px; height: 28px;
border-radius: 4px 0 0 4px;
}
.dpad-right {
top: 28px; right: 0; width: 28px; height: 28px;
border-radius: 0 4px 4px 0;
}
.control-label {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
color: #d4af37;
}
/* Center */
.center-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.royal-crest {
font-size: 18px;
}
.pill-buttons {
display: flex;
gap: 6px;
}
.pill-btn {
display: flex;
flex-direction: column;
align-items: center;
background: #1c1917;
border: 1px solid #78350f;
border-radius: 12px;
padding: 3px 6px;
cursor: pointer;
transform: rotate(-15deg);
}
.pill-key {
font-family: 'Press Start 2P', monospace;
font-size: 5px;
color: #94a3b8;
}
.pill-sub {
font-size: 7px;
color: #d4af37;
}
/* Action Buttons */
.action-buttons-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.action-buttons {
position: relative;
width: 84px;
height: 64px;
}
.action-round-btn {
position: absolute;
width: 38px;
height: 38px;
border-radius: 50%;
border: 2px solid #ffd700;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.6);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.action-round-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.btn-b {
bottom: 0;
left: 0;
background: #eab308;
}
.btn-a {
top: 0;
right: 0;
background: #dc2626;
}
.btn-letter {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: #fff;
text-shadow: 1px 1px 0 #000;
}
.action-labels {
display: flex;
gap: 4px;
font-family: 'Press Start 2P', monospace;
font-size: 5.5px;
color: #d4af37;
}
/* Inventory Modal */
.inventory-drawer {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.inventory-card {
background: #2b1810;
border: 3px solid #d4af37;
border-radius: 8px;
padding: 16px;
width: 80%;
color: #fef3c7;
}
.inventory-card h3 {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #ffd700;
margin-top: 0;
}
.inventory-items {
list-style: none;
padding: 0;
margin: 12px 0;
}
.inv-item {
font-family: 'MedievalSharp', serif;
font-size: 15px;
padding: 4px 0;
border-bottom: 1px dashed #78350f;
}
.close-inv-btn {
background: #78350f;
color: #fff;
border: 1px solid #d4af37;
border-radius: 4px;
padding: 6px 12px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
cursor: pointer;
}
</style>
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { useGameStore } from '../stores/gameStore'
import { sound } from '../audio/synthAudio'
const store = useGameStore()
function handleStart() {
sound.playFanfare()
store.startGame()
}
</script>
<template>
<div class="intro-screen">
<!-- Royal Header -->
<header class="intro-header">
<div class="crown-badge">👑 40 JAHRE REGENTSCHAFT 👑</div>
<h1 class="game-main-title">SASCHAS KRONKORKEN KREUZZUG</h1>
<h2 class="game-subtitle">40 JAHRE BASS & GERSTENSAFT</h2>
</header>
<!-- Key Art: King Sascha I. -->
<div class="king-hero">
<div class="king-frame">
<img
src="/assets/characters/sascha/portrait_v2.jpg"
alt="König Sascha I."
class="king-hero-img"
/>
</div>
<span class="king-caption">KÖNIG SASCHA I. – DER 40-JÄHRIGE IMPERATOR</span>
</div>
<!-- Prequel & QR Introduction -->
<div class="intro-scroll">
<div class="scroll-section">
<h3 class="section-title">📜 DIE GESCHICHTE DES REICHES</h3>
<p class="story-text">
<strong>Hört, hört, wertes Feiervolk!</strong><br>
Du hast soeben über den königlichen QR-Code das offizielle Thronjubiläums-Spiel betreten!
</p>
<p class="story-text">
Seit 40 Jahren regiert König Sascha das stolze Herzogtum Haumdaucher mit eiserner Hand, dunkler Sonnenbrille und wummernden 135-BPM-Techno-Bässen. Doch kurz vor dem heutigen Thron-Rave geschah das Unglück:
</p>
<p class="story-highlight">
Die Schatzkammer wurde geplündert! Sämtliche 40 geheiligten Kronkorken des Reiches sind über ganz Haumdaucher verstreut!
Ohne Kronkorken kein kühles Helles, ohne Helles kein Bass – und ohne Bass keine Feier!
</p>
</div>
<div class="scroll-section">
<h3 class="section-title">🎮 WAS DICH IM SPIEL ERWARTET</h3>
<ul class="features-list">
<li><strong>🏃‍♂️ Retro Jump & Run:</strong> Hüpfe über die Dächer der Hopfen-Pfade und sammle die verlorenen Kronkorken ein.</li>
<li><strong>🍻 Tavernen-Abenteuer:</strong> Sprich mit Verena, Lutz (ZTUL), Armend, Amy, Mane & Pü und gewinne das bayerische Beleidigungs-Gstanzl-Fechten!</li>
<li><strong>🌴 Der Zauberspiegel:</strong> Finde das geheime Urlaubserinnerungs-Easter-Egg von Buschkönig Zuluman.</li>
<li><strong>🔊 Der 135-BPM-Express:</strong> Rase mit den Hof-DJs auf dem Bollerwagen und versorge das feiernde Gefolge.</li>
<li><strong>🏆 Highscore-Urkunde:</strong> Hol dir am Ende deine persönliche königliche Punktzahl und vergleiche dich mit allen Partygästen!</li>
</ul>
</div>
</div>
<!-- Start Action -->
<footer class="intro-footer">
<button class="start-btn" @click="handleStart">
⚔️ SPIEL STARTEN / RETTE DAS JUBILÄUM! ⚔️
</button>
<span class="audio-hint">🔊 Tipp: Smartphone-Lautstärke aufdrehen für echten 135-BPM-Mittelalter-Techno!</span>
</footer>
</div>
</template>
<style scoped>
.intro-screen {
width: 100%;
height: 100%;
background: radial-gradient(circle at 50% 20%, #3d2214 0%, #170d07 100%);
color: #fef3c7;
display: flex;
flex-direction: column;
padding: 14px 12px;
overflow-y: auto;
box-sizing: border-box;
}
.intro-header {
text-align: center;
margin-bottom: 10px;
}
.crown-badge {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #f59e0b;
margin-bottom: 6px;
letter-spacing: 1px;
}
.game-main-title {
font-family: 'Press Start 2P', monospace;
font-size: 11px;
line-height: 1.4;
color: #ffd700;
text-shadow: 2px 2px 0 #78350f, 0 0 10px rgba(255, 215, 0, 0.4);
margin: 0 0 4px 0;
}
.game-subtitle {
font-family: 'MedievalSharp', serif;
font-size: 15px;
color: #f97316;
margin: 0;
font-style: italic;
}
.king-hero {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 12px;
}
.king-frame {
width: 120px;
height: 120px;
border: 4px solid #ffd700;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.5), inset 0 0 10px rgba(0,0,0,0.5);
background: #2b1810;
}
.king-hero-img {
width: 100%;
height: 100%;
object-fit: cover;
image-rendering: crisp-edges;
}
.king-caption {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #d4af37;
margin-top: 6px;
text-align: center;
}
.intro-scroll {
flex: 1;
background: rgba(26, 15, 10, 0.7);
border: 2px solid #854d0e;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
overflow-y: auto;
}
.scroll-section {
margin-bottom: 14px;
}
.section-title {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #ffd700;
border-bottom: 1px dashed #78350f;
padding-bottom: 4px;
margin: 0 0 8px 0;
}
.story-text {
font-family: 'MedievalSharp', serif;
font-size: 14px;
line-height: 1.4;
margin: 0 0 6px 0;
color: #fde68a;
}
.story-highlight {
background: rgba(185, 28, 28, 0.25);
border-left: 3px solid #ef4444;
padding: 6px 8px;
font-family: 'MedievalSharp', serif;
font-size: 13.5px;
line-height: 1.35;
color: #fee2e2;
margin: 6px 0;
}
.features-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.features-list li {
font-family: 'MedievalSharp', serif;
font-size: 13px;
line-height: 1.3;
color: #e2e8f0;
padding-left: 4px;
border-left: 2px solid #f59e0b;
}
.features-list strong {
color: #facc15;
}
.intro-footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.start-btn {
width: 100%;
background: linear-gradient(180deg, #dc2626 0%, #991b1b 100%);
color: #fff;
border: 3px solid #ffd700;
border-radius: 8px;
padding: 12px 14px;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
letter-spacing: 0.5px;
cursor: pointer;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.7), 0 0 12px rgba(220, 38, 38, 0.6);
animation: pulseBtn 1.8s infinite;
}
@keyframes pulseBtn {
0%, 100% { transform: scale(1); box-shadow: 0 0 10px rgba(255, 215, 0, 0.5); }
50% { transform: scale(1.02); box-shadow: 0 0 20px rgba(245, 158, 11, 0.9); }
}
.audio-hint {
font-family: 'MedievalSharp', serif;
font-size: 12px;
color: #94a3b8;
text-align: center;
}
</style>
@@ -0,0 +1,637 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { sound } from '../audio/synthAudio'
const store = useGameStore()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const showExpressIntro = ref(true)
let animId: number | null = null
let wagonX = 70
let wagonY = 325
let speed = 3.2
let distance = 0
const maxDistance = 2100
function startExpress() {
sound.playDialogueBlip()
showExpressIntro.value = false
}
interface Guest {
id: string
x: number
name: string
served: boolean
avatarColor: string
}
let guests: Guest[] = [
{ id: 'hansi', x: 300, name: 'Hansi 🧔', served: false, avatarColor: '#6366f1' },
{ id: 'viktor', x: 550, name: 'Viktor (Vampir) 🧛', served: false, avatarColor: '#881337' },
{ id: 'albin', x: 820, name: 'Albin (Fitness) 🏋️', served: false, avatarColor: '#0284c7' },
{ id: 'fabian', x: 1100, name: 'Fabian (Brauer) 🍺', served: false, avatarColor: '#d97706' },
{ id: 'maierflo', x: 1380, name: 'Maier Flo (DJ) 🎧', served: false, avatarColor: '#7c3aed' },
{ id: 'guate', x: 1650, name: 'Guatemalteke 🌋', served: false, avatarColor: '#059669' },
{ id: 'markus_nat', x: 1920, name: 'Markus & Nathalie 🥂', served: false, avatarColor: '#db2777' }
]
function throwBeer() {
sound.playGulp()
// Check if any guest is near the wagon
guests.forEach(g => {
if (!g.served && Math.abs(distance + wagonX - g.x) < 95) {
g.served = true
store.serveGuest()
}
})
}
function updateGame() {
if (showExpressIntro.value) return // Don't advance while reading intro!
distance += speed
if (distance >= maxDistance) {
store.setAct('finale')
return
}
}
function draw(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.clearRect(0, 0, w, h)
// Sunset Party Sky Gradient
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, '#1e082b')
grad.addColorStop(0.4, '#4a154b')
grad.addColorStop(0.7, '#831843')
grad.addColorStop(0.9, '#c2410c')
grad.addColorStop(1, '#f59e0b')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
// Sweeping Festival Lasers & Party Beams
const now = Date.now() * 0.003
ctx.save()
for (let b = 0; b < 3; b++) {
const angleOffset = b * (Math.PI / 3)
const targetX = w * 0.5 + Math.sin(now + angleOffset) * (w * 0.45)
ctx.strokeStyle = `hsla(${((now * 50) + b * 90) % 360}, 100%, 75%, 0.45)`
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(w * 0.5, 90)
ctx.lineTo(targetX, 350)
ctx.stroke()
}
ctx.restore()
// Medieval Castle silhouette on the horizon
ctx.fillStyle = '#170c14'
ctx.beginPath()
ctx.moveTo(0, 310)
ctx.lineTo(30, 290); ctx.lineTo(50, 290); ctx.lineTo(50, 310)
ctx.lineTo(110, 310); ctx.lineTo(130, 275); ctx.lineTo(160, 275); ctx.lineTo(170, 310)
ctx.lineTo(240, 310); ctx.lineTo(255, 295); ctx.lineTo(275, 295); ctx.lineTo(290, 310)
ctx.lineTo(w, 310); ctx.lineTo(w, 350); ctx.lineTo(0, 350)
ctx.fill()
// Cobblestone Highway
ctx.fillStyle = '#1c1917'
ctx.fillRect(0, 350, w, 130)
ctx.fillStyle = '#292524'
for (let r = 0; r < w + 40; r += 32) {
const rx = (r - (distance % 32))
ctx.fillRect(rx, 352, 26, 6)
ctx.fillRect(rx + 12, 362, 26, 6)
}
// Curbs / Grass edge
ctx.fillStyle = '#365314'
ctx.fillRect(0, 348, w, 3)
// Draw Guests along the road
guests.forEach(g => {
const screenX = g.x - distance
if (screenX > -70 && screenX < w + 70) {
// Guest shadow
ctx.fillStyle = 'rgba(0,0,0,0.4)'
ctx.beginPath()
ctx.ellipse(screenX + 10, 350, 14, 5, 0, 0, Math.PI * 2)
ctx.fill()
if (g.id === 'viktor') {
// Red velvet vampire cape
ctx.fillStyle = '#4c0519'
ctx.fillRect(screenX - 5, 305, 30, 44)
}
// Guest body
ctx.fillStyle = g.avatarColor
ctx.fillRect(screenX, 305, 20, 45)
// Head
ctx.fillStyle = '#fed7aa'
ctx.beginPath()
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, 297, 8, 2.5)
} else if (g.id === 'viktor') {
// Sunglasses & fangs
ctx.fillStyle = '#0f172a'
ctx.fillRect(screenX + 6, 293, 8, 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, 278)
if (g.served) {
ctx.fillStyle = '#22c55e'
ctx.font = 'bold 11px sans-serif'
ctx.fillText('🍻 PROST!', screenX + 10, 264)
} else {
ctx.fillStyle = '#fbbf24'
ctx.font = '8px "Press Start 2P", monospace'
ctx.fillText('DURST!', screenX + 10, 264)
}
}
})
// ================= REDESIGNED BOLLERWAGEN (RETRO PIXEL ART) =================
const wx = wagonX
const wy = wagonY
const bob = showExpressIntro.value ? 0 : Math.sin(Date.now() * 0.015) * 2.5
// 1. Shadow underneath the wagon
ctx.fillStyle = 'rgba(0,0,0,0.5)'
ctx.beginPath()
ctx.ellipse(wx + 30, wy + 32, 54, 8, 0, 0, Math.PI * 2)
ctx.fill()
// 2. Heavy Rustic Oak Timber Bed with Iron Corner Brackets
ctx.fillStyle = '#451a03' // Dark wood outline
ctx.fillRect(wx - 26, wy + bob, 114, 26)
ctx.fillStyle = '#78350f' // Main oak plank
ctx.fillRect(wx - 24, wy + bob + 2, 110, 22)
ctx.fillStyle = '#92400e' // Highlight upper plank
ctx.fillRect(wx - 24, wy + bob + 4, 110, 8)
// Iron bands and golden rivets
ctx.fillStyle = '#1c1917'
ctx.fillRect(wx - 26, wy + bob, 6, 26)
ctx.fillRect(wx + 28, wy + bob, 5, 26)
ctx.fillRect(wx + 82, wy + bob, 6, 26)
ctx.fillStyle = '#ffd700'
;[wx - 23, wx + 30, wx + 85].forEach(rx => {
ctx.fillRect(rx, wy + bob + 4, 2, 2)
ctx.fillRect(rx, wy + bob + 14, 2, 2)
})
// 3. Side-Mounted Hopfen Beer Keg with Brass Faucet
ctx.fillStyle = '#292524'
ctx.fillRect(wx + 5, wy + bob - 12, 22, 14)
ctx.fillStyle = '#b45309'
ctx.fillRect(wx + 6, wy + bob - 11, 20, 12)
ctx.fillStyle = '#ffd700' // Faucet
ctx.fillRect(wx + 26, wy + bob - 6, 5, 3)
ctx.fillRect(wx + 29, wy + bob - 3, 2, 4)
// 4. Giant 135-BPM Bass Subwoofer on the rear
ctx.fillStyle = '#09090b'
ctx.fillRect(wx - 22, wy - 36 + bob, 26, 38)
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 1.5
ctx.strokeRect(wx - 22, wy - 36 + bob, 26, 38)
// Pulsing Bass Speaker Cone
const bassKick = Math.abs(Math.sin(Date.now() * 0.02) * 5)
ctx.fillStyle = '#e11d48'
ctx.beginPath()
ctx.arc(wx - 9, wy - 17 + bob, 8 + bassKick, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#f43f5e'
ctx.lineWidth = 2
ctx.stroke()
// Speaker center cap
ctx.fillStyle = '#ffd700'
ctx.beginPath()
ctx.arc(wx - 9, wy - 17 + bob, 3, 0, Math.PI * 2)
ctx.fill()
// 5. Hof-DJ Mixer Console & Turntable on the front
ctx.fillStyle = '#18181b'
ctx.fillRect(wx + 46, wy - 16 + bob, 34, 18)
ctx.strokeStyle = '#38bdf8'
ctx.lineWidth = 1
ctx.strokeRect(wx + 46, wy - 16 + bob, 34, 18)
// Vinyl Turntable with spinning vinyl
ctx.fillStyle = '#0a0a0a'
ctx.beginPath()
ctx.arc(wx + 60, wy - 7 + bob, 7, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ef4444' // Center vinyl label
ctx.beginPath()
ctx.arc(wx + 60, wy - 7 + bob, 2.5, 0, Math.PI * 2)
ctx.fill()
// Flashing Mixer LEDs
const ledFlash = Math.floor(Date.now() / 150) % 3
ctx.fillStyle = ledFlash === 0 ? '#22c55e' : '#15803d'
ctx.fillRect(wx + 72, wy - 12 + bob, 3, 3)
ctx.fillStyle = ledFlash === 1 ? '#eab308' : '#854d0e'
ctx.fillRect(wx + 72, wy - 8 + bob, 3, 3)
ctx.fillStyle = ledFlash === 2 ? '#ef4444' : '#7f1d1d'
ctx.fillRect(wx + 72, wy - 4 + bob, 3, 3)
// 6. KING SASCHA standing proud on the wagon
// Flowing Royal Red Velvet Cape
ctx.fillStyle = '#b91c1c'
ctx.fillRect(wx + 10, wy - 36 + bob, 24, 38)
// Royal Blue Tunic with Gold Trim
ctx.fillStyle = '#1d4ed8'
ctx.fillRect(wx + 15, wy - 32 + bob, 18, 24)
ctx.fillStyle = '#d4af37'
ctx.fillRect(wx + 22, wy - 32 + bob, 4, 24)
// Face & Beard
ctx.fillStyle = '#fed7aa'
ctx.fillRect(wx + 16, wy - 46 + bob, 16, 14)
// Short Blonde/Brown Hair (as requested)
ctx.fillStyle = '#b45309'
ctx.fillRect(wx + 15, wy - 50 + bob, 18, 5)
// Deep Black Royal Sunglasses
ctx.fillStyle = '#0f172a'
ctx.fillRect(wx + 18, wy - 43 + bob, 6, 4)
ctx.fillRect(wx + 25, wy - 43 + bob, 6, 4)
ctx.fillRect(wx + 23, wy - 42 + bob, 3, 2) // Bridge
// Golden Imperial Crown with Jewels
ctx.fillStyle = '#facc15'
ctx.fillRect(wx + 15, wy - 53 + bob, 18, 5)
ctx.beginPath()
ctx.moveTo(wx + 15, wy - 53 + bob); ctx.lineTo(wx + 15, wy - 58 + bob); ctx.lineTo(wx + 19, wy - 53 + bob)
ctx.lineTo(wx + 24, wy - 60 + bob); ctx.lineTo(wx + 29, wy - 53 + bob); ctx.lineTo(wx + 33, wy - 58 + bob)
ctx.lineTo(wx + 33, wy - 53 + bob)
ctx.fill()
// Crown jewels
ctx.fillStyle = '#dc2626'
ctx.fillRect(wx + 23, wy - 54 + bob, 2, 2)
// Raised Beer Stein in Right Hand (Prost!)
ctx.fillStyle = '#f59e0b' // Golden Beer
ctx.fillRect(wx + 35, wy - 44 + bob, 8, 12)
ctx.fillStyle = '#ffffff' // White froth/foam
ctx.fillRect(wx + 34, wy - 47 + bob, 10, 4)
ctx.fillStyle = '#cbd5e1' // Glass handle
ctx.fillRect(wx + 42, wy - 42 + bob, 3, 7)
// 7. Detailed Wooden Spoke Wheels with Golden Brass Caps
const wheelSpin = (distance * 0.08) % (Math.PI * 2)
;[-8, 68].forEach(wheelOffset => {
ctx.save()
ctx.translate(wx + wheelOffset, wy + 26)
ctx.rotate(wheelSpin)
// Outer dark tyre
ctx.fillStyle = '#1c1917'
ctx.beginPath()
ctx.arc(0, 0, 13, 0, Math.PI * 2)
ctx.fill()
// Inner wooden rim
ctx.fillStyle = '#78350f'
ctx.beginPath()
ctx.arc(0, 0, 11, 0, Math.PI * 2)
ctx.fill()
// Brass rim edge
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 1.5
ctx.stroke()
// 8 Spokes
ctx.strokeStyle = '#b45309'
ctx.lineWidth = 1.5
for (let s = 0; s < 4; s++) {
ctx.beginPath()
ctx.moveTo(-11 * Math.cos(s * Math.PI / 4), -11 * Math.sin(s * Math.PI / 4))
ctx.lineTo(11 * Math.cos(s * Math.PI / 4), 11 * Math.sin(s * Math.PI / 4))
ctx.stroke()
}
// Center brass axle cap
ctx.fillStyle = '#facc15'
ctx.beginPath()
ctx.arc(0, 0, 4, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
})
// Top Banner HUD
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 8px "Press Start 2P", monospace'
ctx.textAlign = 'center'
ctx.fillText('AKT IV: DER 135-BPM-FEST-KONVOI', w / 2, 22)
// Progress Bar
const progress = Math.min(1, distance / maxDistance)
const barMargin = 20
const barWidth = w - barMargin * 2
ctx.fillStyle = 'rgba(0,0,0,0.7)'
ctx.fillRect(barMargin, 32, barWidth, 10)
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 1
ctx.strokeRect(barMargin, 32, barWidth, 10)
// Progress fill
ctx.fillStyle = '#22c55e'
ctx.fillRect(barMargin + 1, 33, (barWidth - 2) * progress, 8)
// Moving Keg Icon on the progress bar
const kegX = barMargin + (barWidth * progress)
ctx.font = '10px sans-serif'
ctx.fillText('🍺', kegX, 39)
}
function loop() {
updateGame()
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) draw(ctx, canvasRef.value.width, canvasRef.value.height)
}
animId = requestAnimationFrame(loop)
}
onMounted(() => {
animId = requestAnimationFrame(loop)
})
onUnmounted(() => {
if (animId) cancelAnimationFrame(animId)
})
</script>
<template>
<div class="express-container">
<canvas ref="canvasRef" width="360" height="480" class="game-canvas"></canvas>
<div class="express-controls">
<button class="throw-beer-btn" @click="throwBeer">
🍺 BIER AN DIE GÄSTE SCHENKEN!
</button>
<span class="express-hint">Tippe, wenn du an deinen Freunden vorbeifährst!</span>
</div>
<!-- Pre-Level Start Screen Modal -->
<div v-if="showExpressIntro" class="level-intro-modal">
<div class="intro-card">
<div class="intro-act-tag">👑 AKT IV: FAHRGESCHÄFT</div>
<h2 class="intro-card-title">DER 135-BPM-FEST-KONVOI</h2>
<p class="intro-card-story">
Der Hof-Bass wummert, der Bollerwagen ist poliert!<br />
König Sascha zieht mit der Reichs-Soundanlage über die Festmeile von Haumdaucher. Deine Freunde stehen durstig am Straßenrand!
</p>
<div class="mission-callout">
<span class="callout-icon">🎯</span>
<div class="callout-text">
<strong>KÖNIGLICHE MISSION:</strong><br />
Versorge Hansi, Viktor, Albin, Fabian, Flo Maier & das Gefolge mit kühlem Gerstensaft im Takt des Basses!
</div>
</div>
<div class="controls-overview">
<div class="ctrl-item">
<span class="ctrl-badge">🍺 BIER SCHENKEN</span>
<span class="ctrl-label">Tippe den Button, sobald du an einem Freund bist</span>
</div>
<div class="ctrl-item">
<span class="ctrl-badge">🏆 TIMING</span>
<span class="ctrl-label">Jedes glückliche Gefolge bringt fette Highscore-Punkte!</span>
</div>
</div>
<button class="start-act-btn" @click="startExpress">
🚀 PARTY-EXPRESS STARTEN!
</button>
</div>
</div>
</div>
</template>
<style scoped>
.express-container {
position: relative;
width: 100%;
height: 100%;
background: #000;
display: flex;
flex-direction: column;
}
.game-canvas {
width: 100%;
height: 100%;
object-fit: cover;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.express-controls {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 20;
}
.throw-beer-btn {
background: #eab308;
color: #1a0f0a;
border: 2px solid #fff;
border-radius: 8px;
font-family: 'Press Start 2P', monospace;
font-size: 8.5px;
padding: 10px 14px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
.express-hint {
font-family: 'MedievalSharp', serif;
font-size: 13px;
color: #fef08a;
text-shadow: 1px 1px 2px #000;
}
/* Pre-Level Start Screen Modal */
.level-intro-modal {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
z-index: 50;
backdrop-filter: blur(2px);
}
.intro-card {
background: #231209;
border: 3px double #d4af37;
border-radius: 10px;
padding: 16px 14px;
max-width: 320px;
width: 100%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.95);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
}
.intro-act-tag {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #f59e0b;
letter-spacing: 0.5px;
}
.intro-card-title {
font-family: 'Press Start 2P', monospace;
font-size: 9.5px;
color: #ffd700;
margin: 0;
text-shadow: 1px 1px 0 #000;
}
.intro-card-story {
font-family: 'MedievalSharp', serif;
font-size: 13px;
color: #fef08a;
line-height: 1.35;
margin: 0;
}
.mission-callout {
background: #451a03;
border-left: 3px solid #22c55e;
border-radius: 4px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.callout-icon {
font-size: 20px;
}
.callout-text {
font-family: 'MedievalSharp', serif;
font-size: 12.5px;
color: #bbf7d0;
line-height: 1.3;
}
.controls-overview {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
background: #170c06;
border: 1px solid #78350f;
border-radius: 6px;
padding: 6px 10px;
text-align: left;
}
.ctrl-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.ctrl-badge {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #ffd700;
}
.ctrl-label {
font-family: 'MedievalSharp', serif;
font-size: 11.5px;
color: #fed7aa;
}
.start-act-btn {
background: linear-gradient(180deg, #dc2626 0%, #991b1b 100%);
color: #fff;
border: 2px solid #ffd700;
border-radius: 8px;
padding: 10px 16px;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
cursor: pointer;
margin-top: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
transition: transform 0.1s;
}
.start-act-btn:active {
transform: scale(0.96);
}
</style>
@@ -0,0 +1,713 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { sound } from '../audio/synthAudio'
const store = useGameStore()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const showLevelIntro = ref(true)
let animationFrameId: number | null = null
function startLevel() {
sound.playDialogueBlip()
showLevelIntro.value = false
}
// Game state
interface Platform {
x: number
y: number
w: number
h: number
type: 'roof' | 'barrel' | 'wood'
}
interface Item {
x: number
y: number
type: 'cap' | 'beer'
collected: boolean
bobOffset: number
}
interface Particle {
x: number
y: number
vx: number
vy: number
life: number
color: string
}
let player = {
x: 60,
y: 350,
w: 32,
h: 48,
vx: 0,
vy: 0,
isGrounded: false,
jumpCount: 0,
frame: 0
}
let cameraX = 0
let levelWidth = 2800
let platforms: Platform[] = []
let items: Item[] = []
let particles: Particle[] = []
// Input state
let keys = {
left: false,
right: false,
jump: false
}
// Generate level
function initLevel() {
platforms = [
// Ground base
{ 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: 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: 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: 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: 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 = 220 + (i % 3) * 60
items.push({
x: px,
y: py,
type: 'cap',
collected: false,
bobOffset: Math.random() * Math.PI * 2
})
}
// A few Helles beers for big boost
;[450, 1100, 1800, 2450].forEach(bx => {
items.push({
x: bx,
y: 190,
type: 'beer',
collected: false,
bobOffset: 0
})
})
}
// Controller hooks for GameBoy buttons
function onJump() {
if (player.isGrounded || player.jumpCount < 2) {
player.vy = -9.2
player.jumpCount++
player.isGrounded = false
sound.playJump()
// Jump dust particles
for (let i = 0; i < 6; i++) {
particles.push({
x: player.x + player.w / 2,
y: player.y + player.h,
vx: (Math.random() - 0.5) * 3,
vy: (Math.random() - 0.5) * 2,
life: 20,
color: '#d4af37'
})
}
}
}
function onLeft(pressed: boolean) {
keys.left = pressed
}
function onRight(pressed: boolean) {
keys.right = pressed
}
// Expose to parent GameBoy console
defineExpose({
onJump,
onLeft,
onRight
})
function updatePhysics() {
// Horizontal movement (auto slight forward jog + manual control)
const baseSpeed = 2.2
if (keys.right) {
player.vx = baseSpeed + 2.5
} else if (keys.left) {
player.vx = -baseSpeed - 1.5
} else {
// gentle casual auto-scroll jog
player.vx = baseSpeed
}
player.x += player.vx
// Gravity
player.vy += 0.42
if (player.vy > 10) player.vy = 10
player.y += player.vy
player.isGrounded = false
// Platform collision
for (const plat of platforms) {
if (
player.x + player.w > plat.x &&
player.x < plat.x + plat.w &&
player.y + player.h >= plat.y &&
player.y + player.h <= plat.y + 14 &&
player.vy >= 0
) {
player.y = plat.y - player.h
player.vy = 0
player.isGrounded = true
player.jumpCount = 0
}
}
// Prevent falling to oblivion (respawn smoothly with cheer)
if (player.y > 460) {
player.y = 250
player.vy = 0
player.x = Math.max(60, player.x - 120)
}
// Camera tracking
cameraX = player.x - 80
// Item pickup
items.forEach(item => {
if (item.collected) return
const dx = (player.x + player.w / 2) - item.x
const dy = (player.y + player.h / 2) - item.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 26) {
item.collected = true
if (item.type === 'cap') {
store.addKronkorken(1)
} else {
store.addBeer()
}
// Sparkles
for (let p = 0; p < 8; p++) {
particles.push({
x: item.x,
y: item.y,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
life: 25,
color: item.type === 'cap' ? '#ffd700' : '#f59e0b'
})
}
}
})
// Particles update
particles.forEach(p => {
p.x += p.vx
p.y += p.vy
p.life--
})
particles = particles.filter(p => p.life > 0)
// Level completion check (reaching the Taverne gate)
if (player.x >= levelWidth - 100) {
store.setAct('tavern')
}
player.frame++
}
function drawGame(ctx: CanvasRenderingContext2D, width: number, height: number) {
ctx.clearRect(0, 0, width, height)
// Sky gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, height)
skyGrad.addColorStop(0, '#3a7bd5')
skyGrad.addColorStop(0.65, '#f5af19')
skyGrad.addColorStop(1, '#e65c00')
ctx.fillStyle = skyGrad
ctx.fillRect(0, 0, width, height)
// Parallax mountains & castle silhouette
ctx.fillStyle = 'rgba(74, 35, 90, 0.45)'
for (let m = -1; m < 5; m++) {
const mx = m * 240 - (cameraX * 0.2) % 240
ctx.beginPath()
ctx.moveTo(mx, 400)
ctx.lineTo(mx + 120, 200)
ctx.lineTo(mx + 240, 400)
ctx.fill()
}
// Castle towers in background
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, 220, 50, 180)
ctx.beginPath()
ctx.moveTo(cx + 50, 220)
ctx.lineTo(cx + 85, 160)
ctx.lineTo(cx + 120, 220)
ctx.fill()
}
ctx.save()
ctx.translate(-cameraX, 0)
// Draw platforms
for (const plat of platforms) {
if (plat.type === 'roof') {
ctx.fillStyle = '#b91c1c' // Red roof tiles
ctx.fillRect(plat.x, plat.y, plat.w, plat.h)
// Roof trim
ctx.fillStyle = '#7f1d1d'
ctx.fillRect(plat.x, plat.y, plat.w, 4)
} else if (plat.type === 'wood') {
ctx.fillStyle = '#854d0e' // Timber wood platform
ctx.fillRect(plat.x, plat.y, plat.w, plat.h)
ctx.fillStyle = '#713f12'
ctx.fillRect(plat.x, plat.y, plat.w, 3)
// Support beams
ctx.fillRect(plat.x + 8, plat.y + plat.h, 6, 40)
ctx.fillRect(plat.x + plat.w - 14, plat.y + plat.h, 6, 40)
} else {
// Beer barrel
ctx.fillStyle = '#92400e'
ctx.beginPath()
ctx.ellipse(plat.x + plat.w / 2, plat.y + plat.h / 2, plat.w / 2, plat.h / 2, 0, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#fef08a'
ctx.lineWidth = 2
ctx.stroke()
}
}
// Draw Items (Kronkorken & Beer)
const time = Date.now() * 0.005
for (const item of items) {
if (item.collected) continue
const bob = Math.sin(time + item.bobOffset) * 5
if (item.type === 'cap') {
// Golden Kronkorken with serrated edge & crown
ctx.save()
ctx.translate(item.x, item.y + bob)
ctx.fillStyle = '#ffd700'
ctx.beginPath()
ctx.arc(0, 0, 9, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#b45309'
ctx.lineWidth = 2
ctx.stroke()
// Star / 40 embossing
ctx.fillStyle = '#78350f'
ctx.font = 'bold 8px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('40', 0, 0)
ctx.restore()
} else {
// Helles Beer Mug with white foam
ctx.save()
ctx.translate(item.x, item.y + bob)
ctx.fillStyle = '#f59e0b'
ctx.fillRect(-6, -6, 12, 14)
ctx.fillStyle = '#ffffff'
ctx.fillRect(-7, -9, 14, 4) // Foam
// Handle
ctx.strokeStyle = '#d97706'
ctx.lineWidth = 2
ctx.strokeRect(6, -3, 4, 8)
ctx.restore()
}
}
// Draw Particles
particles.forEach(p => {
ctx.fillStyle = p.color
ctx.fillRect(p.x, p.y, 3, 3)
})
// Draw Goal Gate: Taverne 'Zum wummernden Ritter'
ctx.fillStyle = '#3e2723'
ctx.fillRect(levelWidth - 140, 270, 110, 130)
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 9px MedievalSharp, serif'
ctx.fillText('ZUM WUMMERNDEN RITTER', levelWidth - 135, 285)
// Glowing door
ctx.fillStyle = '#f59e0b'
ctx.fillRect(levelWidth - 105, 320, 40, 80)
// Draw Player: König Sascha (Pixel King with sunglasses, crown & ermine coat)
const px = player.x
const py = player.y
const legOffset = Math.sin(player.frame * 0.3) * 4
// Red Royal Ermine Cape (waving behind)
ctx.fillStyle = '#dc2626'
ctx.beginPath()
ctx.moveTo(px + 6, py + 16)
ctx.lineTo(px - 10 + Math.sin(player.frame * 0.25) * 5, py + 42)
ctx.lineTo(px + 14, py + 42)
ctx.fill()
// White ermine fur trim
ctx.fillStyle = '#f8fafc'
ctx.fillRect(px - 10 + Math.sin(player.frame * 0.25) * 5, py + 38, 24, 4)
// Royal Blue Tunic & Body
ctx.fillStyle = '#1d4ed8'
ctx.fillRect(px + 6, py + 16, 20, 22)
ctx.fillStyle = '#eab308' // Gold Belt
ctx.fillRect(px + 6, py + 30, 20, 3)
// Legs & Boots
ctx.fillStyle = '#451a03'
ctx.fillRect(px + 8, py + 38, 6, 10 + (player.isGrounded ? legOffset : 0))
ctx.fillRect(px + 18, py + 38, 6, 10 - (player.isGrounded ? legOffset : 0))
// Face
ctx.fillStyle = '#fcd34d'
ctx.fillRect(px + 8, py + 4, 16, 14)
// Iconic Black Sunglasses 🕶️
ctx.fillStyle = '#0f172a'
ctx.fillRect(px + 9, py + 8, 7, 4)
ctx.fillRect(px + 17, py + 8, 7, 4)
ctx.fillRect(px + 15, py + 9, 3, 2) // Bridge
// Golden Crown 👑
ctx.fillStyle = '#facc15'
ctx.beginPath()
ctx.moveTo(px + 6, py + 4)
ctx.lineTo(px + 6, py - 4)
ctx.lineTo(px + 11, py - 1)
ctx.lineTo(px + 16, py - 6)
ctx.lineTo(px + 21, py - 1)
ctx.lineTo(px + 26, py - 4)
ctx.lineTo(px + 26, py + 4)
ctx.fill()
ctx.fillStyle = '#dc2626'
ctx.fillRect(px + 15, py - 2, 2, 2) // Ruby
// Scepter in Hand
ctx.strokeStyle = '#facc15'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(px + 24, py + 18)
ctx.lineTo(px + 28, py + 34)
ctx.stroke()
// Kronkorken on top of scepter
ctx.fillStyle = '#ffd700'
ctx.beginPath()
ctx.arc(px + 24, py + 16, 4, 0, Math.PI * 2)
ctx.fill()
ctx.restore() // End camera translation
}
function gameLoop() {
if (!showLevelIntro.value) {
updatePhysics()
}
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) {
drawGame(ctx, canvasRef.value.width, canvasRef.value.height)
}
}
animationFrameId = requestAnimationFrame(gameLoop)
}
// Keyboard controls
function handleKeyDown(e: KeyboardEvent) {
if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = true
if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = true
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
onJump()
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = false
if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = false
}
onMounted(() => {
initLevel()
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
animationFrameId = requestAnimationFrame(gameLoop)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
})
</script>
<template>
<div class="platformer-container">
<canvas
ref="canvasRef"
width="360"
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">AKT II: DIE HOPFEN-PFADE</span>
<span class="level-hint">Tippe oder drücke SPRUNG zum Hüpfen</span>
</div>
</div>
<!-- Pre-Level Start Screen Modal -->
<div v-if="showLevelIntro" class="level-intro-modal">
<div class="intro-card">
<div class="intro-act-tag">👑 AKT II: JUMP & RUN</div>
<h2 class="intro-card-title">DIE HOPFEN-PFADE</h2>
<p class="intro-card-story">
Hört die Kunde, edler König Sascha!<br />
Deine Untertanen dürsten im ganzen Herzogtum nach frischem Gerstensaft. Doch ohne deine 40 heiligen Kronkorken bleiben die Fässer versiegelt!
</p>
<div class="mission-callout">
<span class="callout-icon">🎯</span>
<div class="callout-text">
<strong>KÖNIGLICHE MISSION:</strong><br />
Hüpfe über Ziegeldächer und Holzgerüste. Sammle mindestens <strong>5 Kronkorken</strong>, um das Tor zur Taverne zu entriegeln!
</div>
</div>
<div class="controls-overview">
<div class="ctrl-item">
<span class="ctrl-badge">🕹️ STEUERKREUZ</span>
<span class="ctrl-label">Laufen (Links / Rechts)</span>
</div>
<div class="ctrl-item">
<span class="ctrl-badge">🅰️ A-TASTE / TIPPE</span>
<span class="ctrl-label">Sprung (auch Doppel-Sprung!)</span>
</div>
</div>
<button class="start-act-btn" @click="startLevel">
🚀 AUF DIE DÄCHER! (START)
</button>
</div>
</div>
</div>
</template>
<style scoped>
.platformer-container {
position: relative;
width: 100%;
height: 100%;
background: #000;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.game-canvas {
width: 100%;
height: 100%;
object-fit: cover;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.platformer-hud-top {
position: absolute;
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;
}
.level-title {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #ffd700;
text-shadow: 1px 1px 0 #000;
}
.level-hint {
font-family: 'VT323', monospace;
font-size: 14px;
color: #fef08a;
text-shadow: 1px 1px 0 #000;
}
/* Pre-Level Start Screen Modal */
.level-intro-modal {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
z-index: 50;
backdrop-filter: blur(2px);
}
.intro-card {
background: #231209;
border: 3px double #d4af37;
border-radius: 10px;
padding: 16px 14px;
max-width: 320px;
width: 100%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.95);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
}
.intro-act-tag {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #f59e0b;
letter-spacing: 0.5px;
}
.intro-card-title {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: #ffd700;
margin: 0;
text-shadow: 1px 1px 0 #000;
}
.intro-card-story {
font-family: 'MedievalSharp', serif;
font-size: 13px;
color: #fef08a;
line-height: 1.35;
margin: 0;
}
.mission-callout {
background: #451a03;
border-left: 3px solid #22c55e;
border-radius: 4px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.callout-icon {
font-size: 20px;
}
.callout-text {
font-family: 'MedievalSharp', serif;
font-size: 12.5px;
color: #bbf7d0;
line-height: 1.3;
}
.controls-overview {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
background: #170c06;
border: 1px solid #78350f;
border-radius: 6px;
padding: 6px 10px;
}
.ctrl-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.ctrl-badge {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #ffd700;
}
.ctrl-label {
font-family: 'MedievalSharp', serif;
font-size: 12px;
color: #fed7aa;
}
.start-act-btn {
background: linear-gradient(180deg, #dc2626 0%, #991b1b 100%);
color: #fff;
border: 2px solid #ffd700;
border-radius: 8px;
padding: 10px 16px;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
cursor: pointer;
margin-top: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
transition: transform 0.1s;
}
.start-act-btn:active {
transform: scale(0.96);
}
</style>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { PROLOGUE_DIALOGUES, type DialogueNode } from '../config/dialogues'
import { CHARACTERS } from '../config/characters'
import { sound } from '../audio/synthAudio'
const store = useGameStore()
const currentNodeId = ref('start')
function selectChoice(choice: typeof PROLOGUE_DIALOGUES['start']['choices'][0]) {
sound.playDialogueBlip()
if (choice.action === 'addKronkorken' && choice.reward) {
store.addKronkorken(choice.reward)
}
if (choice.action === 'unlockBridgerton') {
store.unlockEasterEgg('bridgerton')
}
if (choice.action === 'startPlatformer') {
store.setAct('platformer')
return
}
if (choice.nextId && PROLOGUE_DIALOGUES[choice.nextId]) {
currentNodeId.value = choice.nextId
}
}
const currentNode = () => PROLOGUE_DIALOGUES[currentNodeId.value]
const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['sascha']
</script>
<template>
<div class="prologue-container">
<!-- Balcony Visual Scene with Regal Portrait Frame -->
<div class="scene-visual">
<div class="balcony-backdrop"></div>
<div class="scene-tag">
<span class="scene-title">SCHLOSS HAUMDAUCHER – DER BALKON</span>
</div>
<div class="speaker-frame">
<img
:src="currentSpeaker().portrait"
:alt="currentSpeaker().name"
class="speaker-visual"
/>
</div>
</div>
<!-- Retro Dialogue Card -->
<div class="dialogue-card">
<div class="dialogue-header">
<div class="speaker-name-badge">
<span class="speaker-name">{{ currentSpeaker().name }}</span>
<span class="speaker-title">{{ currentSpeaker().title }}</span>
</div>
</div>
<div class="dialogue-body">
<p class="dialogue-text">„{{ currentNode().text }}“</p>
</div>
<div class="dialogue-choices">
<button
v-for="(choice, idx) in currentNode().choices"
:key="idx"
class="choice-btn"
@click="selectChoice(choice)"
>
<span class="choice-arrow">▶</span>
<span class="choice-text">„{{ choice.text }}“</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.prologue-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #1a0f0a;
overflow: hidden;
}
.scene-visual {
position: relative;
flex: 0 0 auto;
min-height: 175px;
padding: 10px 8px;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
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;
}
.scene-tag {
position: relative;
background: rgba(0, 0, 0, 0.85);
border: 1px solid #ffd700;
padding: 4px 10px;
border-radius: 4px;
z-index: 2;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
}
.scene-title {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #ffd700;
letter-spacing: 0.5px;
}
.speaker-frame {
position: relative;
width: 116px;
height: 116px;
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 top;
image-rendering: crisp-edges;
}
.dialogue-card {
flex: 1;
background: #26160e;
padding: 12px;
display: flex;
flex-direction: column;
border-top: 1px solid #78350f;
overflow-y: auto;
}
.dialogue-header {
margin-bottom: 8px;
}
.speaker-name-badge {
display: flex;
flex-direction: column;
}
.speaker-name {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #facc15;
}
.speaker-title {
font-size: 11px;
color: #cbd5e1;
font-family: 'MedievalSharp', serif;
}
.dialogue-body {
margin-bottom: 10px;
background: rgba(0, 0, 0, 0.4);
padding: 8px 10px;
border-left: 3px solid #d4af37;
border-radius: 4px;
}
.dialogue-text {
margin: 0;
font-family: 'MedievalSharp', serif;
font-size: 14px;
line-height: 1.35;
color: #fef3c7;
}
.dialogue-choices {
display: flex;
flex-direction: column;
gap: 6px;
}
.choice-btn {
display: flex;
align-items: center;
gap: 8px;
background: #3b2014;
border: 1px solid #b45309;
border-radius: 6px;
padding: 8px 10px;
text-align: left;
color: #fef08a;
font-family: 'MedievalSharp', serif;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.choice-btn:hover, .choice-btn:active {
background: #78350f;
border-color: #ffd700;
transform: translateX(3px);
}
.choice-arrow {
color: #f59e0b;
font-size: 10px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
export interface CharacterConfig {
id: string
name: string
title: string
portrait: string
description: string
famousQuote: string
themeColor: string
}
export const CHARACTERS: Record<string, CharacterConfig> = {
sascha: {
id: 'sascha',
name: 'König Sascha I.',
title: 'Der 40-jährige Imperator',
portrait: '/assets/characters/sascha/portrait_v2.jpg',
description: 'Herrscher über Haumdaucher, Träger der heiligen Sonnenbrille und Beschützer des 135-BPM-Basses.',
famousQuote: 'O zapft is im Reiche! Wo san meine Kronkorken?!',
themeColor: '#d4af37'
},
mop: {
id: 'mop',
name: 'Mop (Moritz)',
title: 'Der Welten-Erschaffer & Matrix-Architekt',
portrait: '/assets/characters/mop/portrait_v2.jpg',
description: 'Sitzt auf einer Wolke über dem Schloss und baut aus Pixeln und Hopfen diese Welt.',
famousQuote: 'Just adding a few more polygons here... wait, pixels. Pixels.',
themeColor: '#38bdf8'
},
nat: {
id: 'nat',
name: 'NAT (Josef)',
title: 'Hof-Chronist & Barde des Wildschweins',
portrait: '/assets/characters/nat/portrait_v2.jpg',
description: 'Kennt alle 3 Staffeln Bridgerton in- und auswendig, trägt florale Bomberjacken und erzählt epische Geschichten.',
famousQuote: 'Also pass auf Sascha... ich damals mit meinen Girls ab...',
themeColor: '#22c55e'
},
verena: {
id: 'verena',
name: 'Königin Verena',
title: 'Herrin des Hofes',
portrait: '/assets/characters/verena/portrait_v2.jpg',
description: 'Liebt Sascha über alles auf der Welt, hat aber ein unumstößliches königliches Edikt erlassen.',
famousQuote: 'Ich hab dich lieb mein Schatz, aber ich putze nach der Party fei ned!',
themeColor: '#ec4899'
},
hansi: {
id: 'hansi',
name: 'Hansi (Johann)',
title: 'Ritter der floralen Garde',
portrait: '/assets/characters/hansi/portrait_v2.jpg',
description: 'Perfekt gestylter Schnurrbart und florale Plattenrüstung.',
famousQuote: 'Habe die Ehre! Der Bass im Rittersaal ist auf Punkt.',
themeColor: '#6366f1'
},
lutz: {
id: 'lutz',
name: 'Lutz',
title: 'Der ZTUL-Hofschornsteinfeger',
portrait: '/assets/characters/lutz/portrait_v2.jpg',
description: 'Ein Hüne von Gestalt mit Kappe, runder Brille und dem legendären ZTUL-Trikot.',
famousQuote: 'Glück auf, Euer Durchlaucht! Schornstein ist frei für den Party-Nebel.',
themeColor: '#64748b'
},
armend: {
id: 'armend',
name: 'Armend',
title: 'Meister-Schmied der Kronkorken-Manufaktur',
portrait: '/assets/characters/armend/portrait_v2.jpg',
description: 'Prachtvoller Vollbart, formt aus 40 Kronkorken den goldenen Sound-Verstärker.',
famousQuote: 'Schlag zu, solange das Blech warm ist!',
themeColor: '#f97316'
},
amy: {
id: 'amy',
name: 'Amy (Amelie)',
title: 'Die Hof-Künstlerin',
portrait: '/assets/characters/amy/portrait_v2.jpg',
description: 'Kreatives Genie in Latzhose mit Dutt und feinen Kräuter-Zigarren.',
famousQuote: 'Rave the Kingdom! Die Farben brauchen mehr Bass!',
themeColor: '#a855f7'
},
pue: {
id: 'pue',
name: 'Pü (Jakob)',
title: 'Lichtmeister & Kristall-Rave-Magier',
portrait: '/assets/characters/pue/portrait_v2.jpg',
description: 'Lässt im geheimen Hinterzimmer die Disco-Kristalle tanzen und erhellt die Entourage mit bunten Lasern.',
famousQuote: 'Bass auf Anschlag, Kristalle auf Stroboskop! Willkommen im geheimen Hinterzimmer!',
themeColor: '#eab308'
},
mario: {
id: 'mario',
name: 'Mario',
title: 'Hof-Gaukler & Kettensägen-Virtuose',
portrait: '/assets/characters/mario/portrait_v2.jpg',
description: 'Jongliert furchtlos auf dem Marktplatz mit kreischenden Kettensägen und Hopfenkrügen.',
famousQuote: 'Pass auf die Zinken auf! Kettensägen-Akrobatik für König Sascha!',
themeColor: '#14b8a6'
},
mane: {
id: 'mane',
name: 'Mane (Ramona)',
title: 'Königliche Alpen-Wanderin',
portrait: '/assets/characters/mane/portrait_v2.jpg',
description: 'Zieht mit Wanderstock, Sonnenbrille und Flechtzopf durchs alpine Hinterland von Haumdaucher.',
famousQuote: 'Schee ists hier! Noch 3 Kilometer über den Grat bis zum Gipfel-Bier!',
themeColor: '#84cc16'
},
flo_maier: {
id: 'flo_maier',
name: 'Flo Maier',
title: 'Hof-DJ & Sound-Ingenieur',
portrait: '/assets/characters/flo_maier/portrait_v2.jpg',
description: 'Mastermind an den Turntables und Behüter der heiligen 135-BPM-Frequenz.',
famousQuote: 'Dieser Track drückt mehr als die königliche Kutsche!',
themeColor: '#0284c7'
},
viktor: {
id: 'viktor',
name: 'Viktor',
title: 'Nachtwächter & Party-Ritter',
portrait: '/assets/characters/viktor/portrait_v2.jpg',
description: 'Wacht im samtenen Vampir-Umhang über das Nachtleben und sorgt für eiskaltes Helles.',
famousQuote: 'Die Nacht ist jung und der Gerstensaft fließt in Strömen!',
themeColor: '#b91c1c'
},
djs: {
id: 'djs',
name: 'Die Hof-DJs',
title: 'Minnesänger der 135 BPM',
portrait: '/assets/characters/flo_maier/portrait_v2.jpg',
description: 'Stehen an der königlichen Kanzel am doppelten Eichenholz-Plattenteller.',
famousQuote: 'Drop it like it\'s 1426! Mehr Bass auf die Burgzinnen!',
themeColor: '#06b6d4'
},
gefolge: {
id: 'gefolge',
name: 'Das Haumdaucher Gefolge',
title: 'Die feiernde Reichs-Crowd',
portrait: '/assets/characters/viktor/portrait_v2.jpg',
description: 'Viktor der Vampir-Ritter, DJ Flo Maier, Hansi, Fabian, Albin, der bärtige Guatemalteke, Markus & Nathalie.',
famousQuote: 'Ein Hoch auf König Sascha! Hoch soll er leben!',
themeColor: '#e11d48'
}
}
@@ -0,0 +1,106 @@
export interface DialogueChoice {
text: string
nextId?: string
action?: 'addKronkorken' | 'unlockZuluman' | 'unlockBridgerton' | 'startPlatformer' | 'startExpress' | 'startFinale'
reward?: number
reply?: {
speakerId: string
text: string
}
}
export interface DialogueNode {
id: string
speakerId: string
text: string
choices: DialogueChoice[]
}
export const PROLOGUE_DIALOGUES: Record<string, DialogueNode> = {
start: {
id: 'start',
speakerId: 'nat',
text: 'Seyd gegrüßet, Sascha! 40 Lenze zählet dein edles Antlitz! Aber ich sag\'s dir wie\'s is... der Hofstaat ist in heller Aufruhr. Ein Drama wie damals beim königlichen Ball in Staffel 2 von Bridgerton! Sämtliche 40 königlichen Kronkorken san über ganz Haumdaucher verstreut!',
choices: [
{
text: 'Zefix Josef! Ohne Kronkorken kein Bier, und ohne Bier kein Bass!',
nextId: 'mop_appears',
action: 'unlockBridgerton'
},
{
text: 'Bridgerton?! Josef, konzentrier dich! Wo san meine 40 Kronkorken?!',
nextId: 'mop_appears'
},
{
text: 'Lass mich raten... Du mit deinen Girls ab und zack, Leergut weg?',
nextId: 'nat_girls',
action: 'addKronkorken',
reward: 2
}
]
},
nat_girls: {
id: 'nat_girls',
speakerId: 'nat',
text: 'Ja mei, woher weißt du das immer?! Wir zogen durch die Lande, die Musik war laut, der Gerstensaft floss in Strömen... und dann hat der Wind die Korken über die Dächer geweht!',
choices: [
{
text: 'Na servus. Dann muss der König wohl selbst ran.',
nextId: 'mop_appears'
}
]
},
mop_appears: {
id: 'mop_appears',
speakerId: 'mop',
text: 'Habe die Ehre, Sascha! Keine Panik, dein Erschaffer hat schon die Sprungfedern in deine königlichen Stiefel programmiert. Sammle mindestens 15 Kronkorken auf den Hopfen-Pfaden, bevor wir in die Taverne einziehen!',
choices: [
{
text: 'Auf geht\'s Mop! Starte den Kronkorken-Kreuzzug!',
action: 'startPlatformer'
},
{
text: 'Zeig mir erst noch meine Sonnenbrille für den Style.',
nextId: 'sascha_ready'
}
]
},
sascha_ready: {
id: 'sascha_ready',
speakerId: 'sascha',
text: 'Sonnenbrille sitzt. Hermelinmantel weht im Wind. Haumdaucher, der König kommt!',
choices: [
{
text: 'Level 1 starten: Die Hopfen-Pfade!',
action: 'startPlatformer'
}
]
}
}
export const GSTANZL_DUEL = [
{
insult: 'Dein Techno-Bass ist so dünn wie schales Exportbier!',
answers: [
{ text: 'Dafür schallert mein Drop lauter als dei ganze Ahnengalerie!', correct: true },
{ text: 'Ich trinke eigentlich lieber Weißwein.', correct: false },
{ text: 'Aber meine Sonnenbrille ist dafür sehr dunkel!', correct: false }
]
},
{
insult: 'Mit 40 Lenzen bist du doch reif fürs Altenteil, alter Mann!',
answers: [
{ text: 'Mit 40 fängt der Rave erst an, und i tanz di locker an die Wand!', correct: true },
{ text: 'Ja mei, der Rücken zwickt schon a bisserl.', correct: false },
{ text: 'Ich frage mal schnell meinen Steuerberater.', correct: false }
]
},
{
insult: 'Niemand im ganzen Herzogtum feiert deine 135 BPM!',
answers: [
{ text: 'Ganz Haumdaucher bebt, wenn der König den Fader bewegt!', correct: true },
{ text: 'Vielleicht sollte ich lieber Schlager spielen.', correct: false },
{ text: 'Pü hat aber gesagt, das Licht passt dazu.', correct: false }
]
}
]
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
import { useGameStore, saveStateToStorage } from './stores/gameStore'
const store = useGameStore()
store.$subscribe((_mutation, state) => {
saveStateToStorage(state)
})
app.mount('#app')
@@ -0,0 +1,221 @@
import { defineStore } from 'pinia'
import { sound } from '../audio/synthAudio'
export type GameAct = 'intro' | 'prologue' | 'platformer' | 'tavern' | 'party_express' | 'finale'
export interface GameState {
currentAct: GameAct
kronkorken: number
beers: number
guestsServed: number
gstanzlWon: boolean
partyMood: number
inventory: string[]
unlockedEasterEggs: string[]
startTime: number
elapsedSeconds: number
soundMuted: boolean
}
export const STORAGE_KEY = 'sasch40_game_save_v1'
const VALID_ACTS: GameAct[] = ['intro', 'prologue', 'platformer', 'tavern', 'party_express', 'finale']
function getInitialState(): GameState {
const defaults: GameState = {
currentAct: 'intro',
kronkorken: 0,
beers: 0,
guestsServed: 0,
gstanzlWon: false,
partyMood: 20,
inventory: ['Königliche Sonnenbrille', 'Leergutsack'],
unlockedEasterEggs: [],
startTime: Date.now(),
elapsedSeconds: 0,
soundMuted: false,
}
if (typeof window === 'undefined' || !window.localStorage) {
return defaults
}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaults
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
const state: GameState = {
currentAct: VALID_ACTS.includes(parsed.currentAct) ? parsed.currentAct : 'intro',
kronkorken: typeof parsed.kronkorken === 'number' && !isNaN(parsed.kronkorken) ? parsed.kronkorken : 0,
beers: typeof parsed.beers === 'number' && !isNaN(parsed.beers) ? parsed.beers : 0,
guestsServed: typeof parsed.guestsServed === 'number' && !isNaN(parsed.guestsServed) ? parsed.guestsServed : 0,
gstanzlWon: Boolean(parsed.gstanzlWon),
partyMood: typeof parsed.partyMood === 'number' && !isNaN(parsed.partyMood) ? parsed.partyMood : 20,
inventory: Array.isArray(parsed.inventory) ? parsed.inventory : defaults.inventory,
unlockedEasterEggs: Array.isArray(parsed.unlockedEasterEggs) ? parsed.unlockedEasterEggs : [],
startTime: typeof parsed.startTime === 'number' ? parsed.startTime : defaults.startTime,
elapsedSeconds: typeof parsed.elapsedSeconds === 'number' ? parsed.elapsedSeconds : 0,
soundMuted: Boolean(parsed.soundMuted)
}
if (state.soundMuted) {
sound.setMuted(true)
}
return state
}
} catch (e) {
console.warn('Failed to parse saved game state:', e)
}
return defaults
}
export function saveStateToStorage(state: GameState) {
if (typeof window === 'undefined' || !window.localStorage) return
try {
const dataToSave = {
currentAct: state.currentAct,
kronkorken: state.kronkorken,
beers: state.beers,
guestsServed: state.guestsServed,
gstanzlWon: state.gstanzlWon,
partyMood: state.partyMood,
inventory: state.inventory,
unlockedEasterEggs: state.unlockedEasterEggs,
startTime: state.startTime,
elapsedSeconds: state.elapsedSeconds,
soundMuted: state.soundMuted
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave))
} catch (e) {
console.warn('Failed to save game state to localStorage:', e)
}
}
export const useGameStore = defineStore('game', {
state: (): GameState => getInitialState(),
getters: {
isArcadeMode(state): boolean {
return state.currentAct === 'platformer' || state.currentAct === 'party_express'
},
totalScore(state): number {
const capPoints = state.kronkorken * 100
const beerPoints = state.beers * 250
const guestPoints = state.guestsServed * 300
const gstanzlBonus = state.gstanzlWon ? 1500 : 0
const eggBonus = state.unlockedEasterEggs.length * 500
const completionBonus = 2000
return capPoints + beerPoints + guestPoints + gstanzlBonus + eggBonus + completionBonus
},
scoreRank(): { rank: string; title: string; color: string } {
const score = this.totalScore
if (score >= 15000) {
return {
rank: 'S-RANG (LEGENDÄR)',
title: 'Göttlicher Imperator des 135-BPM-Schalldrucks',
color: '#ffd700'
}
} else if (score >= 12000) {
return {
rank: 'A-RANG (HERZOGLICH)',
title: 'Großfürst des Gerstensafts & Rave-Legende',
color: '#38bdf8'
}
} else if (score >= 9000) {
return {
rank: 'B-RANG (RITTERLICH)',
title: 'Ehren-Ritter der Hopfen-Manufaktur',
color: '#4ade80'
}
} else {
return {
rank: 'C-RANG (KNAPPE)',
title: 'Durstiger Party-Knappe von Haumdaucher',
color: '#fbbf24'
}
}
},
titleAward(state): string {
return this.scoreRank.title
}
},
actions: {
addKronkorken(amount: number = 1) {
this.kronkorken += amount
this.partyMood = Math.min(100, this.partyMood + 2 * amount)
sound.playCoin()
},
addBeer() {
this.beers++
this.partyMood = Math.min(100, this.partyMood + 10)
sound.playGulp()
},
serveGuest() {
this.guestsServed++
this.addBeer()
this.addKronkorken(3)
},
setGstanzlWon() {
this.gstanzlWon = true
this.addKronkorken(15)
},
addItem(item: string) {
if (!this.inventory.includes(item)) {
this.inventory.push(item)
}
},
unlockEasterEgg(egg: string) {
if (!this.unlockedEasterEggs.includes(egg)) {
this.unlockedEasterEggs.push(egg)
this.addKronkorken(5)
}
},
setAct(act: GameAct) {
this.currentAct = act
if (act === 'finale') {
sound.playFanfare()
}
},
startGame() {
this.currentAct = 'prologue'
sound.startBGM()
},
toggleMute() {
this.soundMuted = sound.toggleMute()
},
resetGame() {
if (typeof window !== 'undefined' && window.localStorage) {
try {
localStorage.removeItem(STORAGE_KEY)
} catch (e) {
console.warn('Failed to clear saved state', e)
}
}
this.currentAct = 'intro'
this.kronkorken = 0
this.beers = 0
this.guestsServed = 0
this.gstanzlWon = false
this.partyMood = 20
this.inventory = ['Königliche Sonnenbrille', 'Leergutsack']
this.unlockedEasterEggs = []
this.startTime = Date.now()
this.elapsedSeconds = 0
saveStateToStorage(this.$state)
}
}
})
+28
View File
@@ -0,0 +1,28 @@
:root {
font-family: 'MedievalSharp', serif, system-ui;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
background-color: #0f0a07;
color: #fef3c7;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
body, html {
width: 100%;
height: 100%;
overflow: hidden;
touch-action: manipulation;
}
button {
user-select: none;
outline: none;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
host: true,
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB