Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e45dbc2e19 | ||
|
|
7a7038e2c2 | ||
|
|
4488749d6f | ||
|
|
f6d39f01da | ||
|
|
5852479c21 | ||
|
|
d11a84a0d3 | ||
|
|
f2ef5a8834 | ||
|
|
80a056f720 | ||
|
|
87649e6e22 | ||
|
|
c9e21c082d | ||
|
|
a7b93ca34a |
@@ -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.
|
||||
* **Method**: GitOps-style directory structure (Namespace = Folder).
|
||||
* **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)
|
||||
|
||||
### 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 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
|
||||
* **Secrets**: `git-crypt` is used extensively. Ensure you have the unlock key before editing `*.secret` files.
|
||||
* **Identity**:
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
kubespray
|
||||
venv
|
||||
credentials/
|
||||
inventory/**/credentials/
|
||||
|
||||
@@ -1,49 +1,83 @@
|
||||
# AGENTS.md
|
||||
|
||||
> [!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
|
||||
* **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.
|
||||
* **Idempotency**: The process is designed to be repeatable. The `kubespray` folder is treated as ephemeral and is re-created by `init.sh`.
|
||||
* **Topology**: **Single-node cluster** (`haumdaucher`, `136.243.23.215`). The single node acts simultaneously as control plane, etcd member, and worker.
|
||||
* **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
|
||||
The core workflow is encapsulated in `init.sh`.
|
||||
|
||||
### `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/`.
|
||||
### 1. Source `init.sh`
|
||||
```bash
|
||||
source init.sh
|
||||
```
|
||||
**Actions performed**:
|
||||
- Clones / checks out the Kubespray branch defined by `VERSION` (`release-2.28`).
|
||||
- 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`.
|
||||
|
||||
### Usage
|
||||
1. **Source the script**:
|
||||
```bash
|
||||
source init.sh
|
||||
```
|
||||
2. **Deploy / Upgrade**:
|
||||
After sourcing, go to the `kubespray` directory and run the Ansible playbooks as instructed by the script output.
|
||||
* **Standard Run**:
|
||||
```bash
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||
```
|
||||
* **Forced Upgrade**:
|
||||
```bash
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
|
||||
```
|
||||
### 2. Upgrading the Single-Node Cluster (Two-Phase Decoupled Workflow)
|
||||
|
||||
#### Phase 1: Reconcile Kubespray Tooling Baseline (Keep existing k8s version)
|
||||
```bash
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||
```
|
||||
*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
|
||||
cd kubespray
|
||||
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
|
||||
* `init.sh`: The entry point script. **Source of truth for Kubespray version.**
|
||||
* `inventory/`: Contains cluster inventory configurations (hosts, variables). **Edit this, not the one in `kubespray/`**.
|
||||
* `kubespray/`: (Ignored/Ephemeral) The checked-out Kubespray repository. **Do not edit files here directly**; they will be overwritten.
|
||||
|
||||
## 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.
|
||||
* `init.sh`: The entrypoint script (sources environment, applies patches, sets up inventory).
|
||||
* `patches/`: Monorepo patches applied automatically to ephemeral `kubespray/`.
|
||||
* `inventory/`: Source of truth for cluster inventory and variables.
|
||||
* `kubespray/`: (Ephemeral / Gitignored) Cloned Kubespray repository.
|
||||
@@ -1,114 +1,165 @@
|
||||
# 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
|
||||
. ./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
|
||||
---
|
||||
|
||||
```sh
|
||||
cd kubespray
|
||||
vagrant up
|
||||
# up and abkle to ssh
|
||||
vagrant ssh k8s-1
|
||||
```
|
||||
## Step-by-Step Upgrade Runbook
|
||||
|
||||
## Prod
|
||||
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.
|
||||
|
||||
Prepare server:
|
||||
* deactivate swap!
|
||||
* `moritz username ALL=(ALL) NOPASSWD:ALL`
|
||||
* `
|
||||
### Step 0: Pre-Flight Checks & Backups
|
||||
|
||||
```sh
|
||||
ssh centos@<ip>
|
||||
# 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
|
||||
```
|
||||
1. **Verify SSH and sudo access**:
|
||||
```sh
|
||||
ssh moritz@136.243.23.215 "sudo kubectl get nodes -o wide"
|
||||
```
|
||||
|
||||
Install Kubernetes:
|
||||
2. **Check Pod Health**:
|
||||
```sh
|
||||
ssh moritz@136.243.23.215 "sudo kubectl get pods -A | grep -v -E 'Running|Completed'"
|
||||
```
|
||||
|
||||
```sh
|
||||
. ./init.sh
|
||||
# follow instructions from output, sth like:
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||
```
|
||||
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"
|
||||
```
|
||||
|
||||
And get credentials:
|
||||
---
|
||||
|
||||
```sh
|
||||
ssh <ip>
|
||||
sudo su - root
|
||||
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
|
||||
```
|
||||
### Step 1: Reconcile Kubespray Baseline (Phase 1)
|
||||
|
||||
Foreward in k8s-directory.
|
||||
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
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||
```
|
||||
4. Verify cluster health:
|
||||
```sh
|
||||
kubectl get nodes -o wide
|
||||
kubectl get pods -n kube-system
|
||||
```
|
||||
|
||||
## Upgrade cluster
|
||||
---
|
||||
|
||||
Check the current default value of `kube_version` in cloned repository.
|
||||
### Step 2: Upgrade Kubernetes Version (Phase 2)
|
||||
|
||||
**FOR K8S VERSION - Edit /Users/moritz/src/infrapuzzle/bootstrap/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml**
|
||||
1. In `bootstrap/inventory/prod/group_vars/k8s_cluster/k8s-cluster.yml`, update `kube_version`:
|
||||
```yaml
|
||||
kube_version: 1.32.13
|
||||
```
|
||||
|
||||
```sh
|
||||
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
|
||||
# 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
|
||||
```
|
||||
2. Sync the configuration into the `kubespray/` workspace:
|
||||
```sh
|
||||
sync-inventory
|
||||
```
|
||||
|
||||
History:
|
||||
3. Run the in-place upgrade playbook:
|
||||
```sh
|
||||
cd kubespray
|
||||
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml
|
||||
```
|
||||
|
||||
* 2020-04-18 kube_version=v1.16.8 kubespray_branch=release-2.12
|
||||
---
|
||||
|
||||
## Add node
|
||||
### Step 3: Post-Upgrade Verification
|
||||
|
||||
See [documentation](https://github.com/kubernetes-sigs/kubespray/blob/master/docs/nodes.md).
|
||||
1. **Check component versions**:
|
||||
```sh
|
||||
kubectl version
|
||||
```
|
||||
Ensure both `Client Version` and `Server Version` report `v1.32.13`.
|
||||
|
||||
Note: This was more or less a trial and error approach. Running different playbooks over and over again got it right at some point.
|
||||
2. **Verify Node status**:
|
||||
```sh
|
||||
kubectl get nodes -o wide
|
||||
```
|
||||
Ensure status is `Ready` and version matches `v1.32.13`.
|
||||
|
||||
```sh
|
||||
ansible-playbook -i inventory/prod/inventory.ini --limit=ns3088070.ip-37-59-40.eu,ns3100058.ip-37-59-61.eu scale.yml
|
||||
ansible-playbook -i inventory/prod/inventory.ini --limit=etcd,kube-master -e ignore_assert_errors=yes cluster.yml
|
||||
```
|
||||
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`.
|
||||
|
||||
This runs everything and is kind of idempotent:
|
||||
4. **Verify Workloads**:
|
||||
```sh
|
||||
kubectl get pods -A | grep -v -E "Running|Completed"
|
||||
```
|
||||
|
||||
```sh
|
||||
ansible-playbook -i inventory/prod/inventory.ini cluster.yml
|
||||
```
|
||||
---
|
||||
|
||||
## Upgrade to 2.31.3
|
||||
## Troubleshooting & Edge Cases
|
||||
|
||||
Required to execute:
|
||||
### Kubeadm Preflight `CreateJob` Failure
|
||||
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`.
|
||||
|
||||
```
|
||||
ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true -e drain_nodes=false upgrade-cluster.yml
|
||||
```
|
||||
### 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.
|
||||
|
||||
@@ -1,60 +1,78 @@
|
||||
#!/bin/sh
|
||||
|
||||
# check whether script is sourced
|
||||
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "script is executed, but needs to be sourced" && exit 1
|
||||
# Ensure script is sourced, not executed directly
|
||||
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && echo "Error: init.sh must be sourced: source ./init.sh (or . ./init.sh)" && exit 1
|
||||
|
||||
echo "######################################################################################"
|
||||
echo "## Reinit repository"
|
||||
rm -rf kubespray
|
||||
VERSION="release-2.27"
|
||||
git clone --branch $VERSION https://github.com/kubernetes-sigs/kubespray.git
|
||||
BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="release-2.28" # Note: release-2.28 is the final release line supporting Ubuntu 20.04 LTS
|
||||
|
||||
echo "######################################################################################"
|
||||
echo "## Activating pyenv venv"
|
||||
echo "======================================================================================"
|
||||
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 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
|
||||
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 "######################################################################################"
|
||||
echo "## Customizing vagrant dev env"
|
||||
mkdir -p kubespray/vagrant
|
||||
cat << EOF > kubespray/vagrant/config.rb
|
||||
\$instance_name_prefix = "k8s"
|
||||
\$vm_cpus = 4
|
||||
\$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
|
||||
# Helper function for re-syncing changes made in ./inventory to ephemeral ./kubespray/inventory
|
||||
sync-inventory() {
|
||||
rsync -av "$BOOTSTRAP_DIR/inventory/" "$BOOTSTRAP_DIR/kubespray/inventory/"
|
||||
}
|
||||
export -f sync-inventory 2>/dev/null || true
|
||||
alias sync-inventory="rsync -av '$BOOTSTRAP_DIR/inventory/' '$BOOTSTRAP_DIR/kubespray/inventory/'"
|
||||
|
||||
# make the rpm cache
|
||||
mkdir -p kubespray/temp/docker_rpms
|
||||
|
||||
echo "###############"
|
||||
echo "Now cd to kubespray and 'vagrant up'"
|
||||
echo "======================================================================================"
|
||||
echo "## [4/4] Environment Ready! Single-Node Cluster Upgrade Workflow"
|
||||
echo "======================================================================================"
|
||||
echo ""
|
||||
echo "export KUBECONFIG=\"$( pwd )/kubespray/inventory/sample/artifacts/admin.conf\""
|
||||
|
||||
|
||||
echo "######################################################################################"
|
||||
echo "## * syncing config in kubespray dir"
|
||||
rsync -a ./inventory/ ./kubespray/inventory/
|
||||
echo "## * changing into kubespray dir"
|
||||
cd kubespray
|
||||
echo "## * execute the following command to force new settings"
|
||||
echo "ansible-playbook -i inventory/prod/inventory.ini cluster.yml"
|
||||
echo "## * execute the following command to force new settings with forced cluster upgrade"
|
||||
echo "ansible-playbook -i inventory/prod/inventory.ini -e upgrade_cluster_setup=true cluster.yml"
|
||||
|
||||
# cp -r kubespray/inventory/sample kubespray/inventory/prod
|
||||
# rm kubespray/inventory/prod/inventory.ini
|
||||
# cp ./prod.ini kubespray/inventory/prod/inventory.ini
|
||||
#gsed -i "s/kube_network_plugin: .*/kube_network_plugin: flannel/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-cluster.yml
|
||||
# gsed -i "s/# calico_iptables_backend: \"Legacy\"/calico_iptables_backend: \"NFT\"/" ./kubespray/inventory/prod/group_vars/k8s-cluster/k8s-net-calico.yml
|
||||
#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
|
||||
echo "Current directory changed to: $BOOTSTRAP_DIR/kubespray"
|
||||
cd "$BOOTSTRAP_DIR/kubespray"
|
||||
echo ""
|
||||
echo "=== PRE-UPGRADE CHECKLIST ==="
|
||||
echo "1. Verify SSH connection and sudo access:"
|
||||
echo " ssh moritz@136.243.23.215 'sudo kubectl get nodes -o wide'"
|
||||
echo "2. Take manual etcd snapshot before upgrading:"
|
||||
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 ""
|
||||
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 ""
|
||||
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 ""
|
||||
echo "=== POST-UPGRADE VERIFICATION ==="
|
||||
echo " kubectl version"
|
||||
echo " kubectl get nodes -o wide"
|
||||
echo " kubectl get pods -A | grep -v -E 'Running|Completed'"
|
||||
echo "======================================================================================"
|
||||
|
||||
@@ -27,7 +27,17 @@ kube_kubeadm_apiserver_extra_args:
|
||||
kube_api_anonymous_auth: true
|
||||
|
||||
## 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
|
||||
#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
|
||||
@@ -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
|
||||
Ensure that the `OpenClawInstance` is successfully reconciled:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.git
|
||||
scripts
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.local
|
||||
@@ -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;"]
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 656 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 775 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 797 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
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 |
|
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();
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 8.1 MiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 78 KiB |