From fbf6f223fcb9d63b0be1dd136669accf8a605b2b Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 2 Nov 2026 22:53:03 +0100 Subject: [PATCH] Switch repo to English, add i18n with EN/DE language files and first-start language selection - in-game texts moved to lang/en.lang and lang/de.lang (key = text, placeholders) - game logic emits message keys + args, translated at display time - localStorage access via own miniquad JS plugin (www/dc_plugin.js) - code comments, DESIGN.md, CREDITS.md translated; README.md added Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 4 +- CREDITS.md | 12 +- DESIGN.md | 109 ++++++++-------- Dockerfile | 5 +- README.md | 58 +++++++++ lang/de.lang | 199 +++++++++++++++++++++++++++++ lang/en.lang | 200 ++++++++++++++++++++++++++++++ nginx.conf | 2 +- src/game.rs | 238 ++++++++++++++++++----------------- src/gfx.rs | 60 ++++----- src/i18n.rs | 87 +++++++++++++ src/main.rs | 13 ++ src/map.rs | 44 +++---- src/platform.rs | 50 ++++++++ src/sound.rs | 16 +-- src/ui.rs | 302 +++++++++++++++++++++++++++------------------ www/dc_plugin.js | 32 +++++ www/index.html | 3 +- 18 files changed, 1072 insertions(+), 362 deletions(-) create mode 100644 README.md create mode 100644 lang/de.lang create mode 100644 lang/en.lang create mode 100644 src/i18n.rs create mode 100644 src/platform.rs create mode 100644 www/dc_plugin.js diff --git a/.cargo/config.toml b/.cargo/config.toml index e71299d..8b9e8e6 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,4 @@ -# quad-snd (macroquad-Audio) importiert JS-Funktionen aus mq_js_bundle.js. -# Neuere Rust-Versionen verlangen dafür explizit --allow-undefined. +# quad-snd (macroquad audio) imports JS functions from mq_js_bundle.js, and our own +# plugin (www/dc_plugin.js) provides more. Newer Rust versions need --allow-undefined for that. [target.wasm32-unknown-unknown] rustflags = ["-C", "link-arg=--allow-undefined"] diff --git a/CREDITS.md b/CREDITS.md index 21141dd..11b0c61 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,9 +1,9 @@ # Credits -## Schrift: DINOBYTE bitmap font -- Autor: **mby** – https://mby.itch.io/dinobyte -- Lizenz: **Creative Commons Attribution 4.0 International (CC BY 4.0)** – https://creativecommons.org/licenses/by/4.0/ -- Datei: `assets/dbyte_1x.png` (unverändert). Im Spiel wird sie ganzzahlig skaliert und eingefärbt dargestellt. +## Font: DINOBYTE bitmap font +- Author: **mby** – https://mby.itch.io/dinobyte +- License: **Creative Commons Attribution 4.0 International (CC BY 4.0)** – https://creativecommons.org/licenses/by/4.0/ +- File: `assets/dbyte_1x.png` (unmodified). In the game it is scaled by integer factors and tinted. The swapped glyphs ö/ò are corrected in the character mapping, not in the file. -## Alles andere -Code, prozedurale Grafiken und Spieldesign: DungeonCraft-Projekt. +## Everything else +Code, procedural graphics and game design: the DungeonCraft project. diff --git a/DESIGN.md b/DESIGN.md index f60b3e5..859d371 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,64 +1,65 @@ -# DungeonCraft – Design-Entscheidungen +# DungeonCraft – Design Decisions -Eigenständiges Dungeon-Management-Spiel (Genre: Dungeon-Aufbau & Verteidigung), läuft als WASM im Browser. +An original dungeon management game (genre: dungeon building & defence) that runs as WASM in the browser. -## Technik -- Rust + **macroquad** (WebGL), Ziel `wasm32-unknown-unknown` -- Oberfläche komplett **im Canvas** -- Schrift: **DINOBYTE** Bitmap-Font (CP437, 6×8 px, CC BY 4.0, siehe CREDITS.md) für alle Texte, ganzzahlig skaliert -- Alle UI-Fenster mit **CP437-Rahmen** (einfach für Panels, doppelt für Dialoge); Balken (HP/Mana/Tor) als Pixel-Balken -- Sound: Event-System + macroquad-Audio vorbereitet (Lautstärke, Stumm-Taste M); Klänge werden später generiert +## Technology +- Rust + **macroquad** (WebGL), target `wasm32-unknown-unknown` +- User interface drawn entirely **in the canvas** +- Font: **DINOBYTE** bitmap font (CP437, 6×8 px, CC BY 4.0, see CREDITS.md) for all texts, integer scaled +- All UI windows use **CP437 frames** (single lines for panels, double lines for dialogs); bars (HP/mana/gate) are pixel bars +- Sound: event system + macroquad audio prepared (volume, mute key M); sounds will be generated later +- Languages: all in-game texts live in **language files** (`lang/*.lang`, key = text); English is the reference/fallback, German included, more can be added. The language is chosen on first start and stored in the browser. +- Repository language: **English** (code, comments, docs, commit messages) -## Grafik -- **3D**, **echt isometrisch** (orthografische Kamera) -- Kamera **frei 360° drehbar + neigbar** -- Wände mit **unregelmäßiger Oberfläche** (Rauschverformung der Wandflächen, nahtlos zwischen Nachbarn) -- Texturen: **prozedurale Pixelart** (Kachel-Atlas) -- Figuren: **Pixel-Sprites als Billboards** (immer zur Kamera gedreht) -- Licht: **Dunkel + Fackeln** – Grunddunkelheit, eroberte Bereiche und Lichtquellen (Fackeln, Kern, Portal, Tor) hell, flackerndes Licht -- **Kachel-Outlines**: dezente dunkle Linien um jedes Feld (Böden, Wandoberseiten, senkrechte Wandkanten), folgen der unregelmäßigen Oberfläche; abschaltbar (Taste G, später Grafik-Einstellungen) -- UI bleibt 2D-Ebene darüber, Minikarte +## Graphics +- **3D**, **true isometric** (orthographic camera) +- Camera **freely rotatable 360° and tiltable** +- Walls with an **irregular surface** (noise displacement of the wall faces, seamless between neighbours) +- Textures: **procedural pixel art** (tile atlas) +- Units: **pixel sprites as billboards** (always facing the camera) +- Lighting: **dark + torches** – dark base level; claimed areas and light sources (torches, core, portal, gate) are bright, flickering light +- **Tile outlines**: subtle dark lines around every tile (floors, wall tops, vertical wall edges), following the irregular surface; toggle with G (later in the graphics settings) +- The UI is a 2D layer on top, including a minimap -## Steuerung -- Linksklick: aktives Werkzeug benutzen (Rechteck aufziehen für Graben/Räume) -- Rechtsklick: Einheit aus der Hand absetzen -- Kamera: WASD/Pfeiltasten, rechte Maus ziehen, Mausrad-Zoom, Bildschirmrand -- Drehen/Neigen: mittlere Maustaste ziehen, Q/E drehen, R/F neigen -- Zauber: 0 Blitz, X Heilung, V Wicht beschwören -- Fackeln: automatisch beim Erobern; fremde Gebiete schummrig sichtbar +## Controls +- Left click: use the active tool (drag a rectangle for digging/rooms) +- Right click: drop a unit from the hand +- Camera: WASD/arrow keys, right mouse drag, mouse wheel zoom, screen edge +- Rotate/tilt: middle mouse drag, Q/E rotate, R/F tilt +- Spells: 0 lightning, X heal, V summon imp +- Torches: placed automatically when ground is claimed; foreign areas are dimly visible -## Geplant (nach 3D) -- Repo komplett auf Englisch; Spieltexte in separaten Sprachdateien (EN + DE, erweiterbar), Sprache im Spiel umschaltbar -- Menü: Speichern (Download *.dcsave), Laden (Dateiauswahl + Drag & Drop), Autosave im Browser (localStorage) -- Einstellungen: Sprache + Ton, Kamera, Grafik, Spiel, Tastenbelegung – gespeichert im Browser **und** im Savegame -- Musik: eigener **XM/MOD-Replayer in Rust**, gemeinsamer Mixer für Musik + Effekte (Streaming über JS-Audio-Plugin, ersetzt macroquad-Audio) -- Soundtrack (als .xm komponiert, in MilkyTracker bearbeitbar): Titel/Menü, mehrere ruhige Aufbau-Tracks, Kampf, Sieg/Niederlage-Jingles; **Titelmelodie als Leitmotiv in allen Stücken erkennbar** +## Planned +- Menu: save (download `*.dcsave`), load (file picker + drag & drop), autosave in the browser (localStorage) +- Settings: language + sound, camera, graphics, game, key bindings – stored in the browser **and** in the savegame +- Music: own **XM/MOD replayer in Rust**, shared mixer for music + sound effects (streamed via a JS audio plugin, replaces macroquad audio) +- Soundtrack (composed as `.xm`, editable in MilkyTracker): title/menu, several calm build tracks, battle, victory/defeat jingles; **the title melody is a recognisable leitmotif in every piece** -## Spielinhalt (volles Konzept) -- **Graben**: Erde/Gold markieren → Grubenwichte graben, bringen Gold heim, erobern Boden -- **Räume**: Schatzkammer, Quartier, Pilzgarten, Trainingshalle, Bibliothek -- **Portal** erobern → Kreaturen: Kobold, Höhlenspinne, Schattenmagier, Steinoger (abhängig von Räumen) -- **Bedürfnisse**: Schlaf, Essen, Lohn (Zahltag); unzufriedene Kreaturen gehen -- **Hand**: eigene Einheiten aufnehmen/absetzen (max. 8) -- **Zauber** (Mana): Blitz, Heilung, Grubenwicht beschwören -- **Helden**: Ritter, Bogenschützin, Priester, Zwergenhauer, Lichtpaladin (graben sich zum Seelenkern durch) +## Game content +- **Digging**: mark earth/gold → Pit Imps dig, bring gold home and claim ground +- **Rooms**: treasury, lair, mushroom garden, training hall, library +- Claim the **portal** → creatures: kobold, cave spider, shadow mage, stone ogre (depending on rooms) +- **Needs**: sleep, food, wages (payday); unhappy creatures leave +- **Hand**: pick up / drop own units (max. 8) +- **Spells** (mana): lightning, heal, summon Pit Imp +- **Heroes**: knight, archer, priest, dwarf miner, paladin of light (they dig their way to the Soul Core) -## Ziel -- **Sieg**: Heldentor zerstören -- **Niederlage**: Seelenkern zerstört +## Goal +- **Victory**: destroy the Hero Gate +- **Defeat**: the Soul Core is destroyed -## Torangriff -- **Angriffsflagge**: Spieler setzt eine Sammelflagge; freie Kreaturen marschieren hin und greifen alles in der Nähe an (auch das Tor) -- **Tor**: ca. 3000 HP, regeneriert langsam, wenn es nicht angegriffen wird -- **Verteidiger** erscheinen bei HP-Schwellen 75 % / 50 % / 25 % (letzter Trupp mit Boss), herausfordernd +## Attacking the gate +- **Attack flag**: the player places a rally flag; free creatures march there and attack everything nearby (including the gate) +- **Gate**: about 3000 HP, regenerates slowly when not under attack +- **Defenders** appear at HP thresholds 75% / 50% / 25% (the last squad with a boss), challenging -## Heldenwellen -- **Endlos**, solange das Tor steht -- Stärke richtet sich nach dem **Armeewert** (Summe HP × Angriff aller Kreaturen inkl. Stufen) -- Wellenstärke **mitwachsend 60 % → 100 %** des Armeewerts, mit etwas Zufall -- **Schonfrist** 5 Minuten, danach **Mindestwelle** (z. B. 2 Ritter) auch bei schwacher Armee -- Ziel: immer schaffbar, aber herausfordernd +## Hero waves +- **Endless** while the gate stands +- Strength follows the **army value** (sum of HP × damage of all creatures incl. levels) +- Wave strength **grows from 60% to 100%** of the army value, with some randomness +- **Grace period** of 5 minutes, then a **minimum wave** (e.g. 2 knights) even with a weak army +- Goal: always beatable, but challenging -## Betrieb / Test -- Build im Docker (Multi-Stage: Rust → nginx:alpine) -- Testversion auf Port **18093**, später zusätzlich Subdomain über Nginx Proxy Manager +## Operations / testing +- Build in Docker (multi-stage: Rust → nginx:alpine) +- Test build on port **18093**, later additionally a subdomain via Nginx Proxy Manager diff --git a/Dockerfile b/Dockerfile index 6f2907f..2cfeb77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,18 +4,19 @@ RUN rustup target add wasm32-unknown-unknown WORKDIR /src COPY Cargo.toml Cargo.lock ./ COPY .cargo .cargo -# Abhängigkeiten vorab bauen (Docker-Cache) +# build dependencies first (Docker layer cache) RUN mkdir src && echo "fn main() {}" > src/main.rs \ && cargo build --release --target wasm32-unknown-unknown \ && rm -rf src COPY assets assets +COPY lang lang COPY src src RUN touch src/main.rs && cargo build --release --target wasm32-unknown-unknown \ && mkdir /out \ && cp target/wasm32-unknown-unknown/release/dungeoncraft.wasm /out/ \ && cp "$CARGO_HOME"/registry/src/*/macroquad-*/js/mq_js_bundle.js /out/ -# --- Auslieferung: nginx +# --- serve: nginx FROM nginx:alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY www/ /usr/share/nginx/html/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffb59de --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# DungeonCraft + +An original dungeon building & defence game that runs in the browser (Rust + [macroquad](https://github.com/not-fl3/macroquad), compiled to WebAssembly). + +Dig out your underground realm, claim the portal to attract creatures, keep them fed, rested and paid, fend off endless hero waves that adapt to your army – and finally destroy the Hero Gate. + +- 3D isometric view with a freely rotatable and tiltable camera +- Procedural pixel art, irregular cave walls, flickering torch light +- CP437 text-mode style interface with the DINOBYTE bitmap font +- English and German (language files in `lang/`, easy to extend) + +See [DESIGN.md](DESIGN.md) for all design decisions and [CREDITS.md](CREDITS.md) for third-party assets. + +## Running + +The easiest way is Docker; the image builds the WASM file and serves it with nginx: + +```sh +docker compose up -d --build +# open http://localhost:18093 +``` + +### Building without Docker + +Requirements: Rust with the `wasm32-unknown-unknown` target. + +```sh +rustup target add wasm32-unknown-unknown +cargo build --release --target wasm32-unknown-unknown +cp target/wasm32-unknown-unknown/release/dungeoncraft.wasm www/ +cp ~/.cargo/registry/src/*/macroquad-*/js/mq_js_bundle.js www/ +python3 -m http.server 8080 -d www # open http://localhost:8080 +``` + +### Testing the game logic + +The game logic (`src/game.rs`, `src/map.rs`) does not depend on macroquad and contains a headless simulation test. + +## Controls + +| Input | Action | +|---|---| +| Left click / drag | Use the active tool (dig and rooms: drag a rectangle) | +| Right click | Drop a unit from your hand | +| Right drag, WASD, arrows, screen edge | Move the camera | +| Middle drag, Q/E, R/F | Rotate / tilt the camera | +| Mouse wheel | Zoom | +| 1–9, 0, X, V | Select a tool or spell | +| P / Space | Pause | +| M, +/- | Sound on/off, volume | +| G | Tile outlines on/off | +| H | Help | + +## Adding a language + +1. Copy `lang/en.lang` to `lang/.lang` and translate the texts (placeholders `{0}`, `{1}` stay as they are). +2. Add the file to `SOURCES` in `src/i18n.rs`. +3. Note: the DINOBYTE font only covers the CP437 character set. diff --git a/lang/de.lang b/lang/de.lang new file mode 100644 index 0000000..18bd081 --- /dev/null +++ b/lang/de.lang @@ -0,0 +1,199 @@ +# DungeonCraft Sprachdatei: Deutsch +# Format: key = Text Platzhalter: {0}, {1}, ... Zeilen mit # sind Kommentare. + +lang.name = Deutsch + +# ---- Einheiten +unit.worker = Grubenwicht +unit.kobold = Kobold +unit.spider = Höhlenspinne +unit.mage = Schattenmagier +unit.ogre = Steinoger +unit.knight = Ritter +unit.archer = Bogenschützin +unit.priest = Priester +unit.dwarf = Zwergenhauer +unit.paladin = Lichtpaladin + +# ---- Räume +room.treasury = Schatzkammer +room.lair = Quartier +room.farm = Pilzgarten +room.training = Trainingshalle +room.library = Bibliothek + +# ---- Tätigkeiten +job.none = Überlegt +job.dig = Gräbt +job.claim = Erobert Boden +job.deliver = Bringt Gold zur Schatzkammer +job.wander = Streift umher +job.sleep_go = Geht schlafen +job.sleep = Schläft +job.eat = Isst +job.train = Trainiert +job.study = Studiert +job.fight = Kämpft +job.core_attack = Greift den Seelenkern an +job.core_march = Marschiert zum Seelenkern +job.leave = Verlässt das Reich +job.gate_attack = Greift das Heldentor an +job.rally = Folgt der Angriffsflagge + +# ---- Meldungen +msg.welcome1 = Willkommen, Herrscher des Unterreichs! Beschütze deinen Seelenkern und zerstöre das Heldentor. +msg.welcome2 = Markiere Erde und Goldadern mit dem Werkzeug »Graben« (Taste 1). Deine Grubenwichte erledigen den Rest. +msg.welcome3 = Grabe dich zum Portal durch und erobere es. Kreaturen brauchen ein Quartier und einen Pilzgarten. +msg.welcome4 = Mit der Angriffsflagge (Taste 9) schickst du deine Kreaturen in den Kampf, auch gegen das Heldentor. +msg.wave_warn = Späher melden: Welle {0} der Helden erreicht in 30 Sekunden das Tor! +msg.defeat = Dein Seelenkern ist zerbrochen. Das Unterreich ist verloren. +msg.victory = Das Heldentor ist zerstört. Das Unterreich gehört dir! +msg.creature_arrived = Ein {0} ist durch das Portal gekommen. +msg.payday_partial = Zahltag! {0} Kreaturen bezahlt ({1} Gold), {2} gingen leer aus und sind wütend! +msg.payday = Zahltag! {0} Kreaturen erhielten {1} Gold. +msg.wave_boss = Welle {0}: Ein Lichtpaladin führt {1} Helden in dein Reich! +msg.wave = Welle {0}: {1} Helden dringen durch das Tor! +msg.gate_boss = Das Tor wankt! Der Torwächter erscheint mit {0} Verteidigern! +msg.gate_defenders = Das Tor ruft {0} Verteidiger herbei! +msg.level_up = {0} erreicht Stufe {1}. +msg.hero_slain = {0} besiegt! Beute: {1} Gold. +msg.creature_died = Dein {0} ist gefallen. +msg.portal_claimed = Das Portal gehört nun dir! Kreaturen werden davon angelockt. +msg.flag_removed = Angriffsflagge entfernt. Deine Kreaturen kehren zurück. +msg.flag_set = Angriffsflagge gesetzt! Kampfbereite Kreaturen sammeln sich dort. +msg.creature_leaves = Ein unzufriedener {0} verlässt dein Reich! +msg.no_gold = Nicht genug Gold! +msg.sold = Räume verkauft: +{0} Gold. +msg.no_mana_bolt = Nicht genug Mana für den Blitz. +msg.no_mana_heal = Nicht genug Mana für die Heilung. +msg.no_mana_cost = Nicht genug Mana ({0} benötigt). + +# ---- Hinweise (höchstens alle 30 Sekunden) +hint.portal_no_lair = Das Portal ist offen, aber ohne Quartier kommen keine Kreaturen. +hint.no_farm = Kreaturen wollen essen: Baue einen Pilzgarten. +hint.lair_full = Alle Quartierplätze sind belegt. Vergrößere dein Quartier. +hint.gate_attack = Deine Kreaturen greifen das Heldentor an! +hint.treasury_full = Deine Schatzkammern sind voll! Baue mehr Schatzkammer-Felder. +hint.flag_unreachable = Die Angriffsflagge ist für deine Kreaturen nicht erreichbar. +hint.flag_ground = Die Flagge muss auf begehbarem Boden stehen. +hint.no_beds = Kreaturen müssen auf dem nackten Boden schlafen. Vergrößere dein Quartier! +hint.hungry = Deine Kreaturen hungern! Vergrößere den Pilzgarten. +hint.core_attacked = Dein Seelenkern wird angegriffen! +hint.build_where = Räume können nur auf eroberten, leeren Bodenfeldern gebaut werden. +hint.hand_full = Deine Hand ist voll (max. 8). Rechtsklick lässt Einheiten fallen. +hint.drop_where = Einheiten können nur auf eigenem Gebiet abgesetzt werden. +hint.summon_where = Grubenwichte können nur auf eigenem Gebiet beschworen werden. + +# ---- Werkzeuge +tool.dig.label = Graben +tool.dig.name = Graben +tool.dig.desc = Erde und Gold markieren. Ziehen für Rechteck; auf markiertem Feld beginnen hebt Markierung auf. +tool.hand.label = Hand +tool.hand.name = Hand +tool.hand.desc = Eigene Einheit aufnehmen (max. 8). Rechtsklick setzt sie auf eigenem Gebiet ab. +tool.treasury.label = Schatz +tool.treasury.desc = Lagert Gold: +500 Kapazität pro Feld. +tool.lair.label = Quartier +tool.lair.desc = Ein Schlafplatz pro Feld. Bestimmt, wie viele Kreaturen kommen. +tool.farm.label = Pilze +tool.farm.desc = Nahrung für Kreaturen. Ab 6 Feldern kommen Höhlenspinnen. +tool.training.label = Training +tool.training.desc = Kreaturen steigen Stufen auf (kostet Gold). Ab 9 Feldern kommen Steinoger. +tool.library.label = Bücher +tool.library.desc = Kreaturen erzeugen Mana. Ab 6 Feldern kommen Schattenmagier. +tool.sell.label = Verkauf +tool.sell.name = Verkaufen +tool.sell.desc = Räume zurück in Boden verwandeln (50 % Erstattung). +tool.flag.label = Flagge +tool.flag.name = Angriffsflagge +tool.flag.desc = Kampfbereite Kreaturen sammeln sich hier. Nahe am Heldentor greifen sie es an. Erneut klicken entfernt sie. +tool.lightning.label = Blitz +tool.lightning.name = Blitz +tool.lightning.desc = 55 Schaden an Helden im Umkreis. +tool.heal.label = Heilung +tool.heal.name = Heilung +tool.heal.desc = Heilt eigene Einheiten im Umkreis um 50 %. +tool.summon.label = Wicht +tool.summon.name = Grubenwicht beschwören +tool.summon.desc = Erschafft einen neuen Grubenwicht auf eigenem Gebiet. +cost.gold_tile = {0} Gold/Feld +cost.mana = {0} Mana +ui.cost = Kosten: {0} +ui.key = Taste: {0} + +# ---- Anzeige +hud.gold = Gold +hud.mana = Mana +hud.core = Kern +hud.gate = Tor +hud.creatures = Kreaturen +hud.workers = Wichte +hud.wave = Welle {0} +hud.army = Armee +hud.off = aus +hud.hand = Hand +hud.empty = leer +ui.map = Karte +ui.pause = PAUSE +ui.pause_hint = P: weiter + +# ---- Hilfe +help.goal = Ziel: Zerstöre das Heldentor und schütze deinen Seelenkern. +help.k_left = Linksklick Werkzeug benutzen (Graben/Räume: Rechteck ziehen) +help.k_right = Rechtsklick Einheit aus der Hand absetzen +help.k_pan = Rechts ziehen Karte verschieben (auch WASD/Pfeile, Bildrand) +help.k_rot = Mitte ziehen Kamera drehen und neigen (auch Q/E, R/F) +help.k_zoom = Mausrad Zoomen +help.k_tools = 1-9, 0, X, V Werkzeug wählen +help.k_misc = P / Leertaste Pause M Ton an/aus +/- Lautstärke +help.k_misc2 = G Kachel-Umrisse an/aus H Diese Hilfe +help.s1 = 1. Grabe zum Portal und lass es von Grubenwichten erobern. +help.s2 = 2. Baue Quartier, Pilzgarten und Schatzkammer. +help.s3 = 3. Trainiere deine Kreaturen, zahle ihren Lohn pünktlich. +help.s4 = 4. Wehre die Heldenwellen ab. Sie passen sich deiner Armee an. +help.s5 = 5. Setze die Angriffsflagge nahe ans Tor und greife an! +help.font = Schrift: DINOBYTE von mby (CC BY 4.0) +help.start = Klick zum Starten + +# ---- Spielende +end.victory = SIEG! +end.defeat = NIEDERLAGE +end.victory_sub = Das Heldentor ist gefallen. +end.defeat_sub = Dein Seelenkern ist zerbrochen. +end.stats = Spielzeit {0} Wellen {1} Armee {2} +end.new = Neues Spiel (N) + +# ---- Infofeld +info.level = {0} (Stufe {1}) +info.hp = HP {0}/{1} +info.guard = Torwächter +info.intruder = Eindringling +info.needs = Energie {0}% Hunger {1}% +info.mood = Laune: {0} +mood.happy = zufrieden +mood.grumpy = mürrisch +mood.angry = wütend +info.wage = Lohn: {0} Gold +info.carries = Trägt {0} Gold +tile.rock = Fels +tile.indestructible = unzerstörbar +tile.earth = Erde +tile.gold = Goldader +tile.gold_amount = {0} Gold +tile.floor = Boden +owner.player = erobert +owner.hero = Heldengebiet +owner.none = neutral +tile.core = Seelenkern +tile.portal = Portal +portal.claimed = erobert +portal.neutral = neutral: Wichte können es erobern +tile.gate = Heldentor +farm.food = {0} Pilze +lair.taken = Schlafplatz belegt +lair.free = Schlafplatz frei +tile.marked = zum Graben markiert + +# ---- Sprachauswahl +langsel.title = Sprache +langsel.hint = Klicke eine Sprache an. Du kannst sie später in den Einstellungen ändern. diff --git a/lang/en.lang b/lang/en.lang new file mode 100644 index 0000000..46610a0 --- /dev/null +++ b/lang/en.lang @@ -0,0 +1,200 @@ +# DungeonCraft language file: English (reference language) +# Format: key = text Placeholders: {0}, {1}, ... Lines starting with # are comments. +# To add a language: copy this file, translate the texts and register it in src/i18n.rs. + +lang.name = English + +# ---- units +unit.worker = Pit Imp +unit.kobold = Kobold +unit.spider = Cave Spider +unit.mage = Shadow Mage +unit.ogre = Stone Ogre +unit.knight = Knight +unit.archer = Archer +unit.priest = Priest +unit.dwarf = Dwarf Miner +unit.paladin = Paladin of Light + +# ---- rooms +room.treasury = Treasury +room.lair = Lair +room.farm = Mushroom Garden +room.training = Training Hall +room.library = Library + +# ---- jobs +job.none = Thinking +job.dig = Digging +job.claim = Claiming ground +job.deliver = Carrying gold to the treasury +job.wander = Wandering +job.sleep_go = Going to sleep +job.sleep = Sleeping +job.eat = Eating +job.train = Training +job.study = Studying +job.fight = Fighting +job.core_attack = Attacking the Soul Core +job.core_march = Marching to the Soul Core +job.leave = Leaving the realm +job.gate_attack = Attacking the Hero Gate +job.rally = Following the attack flag + +# ---- messages +msg.welcome1 = Welcome, ruler of the underworld! Protect your Soul Core and destroy the Hero Gate. +msg.welcome2 = Mark earth and gold veins with the Dig tool (key 1). Your Pit Imps do the rest. +msg.welcome3 = Dig through to the portal and claim it. Creatures need a lair and a mushroom garden. +msg.welcome4 = Use the attack flag (key 9) to send your creatures into battle, even against the Hero Gate. +msg.wave_warn = Scouts report: hero wave {0} reaches the gate in 30 seconds! +msg.defeat = Your Soul Core has shattered. The underworld is lost. +msg.victory = The Hero Gate is destroyed. The underworld is yours! +msg.creature_arrived = A {0} came through the portal. +msg.payday_partial = Payday! {0} creatures paid ({1} gold), {2} got nothing and are angry! +msg.payday = Payday! {0} creatures received {1} gold. +msg.wave_boss = Wave {0}: a Paladin of Light leads {1} heroes into your realm! +msg.wave = Wave {0}: {1} heroes break through the gate! +msg.gate_boss = The gate is crumbling! The Gate Warden appears with {0} defenders! +msg.gate_defenders = The gate summons {0} defenders! +msg.level_up = {0} reaches level {1}. +msg.hero_slain = {0} defeated! Loot: {1} gold. +msg.creature_died = Your {0} has fallen. +msg.portal_claimed = The portal is yours! It attracts creatures. +msg.flag_removed = Attack flag removed. Your creatures return. +msg.flag_set = Attack flag set! Battle-ready creatures gather there. +msg.creature_leaves = An unhappy {0} leaves your realm! +msg.no_gold = Not enough gold! +msg.sold = Rooms sold: +{0} gold. +msg.no_mana_bolt = Not enough mana for Lightning. +msg.no_mana_heal = Not enough mana for Heal. +msg.no_mana_cost = Not enough mana ({0} needed). + +# ---- hints (shown at most every 30 seconds) +hint.portal_no_lair = The portal is open, but without a lair no creatures will come. +hint.no_farm = Creatures want to eat: build a mushroom garden. +hint.lair_full = All beds in the lair are taken. Enlarge your lair. +hint.gate_attack = Your creatures are attacking the Hero Gate! +hint.treasury_full = Your treasuries are full! Build more treasury tiles. +hint.flag_unreachable = Your creatures cannot reach the attack flag. +hint.flag_ground = The flag must be placed on walkable ground. +hint.no_beds = Creatures have to sleep on bare ground. Enlarge your lair! +hint.hungry = Your creatures are starving! Enlarge the mushroom garden. +hint.core_attacked = Your Soul Core is under attack! +hint.build_where = Rooms can only be built on claimed, empty floor tiles. +hint.hand_full = Your hand is full (max. 8). Right-click drops units. +hint.drop_where = Units can only be dropped on your own territory. +hint.summon_where = Pit Imps can only be summoned on your own territory. + +# ---- tools (label = short text under the button) +tool.dig.label = Dig +tool.dig.name = Dig +tool.dig.desc = Mark earth and gold. Drag for a rectangle; starting on a marked tile removes the marks. +tool.hand.label = Hand +tool.hand.name = Hand +tool.hand.desc = Pick up one of your units (max. 8). Right-click drops it on your own territory. +tool.treasury.label = Treasury +tool.treasury.desc = Stores gold: +500 capacity per tile. +tool.lair.label = Lair +tool.lair.desc = One bed per tile. Determines how many creatures come. +tool.farm.label = Mushrooms +tool.farm.desc = Food for creatures. With 6 tiles or more, cave spiders come. +tool.training.label = Training +tool.training.desc = Creatures gain levels (costs gold). With 9 tiles or more, stone ogres come. +tool.library.label = Library +tool.library.desc = Creatures generate mana. With 6 tiles or more, shadow mages come. +tool.sell.label = Sell +tool.sell.name = Sell +tool.sell.desc = Turn rooms back into floor (50% refund). +tool.flag.label = Flag +tool.flag.name = Attack Flag +tool.flag.desc = Battle-ready creatures gather here. Near the Hero Gate they attack it. Click again to remove it. +tool.lightning.label = Lightning +tool.lightning.name = Lightning +tool.lightning.desc = 55 damage to heroes in the area. +tool.heal.label = Heal +tool.heal.name = Heal +tool.heal.desc = Heals your units in the area by 50%. +tool.summon.label = Imp +tool.summon.name = Summon Pit Imp +tool.summon.desc = Creates a new Pit Imp on your own territory. +cost.gold_tile = {0} gold/tile +cost.mana = {0} mana +ui.cost = Cost: {0} +ui.key = Key: {0} + +# ---- HUD +hud.gold = Gold +hud.mana = Mana +hud.core = Core +hud.gate = Gate +hud.creatures = Creatures +hud.workers = Imps +hud.wave = Wave {0} +hud.army = Army +hud.off = off +hud.hand = Hand +hud.empty = empty +ui.map = Map +ui.pause = PAUSE +ui.pause_hint = P: continue + +# ---- help +help.goal = Goal: destroy the Hero Gate and protect your Soul Core. +help.k_left = Left click Use tool (dig/rooms: drag a rectangle) +help.k_right = Right click Drop a unit from your hand +help.k_pan = Right drag Move the map (also WASD/arrows, screen edge) +help.k_rot = Middle drag Rotate and tilt the camera (also Q/E, R/F) +help.k_zoom = Mouse wheel Zoom +help.k_tools = 1-9, 0, X, V Select tool +help.k_misc = P / Space Pause M Sound on/off +/- Volume +help.k_misc2 = G Tile outlines on/off H This help +help.s1 = 1. Dig to the portal and let your Pit Imps claim it. +help.s2 = 2. Build a lair, a mushroom garden and a treasury. +help.s3 = 3. Train your creatures and pay their wages on time. +help.s4 = 4. Fend off the hero waves. They adapt to your army. +help.s5 = 5. Place the attack flag near the gate and attack! +help.font = Font: DINOBYTE by mby (CC BY 4.0) +help.start = Click to start + +# ---- end screen +end.victory = VICTORY! +end.defeat = DEFEAT +end.victory_sub = The Hero Gate has fallen. +end.defeat_sub = Your Soul Core has shattered. +end.stats = Time {0} Waves {1} Army {2} +end.new = New Game (N) + +# ---- info panel +info.level = {0} (level {1}) +info.hp = HP {0}/{1} +info.guard = Gate Warden +info.intruder = Intruder +info.needs = Energy {0}% Hunger {1}% +info.mood = Mood: {0} +mood.happy = content +mood.grumpy = grumpy +mood.angry = angry +info.wage = Wage: {0} gold +info.carries = Carries {0} gold +tile.rock = Rock +tile.indestructible = indestructible +tile.earth = Earth +tile.gold = Gold Vein +tile.gold_amount = {0} gold +tile.floor = Floor +owner.player = claimed +owner.hero = hero territory +owner.none = neutral +tile.core = Soul Core +tile.portal = Portal +portal.claimed = claimed +portal.neutral = neutral: imps can claim it +tile.gate = Hero Gate +farm.food = {0} mushrooms +lair.taken = Bed taken +lair.free = Bed free +tile.marked = marked for digging + +# ---- language selection +langsel.title = Language +langsel.hint = Click a language. You can change it later in the settings. diff --git a/nginx.conf b/nginx.conf index 20ea7ba..296e1fb 100644 --- a/nginx.conf +++ b/nginx.conf @@ -2,7 +2,7 @@ server { listen 80; root /usr/share/nginx/html; index index.html; - # Testversion: immer frisch laden + # test build: always reload fresh files add_header Cache-Control "no-cache"; location / { try_files $uri $uri/ =404; diff --git a/src/game.rs b/src/game.rs index be1981f..1b4ddda 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,4 +1,4 @@ -// Spiellogik: Einheiten, KI, Wirtschaft, Wellen. +// Game logic: units, AI, economy, hero waves. use crate::map::*; @@ -9,7 +9,7 @@ pub const FIRST_WAVE: f32 = 300.0; pub const WAVE_GAP: f32 = 180.0; pub const MAX_SQUAD: usize = 18; -// Sound-Ereignisse. Die Spiellogik meldet sie, das Sound-Modul spielt sie ab. +// Sound events. The game logic reports them, the sound module plays them. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Sfx { Dig, @@ -56,10 +56,17 @@ impl Sfx { } } +// A game message: language key + arguments (arguments starting with '@' are keys too) +#[derive(Clone, Debug)] +pub struct Msg { + pub key: &'static str, + pub args: Vec, +} + #[derive(Clone, Copy)] pub struct SfxEvent { pub sfx: Sfx, - pub pos: Option<(f32, f32)>, // Weltposition (für Lautstärke nach Entfernung), None = UI + pub pos: Option<(f32, f32)>, // world position (for distance-based volume), None = UI } pub const MANA_MAX: f32 = 400.0; pub const CARRY_MAX: i32 = 300; @@ -92,16 +99,16 @@ pub struct St { pub fn st(k: EK) -> St { let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty }; match k { - EK::Worker => s("Grubenwicht", 45.0, 3.0, 1.0, 3.2, 1.0, 0, 0), - EK::Kobold => s("Kobold", 75.0, 8.0, 1.0, 2.6, 0.9, 40, 0), - EK::Spider => s("Höhlenspinne", 95.0, 10.0, 1.0, 2.9, 1.0, 60, 0), - EK::Mage => s("Schattenmagier", 60.0, 13.0, 4.5, 2.0, 1.4, 110, 0), - EK::Ogre => s("Steinoger", 230.0, 20.0, 1.0, 1.8, 1.4, 150, 0), - EK::Knight => s("Ritter", 140.0, 11.0, 1.0, 1.9, 1.1, 0, 120), - EK::Archer => s("Bogenschützin", 75.0, 9.0, 4.5, 2.1, 1.3, 0, 100), - EK::Priest => s("Priester", 80.0, 5.0, 3.5, 1.9, 1.5, 0, 120), - EK::Dwarf => s("Zwergenhauer", 110.0, 9.0, 1.0, 2.0, 1.0, 0, 100), - EK::Paladin => s("Lichtpaladin", 700.0, 26.0, 1.0, 1.7, 1.2, 0, 1000), + EK::Worker => s("unit.worker", 45.0, 3.0, 1.0, 3.2, 1.0, 0, 0), + EK::Kobold => s("unit.kobold", 75.0, 8.0, 1.0, 2.6, 0.9, 40, 0), + EK::Spider => s("unit.spider", 95.0, 10.0, 1.0, 2.9, 1.0, 60, 0), + EK::Mage => s("unit.mage", 60.0, 13.0, 4.5, 2.0, 1.4, 110, 0), + EK::Ogre => s("unit.ogre", 230.0, 20.0, 1.0, 1.8, 1.4, 150, 0), + EK::Knight => s("unit.knight", 140.0, 11.0, 1.0, 1.9, 1.1, 0, 120), + EK::Archer => s("unit.archer", 75.0, 9.0, 4.5, 2.1, 1.3, 0, 100), + EK::Priest => s("unit.priest", 80.0, 5.0, 3.5, 1.9, 1.5, 0, 120), + EK::Dwarf => s("unit.dwarf", 110.0, 9.0, 1.0, 2.0, 1.0, 0, 100), + EK::Paladin => s("unit.paladin", 700.0, 26.0, 1.0, 1.7, 1.2, 0, 1000), } } @@ -151,7 +158,7 @@ pub struct Ent { pub face: f32, pub moving: bool, pub working: bool, - pub guard: bool, // Torverteidiger + pub guard: bool, // gate defender } impl Ent { @@ -200,7 +207,7 @@ impl Ent { #[derive(Clone, Copy)] pub struct Fx { - pub kind: u8, // 0 Geschoss, 1 Blitz, 2 Treffer, 3 Rauch, 4 Heilung, 5 Gold, 6 Staub, 7 Stufenaufstieg, 8 Einnahme + pub kind: u8, // 0 projectile, 1 lightning, 2 hit, 3 smoke, 4 heal, 5 gold, 6 dust, 7 level up, 8 claim pub x: f32, pub y: f32, pub x2: f32, @@ -225,14 +232,14 @@ pub struct Game { pub portal_timer: f32, pub payday: f32, pub fx: Vec, - pub msgs: Vec, - pub state: u8, // 0 läuft, 1 Sieg, 2 Niederlage + pub msgs: Vec, + pub state: u8, // 0 running, 1 victory, 2 defeat pub paused: bool, pub hand: Vec, - pub counts: [usize; 5], // Schatzkammer, Quartier, Pilzgarten, Trainingshalle, Bibliothek + pub counts: [usize; 5], // treasury, lair, farm, training hall, library pub gate_hp: f32, pub gate_hit_t: f32, - pub gate_stage: u32, // ausgelöste Verteidiger-Schwellen (0..3) + pub gate_stage: u32, // defender thresholds triggered (0..3) pub flag: Option, pub events: Vec, pub last_wave_value: f32, @@ -240,7 +247,7 @@ pub struct Game { hint_t: [f32; 16], } -// Fackel an einer Wandseite: Wandkachel + Richtung (Index in DIR4) zur offenen Seite +// torch on a wall face: wall tile + direction (index into DIR4) towards the open side #[derive(Clone, Copy, PartialEq)] pub struct Torch { pub wall: usize, @@ -248,7 +255,7 @@ pub struct Torch { } impl Torch { - // Weltposition der Flamme (x, z) auf Bodenebene + // world position of the flame (x, z) on the ground plane pub fn pos(&self) -> (f32, f32) { let (x, y) = xy(self.wall); let (dx, dy) = DIR4[self.dir]; @@ -258,7 +265,7 @@ impl Torch { pub const ROOM_KINDS: [Kind; 5] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library]; pub const ROOM_COST: [i32; 5] = [60, 80, 100, 140, 160]; -pub const ROOM_NAMES: [&str; 5] = ["Schatzkammer", "Quartier", "Pilzgarten", "Trainingshalle", "Bibliothek"]; +pub const ROOM_NAMES: [&str; 5] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library"]; pub fn room_index(k: Kind) -> Option { ROOM_KINDS.iter().position(|&r| r == k) @@ -320,15 +327,15 @@ impl Game { g.place_torches(i); } } - g.msg("Willkommen, Herrscher des Unterreichs! Beschütze deinen Seelenkern und zerstöre das Heldentor.".into()); - g.msg("Markiere Erde und Goldadern mit dem Werkzeug »Graben« (Taste 1). Deine Grubenwichte erledigen den Rest.".into()); - g.msg("Grabe dich zum Portal durch und erobere es. Kreaturen brauchen ein Quartier und einen Pilzgarten.".into()); - g.msg("Mit der Angriffsflagge (Taste 9) schickst du deine Kreaturen in den Kampf, auch gegen das Heldentor.".into()); + g.msg("msg.welcome1", vec![]); + g.msg("msg.welcome2", vec![]); + g.msg("msg.welcome3", vec![]); + g.msg("msg.welcome4", vec![]); g } - pub fn msg(&mut self, s: String) { - self.msgs.push(s); + pub fn msg(&mut self, key: &'static str, args: Vec) { + self.msgs.push(Msg { key, args }); if self.msgs.len() > 50 { self.msgs.remove(0); } @@ -350,7 +357,7 @@ impl Game { (self.map.gate.0 as f32 + 1.5, self.map.gate.1 as f32 + 1.5) } - // Armeewert: sqrt(Summe HP * Summe Schaden/s) aller eigenen Kreaturen. + // Army value: sqrt(sum of HP * sum of damage/s) of all own creatures. pub fn army_value(&self) -> f32 { let (mut h, mut d) = (0.0, 0.0); for e in &self.ents { @@ -362,7 +369,7 @@ impl Game { (h * d).sqrt() } - // Wichte hängen Fackeln an Wände neben erobertem Boden (Mindestabstand 3,5 Felder) + // imps hang torches on walls next to claimed floor (min. distance 3.5 tiles) pub fn place_torches(&mut self, i: usize) { let (x, y) = xy(i); for (d, &(dx, dy)) in DIR4.iter().enumerate() { @@ -370,7 +377,7 @@ impl Game { if !inb(wx, wy) || !self.map.t[idx(wx, wy)].solid() { continue; } - // Richtung von der Wand zum Boden ist die Gegenrichtung + // the direction from the wall to the floor is the opposite one let t = Torch { wall: idx(wx, wy), dir: (d + 2) % 4 }; let (px, pz) = t.pos(); let near = self.torches.iter().any(|o| { @@ -383,10 +390,10 @@ impl Game { } } - fn hint(&mut self, key: usize, s: &str) { - if self.time - self.hint_t[key] > 30.0 { - self.hint_t[key] = self.time; - self.msg(s.into()); + fn hint(&mut self, slot: usize, key: &'static str) { + if self.time - self.hint_t[slot] > 30.0 { + self.hint_t[slot] = self.time; + self.msg(key, vec![]); } } @@ -460,7 +467,7 @@ impl Game { self.ents.iter().position(|e| e.id == id && e.alive && !e.held) } - // ---------------------------------------------------------------- Update + // ---------------------------------------------------------------- update pub fn update(&mut self, dt: f32) { let dt = dt.min(0.1); @@ -501,7 +508,7 @@ impl Game { self.wave_timer -= dt; if !self.warned && self.wave_timer < 30.0 { self.warned = true; - self.msg(format!("Späher melden: Welle {} der Helden erreicht in 30 Sekunden das Tor!", self.wave + 1)); + self.msg("msg.wave_warn", vec![(self.wave + 1).to_string()]); self.sfx_ui(Sfx::WaveAlarm); } if self.wave_timer <= 0.0 { @@ -527,12 +534,12 @@ impl Game { if self.core_hp <= 0.0 { self.core_hp = 0.0; self.state = 2; - self.msg("Dein Seelenkern ist zerbrochen. Das Unterreich ist verloren.".into()); + self.msg("msg.defeat", vec![]); self.sfx_ui(Sfx::Defeat); } else if self.gate_hp <= 0.0 { self.gate_hp = 0.0; self.state = 1; - self.msg("Das Heldentor ist zerstört. Das Unterreich gehört dir!".into()); + self.msg("msg.victory", vec![]); self.sfx_ui(Sfx::Victory); } } @@ -543,15 +550,15 @@ impl Game { let farm = self.counts[2]; let cap = lair.min(30); if lair == 0 { - self.hint(0, "Das Portal ist offen, aber ohne Quartier kommen keine Kreaturen."); + self.hint(0, "hint.portal_no_lair"); return; } if farm == 0 { - self.hint(1, "Kreaturen wollen essen: Baue einen Pilzgarten."); + self.hint(1, "hint.no_farm"); return; } if creatures >= cap { - self.hint(2, "Alle Quartierplätze sind belegt. Vergrößere dein Quartier."); + self.hint(2, "hint.lair_full"); return; } let mut opts: Vec<(EK, u32)> = vec![(EK::Kobold, 4)]; @@ -578,7 +585,7 @@ impl Game { self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1); self.fx(3, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 0.8, 0xffcc44ff); self.sfx(Sfx::PortalArrive, px as f32 + 1.5, py as f32 + 1.5); - self.msg(format!("Ein {} ist durch das Portal gekommen.", st(kind).name)); + self.msg("msg.creature_arrived", vec![format!("@{}", st(kind).name)]); } fn do_payday(&mut self) { @@ -605,14 +612,14 @@ impl Game { if paid + unpaid > 0 { self.sfx_ui(Sfx::Payday); if unpaid > 0 { - self.msg(format!("Zahltag! {} Kreaturen bezahlt ({} Gold), {} gingen leer aus und sind wütend!", paid, total as i32, unpaid)); + self.msg("msg.payday_partial", vec![paid.to_string(), (total as i32).to_string(), unpaid.to_string()]); } else { - self.msg(format!("Zahltag! {} Kreaturen erhielten {} Gold.", paid, total as i32)); + self.msg("msg.payday", vec![paid.to_string(), (total as i32).to_string()]); } } } - // Stärke eines Trupps (gleiche Formel wie der Armeewert) + // strength of a squad (same formula as the army value) fn squad_value(list: &[(EK, u32)]) -> f32 { let (mut h, mut d) = (0.0, 0.0); for &(k, l) in list { @@ -623,7 +630,7 @@ impl Game { (h * d).sqrt() } - // Stellt einen Heldentrupp mit mindestens der Zielstärke zusammen. + // builds a hero squad with at least the target strength fn build_squad(&mut self, target: f32, base_level: u32, pool: &[(EK, u32)], boss: Option, min: usize) -> Vec<(EK, u32)> { let total: u32 = pool.iter().map(|p| p.1).sum(); let mut list = Vec::new(); @@ -662,7 +669,7 @@ impl Game { self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 1.0, 0xffffeecc); } - // Endlose Wellen: Stärke 60 % -> 100 % des Armeewerts, mit etwas Zufall, mindestens 2 Ritter. + // Endless waves: strength 60% -> 100% of the army value, with some randomness, at least 2 knights. fn spawn_wave(&mut self) { use EK::*; let w = self.wave + 1; @@ -683,13 +690,13 @@ impl Game { self.spawn_squad(&list, false); self.sfx_ui(Sfx::WaveStart); if boss.is_some() { - self.msg(format!("Welle {}: Ein Lichtpaladin führt {} Helden in dein Reich!", w, list.len())); + self.msg("msg.wave_boss", vec![w.to_string(), list.len().to_string()]); } else { - self.msg(format!("Welle {}: {} Helden dringen durch das Tor!", w, list.len())); + self.msg("msg.wave", vec![w.to_string(), list.len().to_string()]); } } - // Verteidiger bei 75 / 50 / 25 % Tor-HP + // defenders at 75 / 50 / 25 % gate HP fn check_gate_stage(&mut self) { use EK::*; let frac = self.gate_hp / GATE_HP; @@ -707,9 +714,9 @@ impl Game { self.spawn_squad(&list, true); self.sfx_ui(Sfx::GateDefenders); if st_i == 3 { - self.msg(format!("Das Tor wankt! Der Torwächter erscheint mit {} Verteidigern!", list.len() - 1)); + self.msg("msg.gate_boss", vec![(list.len() - 1).to_string()]); } else { - self.msg(format!("Das Tor ruft {} Verteidiger herbei!", list.len())); + self.msg("msg.gate_defenders", vec![list.len().to_string()]); } } } @@ -731,7 +738,7 @@ impl Game { self.fx(2, fx, fy, 0.0, 0.0, 0.25, 0xffffffff); } self.sfx(Sfx::GateHit, gx, gy); - self.hint(10, "Deine Kreaturen greifen das Heldentor an!"); + self.hint(10, "hint.gate_attack"); self.check_gate_stage(); } @@ -755,7 +762,7 @@ impl Game { self.sfx(Sfx::LevelUp, e.x, e.y); } if !e.hero && e.level >= 3 { - self.msg(format!("{} erreicht Stufe {}.", s.name, e.level)); + self.msg("msg.level_up", vec![format!("@{}", s.name), e.level.to_string()]); } } if e.hero { @@ -767,7 +774,7 @@ impl Game { } } - // Bewegt eine Einheit entlang ihres Pfads. true = noch unterwegs. + // moves a unit along its path; true = still moving fn step_path(&mut self, e: &mut Ent, dt: f32) -> bool { if e.path.is_empty() { return false; @@ -776,7 +783,7 @@ impl Game { let tile = self.map.t[next]; if !tile.walkable() { if e.hero && tile.diggable() { - // Helden graben sich durch + // heroes dig their way through e.working = true; let (tx, ty) = xy(next); e.face = sgn(tx as f32 + 0.5 - e.x); @@ -893,7 +900,7 @@ impl Game { best } - // Kampf gegen eine bestimmte Einheit. false = Ziel ungültig/unerreichbar. + // fight a specific unit; false = target invalid/unreachable fn attack_job(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool { let Some(j) = self.find(id) else { return false }; let (tx, ty) = (self.ents[j].x, self.ents[j].y); @@ -921,7 +928,7 @@ impl Game { } } if !self.step_path(e, dt) && e.tile() == idx(tx as i32, ty as i32) { - // gleiches Feld: direkt annähern + // same tile: approach directly let dx = tx - e.x; let dy = ty - e.y; let l = (dx * dx + dy * dy).sqrt().max(0.001); @@ -974,14 +981,14 @@ impl Game { let got = self.add_gold(b); self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff); self.sfx(Sfx::HeroDeath, x, y); - self.msg(format!("{} besiegt! Beute: {} Gold.", s.name, got as i32)); + self.msg("msg.hero_slain", vec![format!("@{}", s.name), (got as i32).to_string()]); } else { self.sfx(Sfx::Death, x, y); - self.msg(format!("Dein {} ist gefallen.", s.name)); + self.msg("msg.creature_died", vec![format!("@{}", s.name)]); } } - // ---------------------------------------------------------------- Grubenwichte + // ---------------------------------------------------------------- pit imps (workers) fn dig_neighbor(&self, i: usize, me: u32) -> Option { let (x, y) = xy(i); @@ -1112,7 +1119,7 @@ impl Game { self.map.t[idx(px + x, py + y)].owner = Owner::Player; } } - self.msg("Das Portal gehört nun dir! Kreaturen werden davon angelockt.".into()); + self.msg("msg.portal_claimed", vec![]); self.fx(7, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 1.0, 0xffff66cc); } else { self.map.t[t].owner = Owner::Player; @@ -1128,7 +1135,7 @@ impl Game { let amount = e.carry as f32; let got = self.add_gold(amount); if got < amount { - self.hint(3, "Deine Schatzkammern sind voll! Baue mehr Schatzkammer-Felder."); + self.hint(3, "hint.treasury_full"); } if got > 0.0 { self.fx(5, e.x, e.y, 0.0, 0.0, 0.8, 0xff30d0ff); @@ -1182,7 +1189,7 @@ impl Game { } } - // ---------------------------------------------------------------- Kreaturen + // ---------------------------------------------------------------- creatures fn find_room_tile(&self, cur: usize, kind: Kind, me: u32, need_food: bool) -> Option> { self.map.search( @@ -1327,7 +1334,7 @@ impl Game { self.flag.is_some() && e.hp > e.max_hp * 0.5 && e.energy > 0.15 && e.hunger < 1.8 && e.happy > 0.0 } - // Angriffsflagge: zur Flagge marschieren; liegt das Tor in der Nähe, wird es angegriffen. + // Attack flag: march to the flag; if the gate is nearby, attack it. fn rally(&mut self, e: &mut Ent, dt: f32) { let Some(f) = self.flag else { e.job = Job::None; @@ -1335,7 +1342,7 @@ impl Game { return; }; if e.hp < e.max_hp * 0.3 || e.energy < 0.1 || e.hunger > 2.2 { - // erschöpft oder schwer verletzt: zurückziehen + // exhausted or badly hurt: retreat e.job = Job::None; e.path.clear(); return; @@ -1364,14 +1371,14 @@ impl Game { }; let d = ((e.x - tx).powi(2) + (e.y - ty).powi(2)).sqrt(); if d <= near && e.path.is_empty() { - return; // wartet an der Flagge + return; // wait at the flag } if e.path.is_empty() || e.repath <= 0.0 { e.repath = 1.5; match self.map.path_to(e.tile(), target) { Some(p) => e.path = p, None => { - self.hint(11, "Die Angriffsflagge ist für deine Kreaturen nicht erreichbar."); + self.hint(11, "hint.flag_unreachable"); e.job = Job::Wander; e.timer = 3.0; return; @@ -1388,7 +1395,7 @@ impl Game { let i = idx(x, y); if self.flag == Some(i) { self.flag = None; - self.msg("Angriffsflagge entfernt. Deine Kreaturen kehren zurück.".into()); + self.msg("msg.flag_removed", vec![]); self.sfx_ui(Sfx::Click); for e in self.ents.iter_mut() { if e.alive && e.creature() && e.job == Job::Rally { @@ -1399,13 +1406,13 @@ impl Game { return; } if !self.map.t[i].walkable() { - self.hint(12, "Die Flagge muss auf begehbarem Boden stehen."); + self.hint(12, "hint.flag_ground"); self.sfx_ui(Sfx::Error); return; } self.flag = Some(i); self.sfx(Sfx::FlagSet, x as f32 + 0.5, y as f32 + 0.5); - self.msg("Angriffsflagge gesetzt! Kampfbereite Kreaturen sammeln sich dort.".into()); + self.msg("msg.flag_set", vec![]); let mut ids = Vec::new(); for e in self.ents.iter() { if e.alive && !e.held && e.creature() && matches!(e.job, Job::None | Job::Wander | Job::Train(_) | Job::Study(_) | Job::Rally) { @@ -1426,7 +1433,7 @@ impl Game { let cur = e.tile(); let name = st(e.kind).name; if e.happy <= 0.0 { - self.msg(format!("Ein unzufriedener {} verlässt dein Reich!", name)); + self.msg("msg.creature_leaves", vec![format!("@{}", name)]); self.start_leave(e); return; } @@ -1461,7 +1468,7 @@ impl Game { } } e.happy -= 0.1; - self.hint(4, "Kreaturen müssen auf dem nackten Boden schlafen. Vergrößere dein Quartier!"); + self.hint(4, "hint.no_beds"); e.job = Job::Sleep(cur); return; } @@ -1475,7 +1482,7 @@ impl Game { return; } if e.hunger > 1.8 { - self.hint(5, "Deine Kreaturen hungern! Vergrößere den Pilzgarten."); + self.hint(5, "hint.hungry"); } } if self.wants_rally(e) { @@ -1485,7 +1492,7 @@ impl Game { return; } if e.hp < e.max_hp * 0.5 && e.energy >= 0.25 && self.counts[1] > 0 { - // verletzt: im Quartier ausruhen + // hurt: rest in the lair e.energy = e.energy.min(0.2); self.creature_choose(e); return; @@ -1514,7 +1521,7 @@ impl Game { self.wander(e, 4); } - // ---------------------------------------------------------------- Helden + // ---------------------------------------------------------------- heroes fn hero_ai(&mut self, e: &mut Ent, dt: f32) { if e.think <= 0.0 { @@ -1564,7 +1571,7 @@ impl Game { e.think = 1.5; } if e.guard { - // Torwächter bleiben am Tor + // gate defenders stay at the gate let (gx, gy) = self.gate_center(); let d2 = (e.x - gx).powi(2) + (e.y - gy).powi(2); if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 { @@ -1589,7 +1596,7 @@ impl Game { let fy = (e.y + cy as f32 + 1.5) * 0.5; self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff); self.sfx(Sfx::CoreHit, fx, fy); - self.hint(6, "Dein Seelenkern wird angegriffen!"); + self.hint(6, "hint.core_attacked"); } return; } @@ -1602,7 +1609,7 @@ impl Game { self.step_path(e, dt); } - // ---------------------------------------------------------------- Spieleraktionen + // ---------------------------------------------------------------- player actions pub fn mark_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, on: bool) { for y in y0.min(y1)..=y0.max(y1) { @@ -1648,11 +1655,11 @@ impl Game { } } if broke { - self.msg("Nicht genug Gold!".into()); + self.msg("msg.no_gold", vec![]); self.sfx_ui(Sfx::Error); } if built == 0 && !broke { - self.hint(7, "Räume können nur auf eroberten, leeren Bodenfeldern gebaut werden."); + self.hint(7, "hint.build_where"); } self.recount(); } @@ -1679,13 +1686,13 @@ impl Game { if refund > 0.0 { let got = self.add_gold(refund); self.sfx_ui(Sfx::Sell); - self.msg(format!("Räume verkauft: +{} Gold.", got as i32)); + self.msg("msg.sold", vec![(got as i32).to_string()]); } } pub fn pickup(&mut self, target: Option) -> bool { if self.hand.len() >= 8 { - self.hint(8, "Deine Hand ist voll (max. 8). Rechtsklick lässt Einheiten fallen."); + self.hint(8, "hint.hand_full"); return false; } if let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held && !self.ents[j].hero) { @@ -1711,7 +1718,7 @@ impl Game { } let t = self.map.t[idx(x, y)]; if !t.walkable() || t.owner != Owner::Player { - self.hint(9, "Einheiten können nur auf eigenem Gebiet abgesetzt werden."); + self.hint(9, "hint.drop_where"); return false; } self.hand.pop(); @@ -1731,7 +1738,7 @@ impl Game { pub fn lightning(&mut self, wx: f32, wy: f32) { const COST: f32 = 50.0; if self.mana < COST { - self.msg("Nicht genug Mana für den Blitz.".into()); + self.msg("msg.no_mana_bolt", vec![]); self.sfx_ui(Sfx::Error); return; } @@ -1756,7 +1763,7 @@ impl Game { pub fn heal(&mut self, wx: f32, wy: f32) { const COST: f32 = 60.0; if self.mana < COST { - self.msg("Nicht genug Mana für die Heilung.".into()); + self.msg("msg.no_mana_heal", vec![]); self.sfx_ui(Sfx::Error); return; } @@ -1779,11 +1786,11 @@ impl Game { let cost = self.summon_cost(); let (x, y) = (wx as i32, wy as i32); if !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != Owner::Player { - self.hint(9, "Grubenwichte können nur auf eigenem Gebiet beschworen werden."); + self.hint(13, "hint.summon_where"); return; } if self.mana < cost { - self.msg(format!("Nicht genug Mana ({} benötigt).", cost as i32)); + self.msg("msg.no_mana_cost", vec![(cost as i32).to_string()]); self.sfx_ui(Sfx::Error); return; } @@ -1794,37 +1801,38 @@ impl Game { } } +// Language key describing what a unit is doing pub fn job_name(e: &Ent) -> &'static str { match e.job { - Job::None => "Überlegt", - Job::Dig(_) => "Gräbt", - Job::Claim(_) => "Erobert Boden", - Job::Deliver => "Bringt Gold zur Schatzkammer", - Job::Wander => "Streift umher", + Job::None => "job.none", + Job::Dig(_) => "job.dig", + Job::Claim(_) => "job.claim", + Job::Deliver => "job.deliver", + Job::Wander => "job.wander", Job::Sleep(_) => { if e.moving { - "Geht schlafen" + "job.sleep_go" } else { - "Schläft" + "job.sleep" } } - Job::Eat(_) => "Isst", - Job::Train(_) => "Trainiert", - Job::Study(_) => "Studiert", - Job::Attack(_) => "Kämpft", + Job::Eat(_) => "job.eat", + Job::Train(_) => "job.train", + Job::Study(_) => "job.study", + Job::Attack(_) => "job.fight", Job::AttackCore => { if e.working { - "Greift den Seelenkern an" + "job.core_attack" } else { - "Marschiert zum Seelenkern" + "job.core_march" } } - Job::Leave => "Verlässt das Reich", + Job::Leave => "job.leave", Job::Rally => { if e.working { - "Greift das Heldentor an" + "job.gate_attack" } else { - "Folgt der Angriffsflagge" + "job.rally" } } } @@ -1834,14 +1842,14 @@ pub fn job_name(e: &Ent) -> &'static str { mod tests { use super::*; - // Simuliert ein Spiel mit einfacher "KI-Spielerin" und prüft, dass nichts abstürzt. + // Simulates games with a simple bot player and checks that nothing crashes. #[test] fn simulate_long_game() { for seed in [1u32, 7, 12345] { let mut g = Game::new(seed); let (cx, cy) = g.map.core; let (px, py) = g.map.portal; - // Weg zum Portal graben (durch Erde/Gold, um Fels herum) + // dig a way to the portal (through earth/gold, around rock) let start = idx(cx - 1, cy + 1); let goal = idx(px + 1, py + 1); let path = g @@ -1855,8 +1863,8 @@ mod tests { } g.mark_rect(cx - 6, cy - 6, cx + 9, cy + 9, true); let (ox, oy) = (cx - 3, cy - 3); - g.build_rect(1, ox, oy, ox + 2, oy + 1); // Quartier 3x2 - g.build_rect(2, ox + 5, oy, ox + 7, oy + 1); // Pilzgarten 3x2 + g.build_rect(1, ox, oy, ox + 2, oy + 1); // lair 3x2 + g.build_rect(2, ox + 5, oy, ox + 7, oy + 1); // farm 3x2 let dt = 0.05; let mut built = false; let mut portal_logged = false; @@ -1865,12 +1873,12 @@ mod tests { g.events.clear(); g.msgs.clear(); if step % 200 == 0 && !built && g.gold > 1900.0 { - g.build_rect(0, ox, oy + 6, ox + 1, oy + 8); // Schatzkammer - g.build_rect(3, ox + 6, oy + 6, ox + 8, oy + 8); // Training - g.build_rect(1, ox, oy + 2, ox + 2, oy + 2); // Quartier erweitern + g.build_rect(0, ox, oy + 6, ox + 1, oy + 8); // treasury + g.build_rect(3, ox + 6, oy + 6, ox + 8, oy + 8); // training hall + g.build_rect(1, ox, oy + 2, ox + 2, oy + 2); // enlarge the lair built = true; } - // Bot nutzt den Blitz gegen Helden am Kern + // the bot uses lightning against heroes at the core if step % 20 == 0 && g.mana >= 50.0 { let target = g.ents.iter().find(|e| e.alive && e.hero && (e.x - cx as f32).abs() < 4.0 && (e.y - cy as f32).abs() < 4.0).map(|e| (e.x, e.y)); if let Some((x, y)) = target { @@ -1898,10 +1906,10 @@ mod tests { } if g.portal_owned() && step % 20 == 0 && !portal_logged { portal_logged = true; - println!("seed {} Portal erobert nach {:.0}s", seed, g.time); + println!("seed {} portal claimed after {:.0}s", seed, g.time); } if g.state != 0 { - println!("seed {} Ende nach {:.0}s, state={}", seed, g.time, g.state); + println!("seed {} game over after {:.0}s, state={}", seed, g.time, g.state); break; } } diff --git a/src/gfx.rs b/src/gfx.rs index ceda544..67028ef 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -1,4 +1,4 @@ -// Prozedural erzeugte Pixelart: Kachel-Atlas und Figuren-Sprites. +// Procedurally generated pixel art: tile atlas, unit sprites and the bitmap font. use crate::game::EK; use crate::map::Kind; @@ -8,7 +8,7 @@ pub const CELL: f32 = 16.0; const ATLAS_COLS: u32 = 16; const ATLAS_ROWS: u32 = 5; -// Atlas-Indizes (jeweils 4 Varianten) +// atlas indices (4 variants each) pub const T_ROCK: u32 = 0; pub const T_EARTH: u32 = 4; pub const T_GOLD: u32 = 8; @@ -23,7 +23,7 @@ pub const T_LIBRARY: u32 = 40; pub const T_COREFLOOR: u32 = 44; pub const T_PORTALFLOOR: u32 = 48; pub const T_GATEFLOOR: u32 = 52; -// Einzelzellen +// single cells pub const T_FACE_EARTH: u32 = 56; pub const T_FACE_ROCK: u32 = 57; pub const T_FACE_GOLD: u32 = 58; @@ -44,19 +44,19 @@ pub const I_SUMMON: u32 = 71; pub struct Gfx { pub atlas: Texture2D, pub sprites: Texture2D, - pub font: Texture2D, // DINOBYTE, CP437, 16x16 Zeichen à 6x8 px + pub font: Texture2D, // DINOBYTE, CP437, 16x16 glyphs of 6x8 px } -// ------------------------------------------------------------------ Bitmap-Schrift (CP437) +// ------------------------------------------------------------------ bitmap font (CP437) -pub const GW: f32 = 6.0; // Glyphbreite -pub const GH: f32 = 8.0; // Glyphhöhe +pub const GW: f32 = 6.0; // glyph width +pub const GH: f32 = 8.0; // glyph height const CP437_LOW: &str = " ☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼"; const CP437_HIGH: &str = "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u{a0}"; pub fn cp437(ch: char) -> u8 { - // In DINOBYTE sind ö (0x94) und ò (0x95) vertauscht + // DINOBYTE has ö (0x94) and ò (0x95) swapped match ch { 'ö' => return 0x95, 'ò' => return 0x94, @@ -88,7 +88,7 @@ pub fn cp437(ch: char) -> u8 { } } -// Rahmenzeichen: [oben-links, oben-rechts, unten-links, unten-rechts, waagrecht, senkrecht] +// frame glyphs: [top-left, top-right, bottom-left, bottom-right, horizontal, vertical] pub const BOX_SINGLE: [u8; 6] = [0xDA, 0xBF, 0xC0, 0xD9, 0xC4, 0xB3]; pub const BOX_DOUBLE: [u8; 6] = [0xC9, 0xBB, 0xC8, 0xBC, 0xCD, 0xBA]; @@ -126,7 +126,7 @@ impl Canvas { } } -// Pflasterstein-Muster mit Fugen +// flagstone pattern with grout fn stones(x: i32, y: i32, v: u32, base: (i32, i32, i32), grout: (i32, i32, i32), var: i32) -> Color { let row = y / 8; let off = if row % 2 == 1 { 4 } else { 0 }; @@ -136,7 +136,7 @@ fn stones(x: i32, y: i32, v: u32, base: (i32, i32, i32), grout: (i32, i32, i32), } let stone = hash((x + off) / 8, row, v + 3) % 5; let d = (rnd(x, y, v) * var as f32) as i32 - var / 2 + stone as i32 * 3 - 6; - // Lichtkante oben/links, Schatten unten/rechts + // highlight top/left, shadow bottom/right let edge = if y % 8 == 1 || sx % 8 == 1 { 10 } else if y % 8 == 7 || sx % 8 == 7 { @@ -163,7 +163,7 @@ fn earth_px(x: i32, y: i32, v: u32) -> Color { fn gen_atlas() -> Image { let mut cv = Canvas { img: Image::gen_image_color((ATLAS_COLS * 16) as u16, (ATLAS_ROWS * 16) as u16, Color::new(0.0, 0.0, 0.0, 0.0)) }; for v in 0..4u32 { - // Fels + // rock cv.cell(T_ROCK + v, |x, y| { let blk = rnd(x / 4 + (y / 4) % 2, y / 4, v + 21) * 20.0 - 10.0; let fine = rnd(x, y, v + 5) * 10.0 - 5.0; @@ -174,9 +174,9 @@ fn gen_atlas() -> Image { Some(shade((80, 80, 92), (blk + fine) as i32)) } }); - // Erde + // earth cv.cell(T_EARTH + v, |x, y| Some(earth_px(x, y, v))); - // Gold + // gold cv.cell(T_GOLD + v, |x, y| { let mut best = 99.0f32; for k in 0..6 { @@ -198,7 +198,7 @@ fn gen_atlas() -> Image { cv.cell(T_FLOOR + v, |x, y| Some(stones(x, y, v, (64, 56, 50), (34, 30, 28), 10))); cv.cell(T_CLAIM + v, |x, y| { let col = stones(x, y, v + 10, (78, 44, 58), (36, 20, 28), 10); - // kleines Hoheitszeichen in der Mitte jedes zweiten Steins + // small emblem in the middle of every other stone if (x % 8 == 4 && y % 8 == 4) && hash(x / 8, y / 8, v) % 2 == 0 { Some(c(150, 60, 80)) } else { @@ -268,7 +268,7 @@ fn gen_atlas() -> Image { } }); } - // Wandfronten (Pseudo-3D) + // wall faces cv.cell(T_FACE_EARTH, |x, y| { let streak = (hash(x, 0, 70) % 3) as i32 * 5; Some(shade((62, 42, 28), (rnd(x, y, 71) * 10.0) as i32 - streak - y / 2)) @@ -284,7 +284,7 @@ fn gen_atlas() -> Image { Some(shade((62, 42, 28), (rnd(x, y, 75) * 10.0) as i32 - y / 2)) } }); - // Grab-Markierung: diagonale Streifen + // dig marker: diagonal stripes cv.cell(T_MARK, |x, y| { if (x + y) % 6 < 2 { Some(Color::from_rgba(255, 220, 60, 150)) @@ -294,7 +294,7 @@ fn gen_atlas() -> Image { None } }); - // Bett + // bed cv.cell(D_BED, |x, y| { let dx = (x as f32 - 7.5) / 6.5; let dy = (y as f32 - 8.0) / 5.5; @@ -307,7 +307,7 @@ fn gen_atlas() -> Image { None } }); - // Pilze + // mushrooms cv.cell(D_MUSHROOM, |x, y| { let caps = [(4, 6, 3), (11, 5, 2), (8, 11, 3)]; for &(cx, cy, r) in &caps { @@ -322,7 +322,7 @@ fn gen_atlas() -> Image { } None }); - // Trainingspuppe + // training dummy cv.cell(D_DUMMY, |x, y| { if (x == 7 || x == 8) && y >= 4 { Some(c(90, 60, 36)) @@ -336,7 +336,7 @@ fn gen_atlas() -> Image { None } }); - // Goldhaufen + // gold pile cv.cell(D_COINS, |x, y| { let dx = (x as f32 - 7.5) / 7.0; let dy = (y as f32 - 11.0) / 5.0; @@ -348,7 +348,7 @@ fn gen_atlas() -> Image { None } }); - // Bücherregal + // bookshelf cv.cell(D_SHELF, |x, y| { if y > 9 || x == 0 || x == 15 { return if y <= 10 { Some(c(60, 36, 22)) } else { None }; @@ -365,7 +365,7 @@ fn gen_atlas() -> Image { _ => c(100, 60, 120), }) }); - // Werkzeug-Icons + // tool icons cv.cell(I_PICK, |x, y| { let handle = (x + y == 16 || x + y == 17) && x >= 3; let head = (y == 3 || y == 4) && (2..=13).contains(&x) && (x - 8).abs() + (y - 2) * 2 < 9; @@ -599,7 +599,7 @@ pub fn sprite_index(k: EK) -> u32 { SPRITE_KINDS.iter().position(|&s| s == k).unwrap_or(0) as u32 } -// Zeile 0: normale Sprites, Zeile 1: weiße Silhouetten (Treffer-Blitz) +// row 0: normal sprites, row 1: white silhouettes (hit flash) fn gen_sprites() -> Image { let n = SPRITE_KINDS.len() as u32; let mut img = Image::gen_image_color((n * SPR_CELL) as u16, (2 * SPR_CELL) as u16, Color::new(0.0, 0.0, 0.0, 0.0)); @@ -625,7 +625,7 @@ impl Gfx { atlas.set_filter(FilterMode::Nearest); let sprites = Texture2D::from_image(&gen_sprites()); sprites.set_filter(FilterMode::Nearest); - // Schrift laden; Hintergrund transparent machen, Glyphen weiß (zum Einfärben) + // load the font; transparent background, white glyphs (for tinting) let mut img = Image::from_file_with_format(include_bytes!("../assets/dbyte_1x.png"), Some(ImageFormat::Png)).expect("Schrift"); for px in img.get_image_data_mut().iter_mut() { let on = px[3] > 128 && px[0] > 128; @@ -681,7 +681,7 @@ impl Gfx { ); } - // Einzelnes CP437-Zeichen zeichnen (x, y = linke obere Ecke) + // draw a single CP437 glyph (x, y = top-left corner) pub fn glyph(&self, code: u8, x: f32, y: f32, scale: f32, color: Color) { let src = Rect::new((code % 16) as f32 * GW, (code / 16) as f32 * GH, GW, GH); draw_texture_ex( @@ -693,7 +693,7 @@ impl Gfx { ); } - // Text zeichnen (x, y = linke obere Ecke); scale sollte ganzzahlig sein + // draw text (x, y = top-left corner); scale should be an integer pub fn text(&self, s: &str, x: f32, y: f32, scale: f32, color: Color) { let mut cx = x; for ch in s.chars() { @@ -708,13 +708,13 @@ impl Gfx { s.chars().count() as f32 * GW * scale } - // Text mit dunklem Schatten + // text with a dark shadow pub fn text_sh(&self, s: &str, x: f32, y: f32, scale: f32, color: Color) { self.text(s, x + scale, y + scale, scale, Color::new(0.0, 0.0, 0.0, color.a * 0.85)); self.text(s, x, y, scale, color); } - // Fenster mit CP437-Rahmen; Größe in Zeichen (cols x rows inkl. Rahmen) + // window with a CP437 frame; size in characters (cols x rows incl. frame) pub fn window(&self, x: f32, y: f32, cols: i32, rows: i32, scale: f32, double: bool, frame: Color, bg: Color) { let cw = GW * scale; let ch = GH * scale; @@ -740,7 +740,7 @@ impl Gfx { } } - // Fenster mit Titel in der oberen Rahmenlinie + // window with a title in the top frame line pub fn window_titled(&self, x: f32, y: f32, cols: i32, rows: i32, scale: f32, double: bool, frame: Color, bg: Color, title: &str, tcol: Color) { self.window(x, y, cols, rows, scale, double, frame, bg); if !title.is_empty() { diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..d356e04 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,87 @@ +// Internationalisation: in-game texts live in lang/*.lang files (key = text). +// English is the reference language and the fallback for missing keys. +// To add a language, create lang/.lang and add it to SOURCES. + +use std::cell::RefCell; +use std::collections::HashMap; + +const SOURCES: &[(&str, &str)] = &[("en", include_str!("../lang/en.lang")), ("de", include_str!("../lang/de.lang"))]; + +struct I18n { + langs: Vec<(String, HashMap)>, + current: usize, +} + +fn parse(src: &str) -> HashMap { + let mut m = HashMap::new(); + for line in src.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((k, v)) = line.split_once('=') { + m.insert(k.trim().to_string(), v.trim().to_string()); + } + } + m +} + +thread_local! { + static I18N: RefCell = RefCell::new(I18n { + langs: SOURCES.iter().map(|(code, src)| (code.to_string(), parse(src))).collect(), + current: 0, + }); +} + +// Available languages: (code, display name in that language) +pub fn languages() -> Vec<(String, String)> { + I18N.with(|i| { + i.borrow().langs.iter().map(|(c, m)| (c.clone(), m.get("lang.name").cloned().unwrap_or_else(|| c.clone()))).collect() + }) +} + +pub fn set_language(code: &str) -> bool { + I18N.with(|i| { + let mut i = i.borrow_mut(); + if let Some(k) = i.langs.iter().position(|(c, _)| c == code) { + i.current = k; + true + } else { + false + } + }) +} + +#[allow(dead_code)] // used by the settings menu +pub fn language() -> String { + I18N.with(|i| { + let i = i.borrow(); + i.langs[i.current].0.clone() + }) +} + +// Translate a key (fallback: English, then the key itself) +pub fn tr(key: &str) -> String { + I18N.with(|i| { + let i = i.borrow(); + i.langs[i.current].1.get(key).or_else(|| i.langs[0].1.get(key)).cloned().unwrap_or_else(|| key.to_string()) + }) +} + +// Translate with arguments. Arguments starting with '@' are keys and get translated too. +pub fn trf(key: &str, args: &[String]) -> String { + let mut s = tr(key); + for (n, a) in args.iter().enumerate() { + let v = if let Some(k) = a.strip_prefix('@') { tr(k) } else { a.clone() }; + s = s.replace(&format!("{{{}}}", n), &v); + } + s +} + +// Translate a key in a specific language (used by the language selection dialog) +pub fn tr_in(code: &str, key: &str) -> String { + I18N.with(|i| { + let i = i.borrow(); + i.langs.iter().find(|(c, _)| c == code).and_then(|(_, m)| m.get(key)).cloned().unwrap_or_else(|| key.to_string()) + }) +} diff --git a/src/main.rs b/src/main.rs index 22db6c5..cdb8a0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,9 @@ mod game; mod gfx; +mod i18n; mod map; +mod platform; mod render3d; mod sound; mod ui; @@ -59,6 +61,11 @@ async fn main() { let mut seed = (miniquad::date::now() * 1000.0) as u32 | 1; let (mut game, mut cam) = new_game(seed); let mut ui = Ui::new(); + // language: stored choice, otherwise ask on first start + match platform::storage_get("dc.lang") { + Some(code) if i18n::set_language(&code) => {} + _ => ui.show_langsel = true, + } let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false }; loop { @@ -234,6 +241,12 @@ async fn main() { (game, cam) = new_game(seed); ui.show_help = false; } + UiAction::SelectLanguage(code) => { + i18n::set_language(&code); + platform::storage_set("dc.lang", &code); + ui.show_langsel = false; + game.sfx_ui(game::Sfx::Click); + } UiAction::Consumed => {} UiAction::None => { if game.state == 0 { diff --git a/src/map.rs b/src/map.rs index 73c757d..5234beb 100644 --- a/src/map.rs +++ b/src/map.rs @@ -1,4 +1,4 @@ -// Karte, Kacheln, Generierung und Wegfindung. +// Map, tiles, generation and path finding. use std::cmp::Reverse; use std::collections::BinaryHeap; @@ -34,13 +34,13 @@ pub enum Owner { pub struct Tile { pub kind: Kind, pub owner: Owner, - pub hp: f32, // Grab-Widerstand - pub gold: i32, // Goldvorrat (Goldader) - pub marked: bool, // zum Graben markiert - pub reserved: u32, // Entity-ID + 1, die diese Kachel bearbeitet - pub food: f32, // Pilze im Pilzgarten - pub bed: u32, // Entity-ID + 1, die hier schläft - pub var: u8, // Texturvariante + pub hp: f32, // dig resistance + pub gold: i32, // gold left (gold vein) + pub marked: bool, // marked for digging + pub reserved: u32, // entity id + 1 working on this tile + pub food: f32, // mushrooms in the mushroom garden + pub bed: u32, // entity id + 1 sleeping here + pub var: u8, // texture variant } impl Tile { @@ -97,7 +97,7 @@ impl Rng { pub struct Map { pub t: Vec, - pub core: (i32, i32), // linke obere Ecke des Kerns (3x3) + pub core: (i32, i32), // top-left corner of the core (3x3) pub portal: (i32, i32), pub gate: (i32, i32), } @@ -119,7 +119,7 @@ impl Map { let mut s = seed; loop { let m = Map::try_generate(s); - // Sicherstellen, dass Helden den Kern erreichen können + // make sure heroes can reach the core if m.hero_path_to_core(idx(m.gate.0 + 1, m.gate.1 + 1)).is_some() { return m; } @@ -163,21 +163,21 @@ impl Map { } }; - // Felsbrocken + // rock clusters for _ in 0..22 { let cx = rng.range(4, MW as i32 - 5); let cy = rng.range(4, MH as i32 - 5); let r = rng.range(1, 3); blob(&mut t, &mut rng, cx, cy, r, Kind::Rock); } - // Goldadern + // gold veins for _ in 0..16 { let cx = rng.range(4, MW as i32 - 5); let cy = rng.range(4, MH as i32 - 5); let r = rng.range(1, 2); blob(&mut t, &mut rng, cx, cy, r, Kind::Gold); } - // Garantiertes Gold in Startnähe + // guaranteed gold near the start for k in 0..3 { let cx = core.0 + rng.range(-3, 9); let cy = core.1 + if k % 2 == 0 { rng.range(-9, -6) } else { rng.range(7, 9) }; @@ -197,7 +197,7 @@ impl Map { } }; - // Kleine natürliche Höhlen + // small natural caves for _ in 0..4 { let cx = rng.range(20, 48); let cy = rng.range(6, 58); @@ -206,9 +206,9 @@ impl Map { room(&mut t, cx - w, cy - h, cx + w, cy + h, Owner::None); } - // Startbereich + // starting area room(&mut t, core.0 - 3, core.1 - 3, core.0 + 5, core.1 + 5, Owner::Player); - // Kern 3x3 + // core 3x3 for y in 0..3 { for x in 0..3 { let tl = &mut t[idx(core.0 + x, core.1 + y)]; @@ -216,14 +216,14 @@ impl Map { tl.owner = Owner::Player; } } - // Portalhöhle + // portal cave room(&mut t, portal.0 - 2, portal.1 - 2, portal.0 + 4, portal.1 + 4, Owner::None); for y in 0..3 { for x in 0..3 { t[idx(portal.0 + x, portal.1 + y)].kind = Kind::Portal; } } - // Heldentor + // hero gate room(&mut t, gate.0 - 2, gate.1 - 2, gate.0 + 4, gate.1 + 4, Owner::Hero); for y in 0..3 { for x in 0..3 { @@ -240,9 +240,9 @@ impl Map { Map { t, core, portal, gate } } - // Allgemeine Dijkstra-Suche. - // cost(i) -> None = unpassierbar, Some(c) = Kosten zum Betreten. - // goal(i) -> true = Ziel erreicht. + // Generic Dijkstra search. + // cost(i) -> None = impassable, Some(c) = cost of entering. + // goal(i) -> true = goal reached. pub fn search(&self, start: usize, cost: C, goal: G, diag: bool) -> Option> where C: Fn(usize) -> Option, @@ -279,7 +279,7 @@ impl Map { let Some(c) = cost(ni) else { continue }; let mut step = c * 10; if k >= 4 { - // Diagonal nur, wenn beide Nachbarn begehbar sind (keine Ecken schneiden) + // diagonal only if both neighbours are walkable (no corner cutting) if c > 1 || !self.walk(x + dx, y) || !self.walk(x, y + dy) { continue; } diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..6874fe7 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,50 @@ +// Browser integration via our own miniquad JS plugin (www/dc_plugin.js): +// persistent key/value storage (localStorage). Native builds use an in-memory fallback. + +#[cfg(target_arch = "wasm32")] +mod imp { + extern "C" { + fn dc_storage_get(key: *const u8, key_len: u32) -> i32; + fn dc_storage_take(dst: *mut u8); + fn dc_storage_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32); + } + + pub fn storage_get(key: &str) -> Option { + unsafe { + let n = dc_storage_get(key.as_ptr(), key.len() as u32); + if n < 0 { + return None; + } + let mut buf = vec![0u8; n as usize]; + dc_storage_take(buf.as_mut_ptr()); + String::from_utf8(buf).ok() + } + } + + pub fn storage_set(key: &str, val: &str) { + unsafe { dc_storage_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) } + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use std::cell::RefCell; + use std::collections::HashMap; + thread_local! { + static STORE: RefCell> = RefCell::new(HashMap::new()); + } + pub fn storage_get(key: &str) -> Option { + STORE.with(|s| s.borrow().get(key).cloned()) + } + pub fn storage_set(key: &str, val: &str) { + STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string())); + } +} + +pub use imp::*; + +// Lets the JS plugin loader know this crate uses the "dungeoncraft" plugin +#[no_mangle] +pub extern "C" fn dungeoncraft_crate_version() -> u32 { + 1 +} diff --git a/src/sound.rs b/src/sound.rs index b6c4bff..15bad9b 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -1,6 +1,6 @@ -// Sound-Wiedergabe. Die Spiellogik meldet Ereignisse (Sfx), hier werden sie abgespielt. -// Klänge werden später generiert und per `SoundBank::set` (WAV-Bytes) registriert. -// Ohne registrierten Klang bleibt ein Ereignis stumm. +// Sound playback. The game logic reports events (Sfx), they are played here. +// Sounds will be generated later and registered via `SoundBank::set` (WAV bytes). +// Events without a registered sound stay silent. use crate::game::{Sfx, SfxEvent, SFX_ALL}; use macroquad::audio::{load_sound_from_bytes, play_sound, PlaySoundParams, Sound}; @@ -18,7 +18,7 @@ impl SoundBank { SoundBank { sounds: vec![None; SFX_ALL.len()], last: vec![-1.0; SFX_ALL.len()], volume: 0.7, muted: false } } - // Klang für ein Ereignis registrieren (WAV- oder OGG-Bytes) + // register the sound for an event (WAV or OGG bytes) #[allow(dead_code)] pub async fn set(&mut self, sfx: Sfx, bytes: &[u8]) -> bool { match load_sound_from_bytes(bytes).await { @@ -30,14 +30,14 @@ impl SoundBank { } } - // Hier werden später die prozedural erzeugten Klänge registriert. + // procedurally generated sounds will be registered here pub async fn generate_all(&mut self) {} pub fn change_volume(&mut self, d: f32) { self.volume = (self.volume + d).clamp(0.0, 1.0); } - // Mindestabstand zwischen zwei gleichen Klängen (gegen Klangbrei) + // minimum gap between two identical sounds (avoids a wall of noise) fn min_gap(sfx: Sfx) -> f64 { match sfx { Sfx::Dig | Sfx::GoldDig | Sfx::MeleeHit | Sfx::Shoot | Sfx::GateHit | Sfx::CoreHit => 0.08, @@ -56,7 +56,7 @@ impl SoundBank { if now - self.last[i] < Self::min_gap(ev.sfx) { return; } - // Lautstärke nach Entfernung zur Bildschirmmitte + // volume by distance to the listener let mut vol = self.volume; if let Some((wx, wy)) = ev.pos { let d = ((wx - listener.x).powi(2) + (wy - listener.y).powi(2)).sqrt(); @@ -71,7 +71,7 @@ impl SoundBank { } } -// Hilfsfunktion für die spätere Generierung: Samples (-1..1, mono) -> WAV (16 bit PCM) +// helper for sound generation: samples (-1..1, mono) -> WAV (16 bit PCM) #[allow(dead_code)] pub fn wav_from_samples(samples: &[f32], rate: u32) -> Vec { let data_len = (samples.len() * 2) as u32; diff --git a/src/ui.rs b/src/ui.rs index 2abd7af..78485bf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,15 +1,17 @@ -// Benutzeroberfläche im Canvas: CP437-Fenster mit DINOBYTE-Schrift. +// In-canvas user interface: CP437 windows with the DINOBYTE bitmap font. +// All texts come from the language files (see i18n.rs). use crate::game::*; use crate::gfx::*; +use crate::i18n::{languages, tr, trf}; use crate::map::*; use crate::render3d::{minimap_color, Cam}; use crate::sound::SoundBank; use macroquad::prelude::*; -pub const S: f32 = 2.0; // UI-Skalierung der Schrift -pub const CW: f32 = GW * S; // Zeichenbreite in px -pub const CH: f32 = GH * S; // Zeichenhöhe in px +pub const S: f32 = 2.0; // UI font scale +pub const CW: f32 = GW * S; // character width in px +pub const CH: f32 = GH * S; // character height in px pub const FRAME: Color = Color::new(0.62, 0.52, 0.36, 1.0); pub const FRAME_HI: Color = Color::new(0.95, 0.8, 0.4, 1.0); @@ -52,37 +54,40 @@ pub const TOOLS: [Tool; 12] = [ pub const TOOL_KEYS: [&str; 12] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "X", "V"]; pub struct ToolInfo { - pub label: &'static str, - pub name: &'static str, - pub desc: &'static str, + pub label: String, + pub name: String, + pub desc: String, pub icon: u32, } pub fn tool_info(t: Tool) -> ToolInfo { - let i = |label, name, desc, icon| ToolInfo { label, name, desc, icon }; - match t { - Tool::Dig => i("Graben", "Graben", "Erde und Gold markieren. Ziehen für Rechteck; auf markiertem Feld beginnen hebt Markierung auf.", I_PICK), - Tool::Hand => i("Hand", "Hand", "Eigene Einheit aufnehmen (max. 8). Rechtsklick setzt sie auf eigenem Gebiet ab.", I_HAND), - Tool::Room(0) => i("Schatz", "Schatzkammer", "Lagert Gold: +500 Kapazität pro Feld.", T_TREASURY), - Tool::Room(1) => i("Quartier", "Quartier", "Ein Schlafplatz pro Feld. Bestimmt, wie viele Kreaturen kommen.", T_LAIR), - Tool::Room(2) => i("Pilze", "Pilzgarten", "Nahrung für Kreaturen. Ab 6 Feldern kommen Höhlenspinnen.", T_FARM), - Tool::Room(3) => i("Training", "Trainingshalle", "Kreaturen steigen Stufen auf (kostet Gold). Ab 9 Feldern kommen Steinoger.", T_TRAINING), - Tool::Room(4) => i("Bücher", "Bibliothek", "Kreaturen erzeugen Mana. Ab 6 Feldern kommen Schattenmagier.", T_LIBRARY), - Tool::Sell => i("Verkauf", "Verkaufen", "Räume zurück in Boden verwandeln (50 % Erstattung).", I_SELL), - Tool::Flag => i("Flagge", "Angriffsflagge", "Kampfbereite Kreaturen sammeln sich hier. Nahe am Heldentor greifen sie es an. Erneut klicken entfernt sie.", I_FLAG), - Tool::Lightning => i("Blitz", "Blitz", "55 Schaden an Helden im Umkreis.", I_BOLT), - Tool::Heal => i("Heilung", "Heilung", "Heilt eigene Einheiten im Umkreis um 50 %.", I_HEAL), - Tool::Summon => i("Wicht", "Grubenwicht beschwören", "Erschafft einen neuen Grubenwicht auf eigenem Gebiet.", I_SUMMON), - Tool::Room(_) => i("?", "?", "", 0), - } + let (id, icon, room) = match t { + Tool::Dig => ("dig", I_PICK, None), + Tool::Hand => ("hand", I_HAND, None), + Tool::Room(0) => ("treasury", T_TREASURY, Some(0)), + Tool::Room(1) => ("lair", T_LAIR, Some(1)), + Tool::Room(2) => ("farm", T_FARM, Some(2)), + Tool::Room(3) => ("training", T_TRAINING, Some(3)), + Tool::Room(_) => ("library", T_LIBRARY, Some(4)), + Tool::Sell => ("sell", I_SELL, None), + Tool::Flag => ("flag", I_FLAG, None), + Tool::Lightning => ("lightning", I_BOLT, None), + Tool::Heal => ("heal", I_HEAL, None), + Tool::Summon => ("summon", I_SUMMON, None), + }; + let name = match room { + Some(r) => tr(ROOM_NAMES[r]), + None => tr(&format!("tool.{}.name", id)), + }; + ToolInfo { label: tr(&format!("tool.{}.label", id)), name, desc: tr(&format!("tool.{}.desc", id)), icon } } pub fn tool_cost(t: Tool, g: &Game) -> String { match t { - Tool::Room(r) => format!("{} Gold/Feld", ROOM_COST[r]), - Tool::Lightning => "50 Mana".into(), - Tool::Heal => "60 Mana".into(), - Tool::Summon => format!("{} Mana", g.summon_cost() as i32), + Tool::Room(r) => trf("cost.gold_tile", &[ROOM_COST[r].to_string()]), + Tool::Lightning => trf("cost.mana", &["50".into()]), + Tool::Heal => trf("cost.mana", &["60".into()]), + Tool::Summon => trf("cost.mana", &[(g.summon_cost() as i32).to_string()]), _ => String::new(), } } @@ -93,13 +98,15 @@ pub enum UiAction { MinimapJump(f32, f32), CloseHelp, NewGame, + SelectLanguage(String), Consumed, } pub struct Ui { pub tool: Tool, pub show_help: bool, - pub log: Vec<(String, f64)>, + pub show_langsel: bool, + pub log: Vec<(Msg, f64)>, pub rects: Vec, minimap_img: Image, minimap_tex: Texture2D, @@ -107,6 +114,7 @@ pub struct Ui { minimap_rect: Rect, tool_rects: Vec, end_button: Rect, + lang_rects: Vec<(Rect, String)>, } fn wrap(s: &str, cols: usize) -> Vec { @@ -148,6 +156,7 @@ impl Ui { Ui { tool: Tool::Dig, show_help: true, + show_langsel: false, log: Vec::new(), rects: Vec::new(), minimap_img: img, @@ -156,11 +165,12 @@ impl Ui { minimap_rect: Rect::new(0.0, 0.0, 0.0, 0.0), tool_rects: Vec::new(), end_button: Rect::new(0.0, 0.0, 0.0, 0.0), + lang_rects: Vec::new(), } } pub fn over_ui(&self, x: f32, y: f32) -> bool { - self.show_help || self.rects.iter().any(|r| r.contains(vec2(x, y))) + self.show_help || self.show_langsel || self.rects.iter().any(|r| r.contains(vec2(x, y))) } pub fn minimap_contains(&self, x: f32, y: f32) -> bool { @@ -174,6 +184,14 @@ impl Ui { pub fn click(&mut self, x: f32, y: f32, g: &Game) -> UiAction { let p = vec2(x, y); + if self.show_langsel { + for (r, code) in &self.lang_rects { + if r.contains(p) { + return UiAction::SelectLanguage(code.clone()); + } + } + return UiAction::Consumed; + } if g.state != 0 { if self.end_button.contains(p) { return UiAction::NewGame; @@ -204,7 +222,7 @@ impl Ui { let sh = screen_height(); let (mx, my) = mouse_position(); - // ---------------- obere Leiste + // ---------------- top bar let cols = (sw / CW).ceil() as i32; gfx.window(0.0, 0.0, cols, 3, S, false, FRAME, BG); self.rects.push(Rect::new(0.0, 0.0, sw, 3.0 * CH)); @@ -216,31 +234,34 @@ impl Ui { gfx.text(value, *x, y, S, vc); *x += gfx.measure(value, S) + CW * 2.0; }; - seg("Gold", GOLD, &format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT, &mut x); - gfx.text("Mana", x, y, S, MANA); - x += gfx.measure("Mana", S) + CW * 0.5; + seg(&tr("hud.gold"), GOLD, &format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT, &mut x); + let mana = tr("hud.mana"); + gfx.text(&mana, x, y, S, MANA); + x += gfx.measure(&mana, S) + CW * 0.5; bar(x, y + 3.0, 70.0, 10.0, g.mana / MANA_MAX, MANA); x += 70.0 + CW * 0.5; gfx.text(&format!("{}", g.mana as i32), x, y, S, TXT); x += CW * 5.0; - gfx.text("Kern", x, y, S, RED); - x += gfx.measure("Kern", S) + CW * 0.5; + let core = tr("hud.core"); + gfx.text(&core, x, y, S, RED); + x += gfx.measure(&core, S) + CW * 0.5; bar(x, y + 3.0, 70.0, 10.0, g.core_hp / CORE_HP, RED); x += 70.0 + CW * 1.5; - gfx.text("Tor", x, y, S, BLUE); - x += gfx.measure("Tor", S) + CW * 0.5; + let gate = tr("hud.gate"); + gfx.text(&gate, x, y, S, BLUE); + x += gfx.measure(&gate, S) + CW * 0.5; bar(x, y + 3.0, 70.0, 10.0, g.gate_hp / GATE_HP, BLUE); x += 70.0 + CW * 1.5; let cap = g.counts[1].min(30); - seg("Kreaturen", TXT, &format!("{}/{}", g.creature_count(), cap), TXT, &mut x); - seg("Wichte", TXT, &format!("{}", g.worker_count()), TXT, &mut x); + seg(&tr("hud.creatures"), TXT, &format!("{}/{}", g.creature_count(), cap), TXT, &mut x); + seg(&tr("hud.workers"), TXT, &format!("{}", g.worker_count()), TXT, &mut x); let wave_col = if g.wave_timer < 30.0 { RED } else { TXT }; - seg(&format!("Welle {}", g.wave + 1), wave_col, &mmss(g.wave_timer), wave_col, &mut x); - seg("Armee", TXT, &format!("{}", g.army_value() as i32), TXT, &mut x); - let vol = if snd.muted { "aus".to_string() } else { format!("{}%", (snd.volume * 100.0).round() as i32) }; + seg(&trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col, &mmss(g.wave_timer), wave_col, &mut x); + seg(&tr("hud.army"), TXT, &format!("{}", g.army_value() as i32), TXT, &mut x); + let vol = if snd.muted { tr("hud.off") } else { format!("{}%", (snd.volume * 100.0).round() as i32) }; seg("♪", DIM, &vol, DIM, &mut x); - // ---------------- Werkzeugleiste + // ---------------- toolbar let tb_rows = 5; let tb_y = sh - tb_rows as f32 * CH; gfx.window(0.0, tb_y, cols, tb_rows, S, false, FRAME, BG); @@ -263,13 +284,13 @@ impl Ui { let ti = tool_info(t); gfx.draw_cell(ti.icon, bx + (bw - 32.0) * 0.5, by + 4.0, 32.0, 32.0, WHITE); gfx.text(TOOL_KEYS[i], bx + 3.0, by + 3.0, 1.0, if sel { FRAME_HI } else { DIM }); - let lw = gfx.measure(ti.label, 1.0); - gfx.text(ti.label, bx + (bw - lw) * 0.5, by + 42.0, 1.0, if sel { FRAME_HI } else { TXT }); + let lw = gfx.measure(&ti.label, 1.0); + gfx.text(&ti.label, bx + (bw - lw) * 0.5, by + 42.0, 1.0, if sel { FRAME_HI } else { TXT }); } - // Hand + // hand contents let hx = CW + TOOLS.len() as f32 * (bw + 4.0) + CW; if hx + 120.0 < sw - 200.0 { - gfx.text("Hand", hx, tb_y + CH, S, TXT); + gfx.text(&tr("hud.hand"), hx, tb_y + CH, S, TXT); for (k, id) in g.hand.iter().enumerate() { if let Some(e) = g.ents.iter().find(|e| e.id == *id) { let sx = hx + (k % 4) as f32 * 26.0; @@ -284,18 +305,18 @@ impl Ui { } } if g.hand.is_empty() { - gfx.text("leer", hx, tb_y + CH * 2.5, 1.0, DIM); + gfx.text(&tr("hud.empty"), hx, tb_y + CH * 2.5, 1.0, DIM); } } - // ---------------- Minikarte + // ---------------- minimap let mm_cols = 16; let mm_rows = 12; let mm_w = mm_cols as f32 * CW; let mm_h = mm_rows as f32 * CH; let mm_x = sw - mm_w; let mm_y = tb_y - mm_h; - gfx.window_titled(mm_x, mm_y, mm_cols, mm_rows, S, false, FRAME, BG, "Karte", FRAME_HI); + gfx.window_titled(mm_x, mm_y, mm_cols, mm_rows, S, false, FRAME, BG, &tr("ui.map"), FRAME_HI); self.rects.push(Rect::new(mm_x, mm_y, mm_w, mm_h)); if now - self.minimap_t > 0.25 { self.minimap_t = now; @@ -335,7 +356,7 @@ impl Ui { draw_line(pa.x, pa.y, pb.x, pb.y, 1.5, WHITE); } - // ---------------- Infofeld + // ---------------- info panel if !info.is_empty() { let icols = 30; let mut lines: Vec<(String, Color)> = Vec::new(); @@ -353,14 +374,14 @@ impl Ui { } } - // ---------------- Meldungen - let recent: Vec<&(String, f64)> = self.log.iter().filter(|(_, t)| now - t < 14.0).collect(); + // ---------------- messages (translated at display time, so a language switch applies at once) + let recent: Vec<&(Msg, f64)> = self.log.iter().filter(|(_, t)| now - t < 14.0).collect(); if !recent.is_empty() { let mcols = (((sw - mm_w) / CW) as i32 - 2).clamp(20, 70); let mut lines: Vec<(String, f32)> = Vec::new(); - for (s, t) in recent.iter().rev().take(5).rev() { + for (m, t) in recent.iter().rev().take(5).rev() { let a = (1.0 - ((now - t) as f32 - 11.0) / 3.0).clamp(0.2, 1.0); - for l in wrap(s, mcols as usize - 4) { + for l in wrap(&trf(m.key, &m.args), mcols as usize - 4) { lines.push((l, a)); } } @@ -381,23 +402,25 @@ impl Ui { let t = TOOLS[i]; let ti = tool_info(t); let tcols = 34; - let mut lines: Vec<(String, Color)> = wrap(ti.desc, tcols - 4).into_iter().map(|l| (l, TXT)).collect(); + let mut lines: Vec<(String, Color)> = wrap(&ti.desc, tcols - 4).into_iter().map(|l| (l, TXT)).collect(); let cost = tool_cost(t, g); if !cost.is_empty() { - lines.push((format!("Kosten: {}", cost), GOLD)); + lines.push((trf("ui.cost", &[cost]), GOLD)); } - lines.push((format!("Taste: {}", TOOL_KEYS[i]), DIM)); + lines.push((trf("ui.key", &[TOOL_KEYS[i].to_string()]), DIM)); let rows = lines.len() as i32 + 2; let tx = (mx - tcols as f32 * CW * 0.5).clamp(0.0, sw - tcols as f32 * CW); let ty = tb_y - rows as f32 * CH - 4.0; - gfx.window_titled(tx, ty, tcols as i32, rows, S, false, FRAME_HI, BG, ti.name, FRAME_HI); + gfx.window_titled(tx, ty, tcols as i32, rows, S, false, FRAME_HI, BG, &ti.name, FRAME_HI); for (k, (l, c)) in lines.iter().enumerate() { gfx.text(l, tx + CW * 2.0, ty + CH * (k as f32 + 1.0), S, *c); } } - // ---------------- Dialoge - if g.state != 0 { + // ---------------- dialogs + if self.show_langsel { + self.draw_langsel(gfx); + } else if g.state != 0 { self.draw_end(g, gfx); } else if self.show_help { self.draw_help(gfx); @@ -406,36 +429,37 @@ impl Ui { let x = (sw - w as f32 * CW) * 0.5; let y = sh * 0.35; gfx.window(x, y, w, 5, S, true, FRAME_HI, BG); - let t = "PAUSE"; - gfx.text(t, (sw - gfx.measure(t, 3.0)) * 0.5, y + CH, 3.0, FRAME_HI); - let t2 = "P: weiter"; - gfx.text(t2, (sw - gfx.measure(t2, S)) * 0.5, y + CH * 3.0, S, DIM); + let t = tr("ui.pause"); + gfx.text(&t, (sw - gfx.measure(&t, 3.0)) * 0.5, y + CH, 3.0, FRAME_HI); + let t2 = tr("ui.pause_hint"); + gfx.text(&t2, (sw - gfx.measure(&t2, S)) * 0.5, y + CH * 3.0, S, DIM); } } fn draw_help(&mut self, gfx: &Gfx) { - let lines: &[(&str, Color)] = &[ - ("Ziel: Zerstöre das Heldentor und schütze deinen Seelenkern.", GOLD), + let keys: &[(&str, Color)] = &[ + ("help.goal", GOLD), ("", TXT), - ("Linksklick Werkzeug benutzen (Graben/Räume: Rechteck ziehen)", TXT), - ("Rechtsklick Einheit aus der Hand absetzen", TXT), - ("Rechts ziehen Karte verschieben (auch WASD/Pfeile, Bildrand)", TXT), - ("Mitte ziehen Kamera drehen und neigen (auch Q/E, R/F)", TXT), - ("Mausrad Zoomen", TXT), - ("1-9, 0, X, V Werkzeug wählen", TXT), - ("P / Leertaste Pause M Ton an/aus +/- Lautstärke", TXT), - ("G Kachel-Umrisse an/aus H Diese Hilfe", TXT), + ("help.k_left", TXT), + ("help.k_right", TXT), + ("help.k_pan", TXT), + ("help.k_rot", TXT), + ("help.k_zoom", TXT), + ("help.k_tools", TXT), + ("help.k_misc", TXT), + ("help.k_misc2", TXT), ("", TXT), - ("1. Grabe zum Portal und lass es von Grubenwichten erobern.", TXT), - ("2. Baue Quartier, Pilzgarten und Schatzkammer.", TXT), - ("3. Trainiere deine Kreaturen, zahle ihren Lohn pünktlich.", TXT), - ("4. Wehre die Heldenwellen ab. Sie passen sich deiner Armee an.", TXT), - ("5. Setze die Angriffsflagge nahe ans Tor und greife an!", TXT), + ("help.s1", TXT), + ("help.s2", TXT), + ("help.s3", TXT), + ("help.s4", TXT), + ("help.s5", TXT), ("", TXT), - ("Schrift: DINOBYTE von mby (CC BY 4.0)", DIM), + ("help.font", DIM), ("", TXT), - ("Klick zum Starten", FRAME_HI), + ("help.start", FRAME_HI), ]; + let lines: Vec<(String, Color)> = keys.iter().map(|(k, c)| (if k.is_empty() { String::new() } else { tr(k) }, *c)).collect(); let cols = 74; let rows = lines.len() as i32 + 4; let sw = screen_width(); @@ -449,6 +473,40 @@ impl Ui { self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } + // First start: choose the language (names shown in their own language) + fn draw_langsel(&mut self, gfx: &Gfx) { + let sw = screen_width(); + let sh = screen_height(); + draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.6)); + let langs = languages(); + let cols = 50; + let rows = 6 + langs.len() as i32 * 3; + let x = ((sw - cols as f32 * CW) * 0.5).max(0.0); + let y = ((sh - rows as f32 * CH) * 0.5).max(0.0); + // the title shows all language names, since no language is chosen yet + let title = langs.iter().map(|(c, _)| crate::i18n::tr_in(c, "langsel.title")).collect::>().join(" / "); + gfx.window_titled(x, y, cols, rows, S, true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); + self.lang_rects.clear(); + let (mx, my) = mouse_position(); + for (k, (code, name)) in langs.iter().enumerate() { + let bw = 24.0 * CW; + let bx = (sw - bw) * 0.5; + let by = y + CH * (2.0 + k as f32 * 3.0); + let r = Rect::new(bx, by, bw, CH * 2.0); + let hov = r.contains(vec2(mx, my)); + draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hov { FRAME_HI } else { FRAME }); + gfx.text(name, bx + (bw - gfx.measure(name, S)) * 0.5, by + CH * 0.5, S, TXT); + self.lang_rects.push((r, code.clone())); + } + let hint_y = y + CH * (2.0 + langs.len() as f32 * 3.0 + 0.5); + for (k, (c, _)) in langs.iter().enumerate() { + let h = crate::i18n::tr_in(c, "langsel.hint"); + gfx.text(&h, x + CW * 2.0, hint_y + CH * k as f32, 1.0, DIM); + } + self.rects.push(Rect::new(0.0, 0.0, sw, sh)); + } + fn draw_end(&mut self, g: &Game, gfx: &Gfx) { let sw = screen_width(); let sh = screen_height(); @@ -460,13 +518,14 @@ impl Ui { let y = (sh - rows as f32 * CH) * 0.5; let col = if win { GOLD } else { RED }; gfx.window(x, y, cols, rows, S, true, col, Color::new(0.05, 0.03, 0.04, 0.97)); - let title = if win { "SIEG!" } else { "NIEDERLAGE" }; - gfx.text(title, (sw - gfx.measure(title, 4.0)) * 0.5, y + CH * 1.5, 4.0, col); - let sub = if win { "Das Heldentor ist gefallen." } else { "Dein Seelenkern ist zerbrochen." }; - gfx.text(sub, (sw - gfx.measure(sub, S)) * 0.5, y + CH * 4.5, S, TXT); - let st = format!("Spielzeit {} Wellen {} Armee {}", mmss(g.time), g.wave, g.army_value() as i32); + let title = tr(if win { "end.victory" } else { "end.defeat" }); + gfx.text(&title, (sw - gfx.measure(&title, 4.0)) * 0.5, y + CH * 1.5, 4.0, col); + let sub = tr(if win { "end.victory_sub" } else { "end.defeat_sub" }); + gfx.text(&sub, (sw - gfx.measure(&sub, S)) * 0.5, y + CH * 4.5, S, TXT); + let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]); gfx.text(&st, (sw - gfx.measure(&st, S)) * 0.5, y + CH * 6.0, S, DIM); - let b = " Neues Spiel (N) "; + let b = format!(" {} ", tr("end.new")); + let b = b.as_str(); let bw = gfx.measure(b, S) + CW * 2.0; let bx = (sw - bw) * 0.5; let by = y + CH * 7.75; @@ -480,80 +539,81 @@ impl Ui { } } -// Infotext für Einheit oder Kachel unter dem Mauszeiger (erste Zeile = Titel) +// Info text for the unit or tile under the mouse (first line = title) pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec<(String, Color)> { let mut out = Vec::new(); if let Some(j) = ent { let e = &g.ents[j]; let s = st(e.kind); - out.push((format!("{} (Stufe {})", s.name, e.level), if e.hero { BLUE } else { FRAME_HI })); - out.push((format!("HP {}/{}", e.hp.max(0.0) as i32, e.max_hp as i32), GREEN)); - out.push((job_name(e).to_string(), TXT)); + out.push((trf("info.level", &[tr(s.name), e.level.to_string()]), if e.hero { BLUE } else { FRAME_HI })); + out.push((trf("info.hp", &[(e.hp.max(0.0) as i32).to_string(), (e.max_hp as i32).to_string()]), GREEN)); + out.push((tr(job_name(e)), TXT)); if e.hero { - out.push((if e.guard { "Torwächter".into() } else { "Eindringling".into() }, BLUE)); + out.push((tr(if e.guard { "info.guard" } else { "info.intruder" }), BLUE)); } else if e.creature() { - let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() as i32; - out.push((format!("Energie {}% Hunger {}%", pct(e.energy), pct(e.hunger / 2.0)), TXT)); + let pct = |v: f32| ((v.clamp(0.0, 1.0) * 100.0).round() as i32).to_string(); + out.push((trf("info.needs", &[pct(e.energy), pct(e.hunger / 2.0)]), TXT)); let mood = if e.happy > 0.6 { - ("zufrieden", GREEN) + ("mood.happy", GREEN) } else if e.happy > 0.25 { - ("mürrisch", GOLD) + ("mood.grumpy", GOLD) } else { - ("wütend", RED) + ("mood.angry", RED) }; - out.push((format!("Laune: {}", mood.0), mood.1)); - out.push((format!("Lohn: {} Gold", (s.wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)) as i32), GOLD)); + out.push((trf("info.mood", &[tr(mood.0)]), mood.1)); + let wage = (s.wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)) as i32; + out.push((trf("info.wage", &[wage.to_string()]), GOLD)); } else if e.carry > 0 { - out.push((format!("Trägt {} Gold", e.carry), GOLD)); + out.push((trf("info.carries", &[e.carry.to_string()]), GOLD)); } return out; } let Some((x, y)) = tile else { return out }; let Some(t) = g.map.get(x, y) else { return out }; let owner = match t.owner { - Owner::Player => "erobert", - Owner::Hero => "Heldengebiet", - Owner::None => "neutral", + Owner::Player => "owner.player", + Owner::Hero => "owner.hero", + Owner::None => "owner.none", }; match t.kind { - Kind::Rock => out.push(("Fels".into(), DIM)), - Kind::Earth => out.push(("Erde".into(), FRAME_HI)), + Kind::Rock => out.push((tr("tile.rock"), DIM)), + Kind::Earth => out.push((tr("tile.earth"), FRAME_HI)), Kind::Gold => { - out.push(("Goldader".into(), GOLD)); - out.push((format!("{} Gold", t.gold), GOLD)); + out.push((tr("tile.gold"), GOLD)); + out.push((trf("tile.gold_amount", &[t.gold.to_string()]), GOLD)); } Kind::Floor => { - out.push(("Boden".into(), TXT)); - out.push((owner.into(), DIM)); + out.push((tr("tile.floor"), TXT)); + out.push((tr(owner), DIM)); } Kind::Core => { - out.push(("Seelenkern".into(), RED)); - out.push((format!("HP {}/{}", g.core_hp as i32, CORE_HP as i32), RED)); + out.push((tr("tile.core"), RED)); + out.push((trf("info.hp", &[(g.core_hp as i32).to_string(), (CORE_HP as i32).to_string()]), RED)); } Kind::Portal => { - out.push(("Portal".into(), MANA)); - out.push((if g.portal_owned() { "erobert".into() } else { "neutral: Wichte können es erobern".into() }, DIM)); + out.push((tr("tile.portal"), MANA)); + out.push((tr(if g.portal_owned() { "portal.claimed" } else { "portal.neutral" }), DIM)); } Kind::Gate => { - out.push(("Heldentor".into(), BLUE)); - out.push((format!("HP {}/{}", g.gate_hp as i32, GATE_HP as i32), BLUE)); + out.push((tr("tile.gate"), BLUE)); + out.push((trf("info.hp", &[(g.gate_hp as i32).to_string(), (GATE_HP as i32).to_string()]), BLUE)); } k => { if let Some(r) = room_index(k) { - out.push((ROOM_NAMES[r].into(), FRAME_HI)); + out.push((tr(ROOM_NAMES[r]), FRAME_HI)); match k { - Kind::Farm => out.push((format!("{} Pilze", t.food as i32), GREEN)), - Kind::Lair => out.push((if t.bed != 0 { "Schlafplatz belegt".into() } else { "Schlafplatz frei".into() }, DIM)), + Kind::Farm => out.push((trf("farm.food", &[(t.food as i32).to_string()]), GREEN)), + Kind::Lair => out.push((tr(if t.bed != 0 { "lair.taken" } else { "lair.free" }), DIM)), _ => {} } } } } if t.kind == Kind::Rock { - out.push(("unzerstörbar".into(), DIM)); + out.push((tr("tile.indestructible"), DIM)); } if t.marked { - out.push(("zum Graben markiert".into(), GOLD)); + out.push((tr("tile.marked"), GOLD)); } out } diff --git a/www/dc_plugin.js b/www/dc_plugin.js new file mode 100644 index 0000000..fc00e08 --- /dev/null +++ b/www/dc_plugin.js @@ -0,0 +1,32 @@ +// DungeonCraft browser plugin for miniquad: localStorage access for the Rust code. +(function () { + "use strict"; + var pending = null; + + function bytes(ptr, len) { + return new Uint8Array(wasm_memory.buffer, ptr, len); + } + function str(ptr, len) { + return new TextDecoder().decode(bytes(ptr, len)); + } + + function register(importObject) { + importObject.env.dc_storage_get = function (kp, kl) { + var v = null; + try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { } + if (v === null) { pending = null; return -1; } + pending = new TextEncoder().encode(v); + return pending.length; + }; + importObject.env.dc_storage_take = function (ptr) { + if (pending === null) return; + bytes(ptr, pending.length).set(pending); + pending = null; + }; + importObject.env.dc_storage_set = function (kp, kl, vp, vl) { + try { window.localStorage.setItem(str(kp, kl), str(vp, vl)); } catch (e) { } + }; + } + + miniquad_add_plugin({ register_plugin: register, version: 1, name: "dungeoncraft" }); +})(); diff --git a/www/index.html b/www/index.html index 007b645..234c41a 100644 --- a/www/index.html +++ b/www/index.html @@ -11,8 +11,9 @@ - + +