Compare commits

..
121 Commits
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 d8aa0e5fc1 Bump version to v202611-00076
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:33:43 +01:00
Richard ZinkandClaude Opus 5.5 98b78eb518 Desktop release builds: Linux AppImage, Windows exe and macOS app via tools/build_desktop.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:22:11 +01:00
Richard ZinkandClaude Opus 5.5 2475a8ca3c Desktop build plays music and sound through the system sound output; desktop build documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:49:45 +01:00
Richard ZinkandClaude Opus 5.5 db521ed2ee Desktop build: settings and progress kept as files, savegames in a saves folder, system file dialog for loading
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:46:11 +01:00
Richard ZinkandClaude Opus 5.5 1df97b4af2 Bump version to v202611-00075
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:08:28 +01:00
Richard ZinkandClaude Opus 5.5 bd3f42b8b8 Hero waves break in where the warning shows: they round the realm to the marked entry; the entry tile glows red
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:08:28 +01:00
Richard ZinkandClaude Opus 5.5 dab31579df Bump version to v202611-00074
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:34:04 +01:00
Richard ZinkandClaude Opus 5.5 e85e602491 Balance: easy AI stays smaller; balance runs documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:34:03 +01:00
Richard ZinkandClaude Opus 5.5 28eac52953 Memorial stones only for level 10 creatures, at most 30 per map
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:34:03 +01:00
Richard ZinkandClaude Opus 5.5 66eaedf016 Automated balance runs; AI masters keep growing, mine more, defend with the rally flag and spells; Builder talent stronger
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:07:02 +01:00
Richard ZinkandClaude Opus 5.5 24ba7fb5e3 Bump version to v202611-00073
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:48:07 +01:00
Richard ZinkandClaude Opus 5.5 beb53a2e0c Serve the wasm and scripts gzip-compressed (packed at build time)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:48:07 +01:00
Richard ZinkandClaude Opus 5.5 c52fc4c4ae Hall of the fallen: kills and causes of death, in the game and over all games in the profile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:48:06 +01:00
Richard ZinkandClaude Opus 5.5 065fe80218 Bump version to v202611-00072
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:37:28 +01:00
Richard ZinkandClaude Opus 5.5 bc1f89b4a7 Sounds for weather, rogues and groves: thunder, rain and wind ambience, coin theft, snares, sermons, spores
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:37:28 +01:00
Richard ZinkandClaude Opus 5.5 340014c582 Bump version to v202611-00071
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:29:44 +01:00
Richard ZinkandClaude Opus 5.5 eb219f6b01 Script blocks for weather and talent experience, groves as ground; the co-op campaign brings rogues and a storm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:29:44 +01:00
Richard ZinkandClaude Opus 5.5 4e3158cc19 Bump version to v202611-00070
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:21:07 +01:00
Richard ZinkandClaude Opus 5.5 70f112543f Interface size setting: auto, large, medium or small on the world's pixel grid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:21:07 +01:00
Richard ZinkandClaude Opus 5.5 95655a0886 Bump version to v202611-00069
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:12:42 +01:00
Richard ZinkandClaude Opus 5.5 5a2ac32071 Command markers only with the debug setting; removing a marker clears them at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:12:41 +01:00
Richard ZinkandClaude Opus 5.5 be4459e19e Bump version to v202611-00068
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:40:22 +01:00
Richard ZinkandClaude Opus 5.5 ad5ceb8c13 Named creatures: names per kind and language, epithets at higher levels, memorial stones for the fallen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:40:22 +01:00
Richard ZinkandClaude Opus 5.5 189da6f408 Bump version to v202611-00067
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:19:37 +01:00
Richard ZinkandClaude Opus 5.5 0cbcd1c8d1 Markers: a second click or tap removes them; right click with the flag tool takes the flag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:19:37 +01:00
Richard ZinkandClaude Opus 5.5 c3f7939627 Bump version to v202611-00066
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:11:20 +01:00
Richard ZinkandClaude Opus 5.5 3e17bdf52d Weather above ground: rain puts out fires, fog shortens sight, storms strike with lightning
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:11:19 +01:00
Richard ZinkandClaude Opus 5.5 f98f05f0d2 Bump version to v202611-00065
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:48:06 +01:00
Richard ZinkandClaude Opus 5.5 25334e7b17 Portrait and square screens: world pixels, interface and zoom count on the shorter side
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:48:06 +01:00
Richard ZinkandClaude Opus 5.5 16857f1b9d Bump version to v202611-00064
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:35:25 +01:00
Richard ZinkandClaude Opus 5.5 9804e24015 Smaller touch interface: the same pixel density as on the desktop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:35:11 +01:00
Richard ZinkandClaude Opus 5.5 bd956a4f57 Master talents: experience, points and four branches (Builder, Warlord, Warlock, Merchant) with a toolbar window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:35:11 +01:00
Richard ZinkandClaude Opus 5.5 b93bcd89c4 Bump version to v202611-00063
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:02:50 +01:00
Richard ZinkandClaude Opus 5.5 ff48a69571 New heroes: thief steals gold, monk ignores spells, ranger lays snares, inquisitor turns weak creatures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:02:50 +01:00
Richard ZinkandClaude Opus 5.5 a1a8f9d71d Bump version to v202611-00062
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:27:14 +01:00
Richard Zink 22cc4d9446 Merge branch 'worktree-agent-ae760a17ac34a8093' 2026-11-06 10:25:26 +01:00
Richard ZinkandClaude Opus 5.5 f8eda88bb3 Mushroom groves: unclaimable moss where giant mushrooms grow slowly and glow when ready to fell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:25:24 +01:00
Richard ZinkandClaude Opus 5.5 fdce6c282b Give every achievement its own pixel-art badge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:20:41 +01:00
Richard ZinkandClaude Opus 5.5 368a338ccf Bump version to v202611-00061
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:06:51 +01:00
Richard ZinkandClaude Opus 5.5 ac22351cdf Monster eye marks the camera on the minimap; middle double click resets the view angle; creatures by beds up to 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:06:51 +01:00
Richard ZinkandClaude Opus 5.5 0b2d327791 Bump version to v202611-00060
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:54:19 +01:00
Richard ZinkandClaude Opus 5.5 cccb6b64c3 AI masters build only rooms and towers they have researched
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:54:19 +01:00
Richard ZinkandClaude Opus 5.5 e1a8e2c49c Creatures make their beds only in their own master's lair
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:50:31 +01:00
Richard ZinkandClaude Opus 5.5 568476ed1f Bump version to v202611-00059
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:32:40 +01:00
Richard ZinkandClaude Opus 5.5 6880324e33 Versus map size on the match page; the size sets the number of seats
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:32:40 +01:00
Richard ZinkandClaude Opus 5.5 7b927b0df7 Every master of a match gets an own portal beside the dungeon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:32:40 +01:00
Richard ZinkandClaude Opus 5.5 aeaa1eba36 Portals glow in the colour of the master who claimed them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:26:43 +01:00
Richard ZinkandClaude Opus 5.5 739e82b505 Bump version to v202611-00058
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:21:42 +01:00
Richard ZinkandClaude Opus 5.5 773be21455 Marks and dig selection in each master's colour; imps dig only their master's marks; AI marks near its ground, acts calmer, re-summons imps; allies never claim each other's ground
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:21:42 +01:00
Richard ZinkandClaude Opus 5.5 010a6a8a12 Bump version to v202611-00057
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:01:57 +01:00
Richard ZinkandClaude Opus 5.5 b29e312e6b Fourth master: dwarves get their own side so the yellow AI works; yellow colours and texts; AI cores use the player's throne and heart in their colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:01:57 +01:00
Richard ZinkandClaude Opus 5.5 450e55d693 Dig marks in the colour of the master who set them; every master's dungeon is lit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:56:36 +01:00
Richard ZinkandClaude Opus 5.5 5121a2109b Bump version to v202611-00056
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:49:54 +01:00
Richard ZinkandClaude Opus 5.5 4ef7672008 Music player in the sound settings: pick any calm track or automatic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:49:54 +01:00
Richard ZinkandClaude Opus 5.5 51b7bb4804 Bump version to v202611-00055
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:43:50 +01:00
Richard ZinkandClaude Opus 5.5 0eebf47530 Build menu: traps, doors, steel walls and siege engines; new tools unlock by mission
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:43:49 +01:00
Richard ZinkandClaude Opus 5.5 d4c2260822 Bump version to v202611-00054
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:34:08 +01:00
Richard ZinkandClaude Opus 5.5 de2dbab592 Tooltips wrap every line and grow with their text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:34:08 +01:00
Richard ZinkandClaude Opus 5.5 d8d11699fc Bump version to v202611-00053
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:27:18 +01:00
Richard ZinkandClaude Opus 5.5 cc1fbacd7c Clearer hand cursors: an open goblin hand with four fingers and a thumb, a fist with knuckles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:27:16 +01:00
Richard ZinkandClaude Opus 5.5 7b9d90ebbd Bump version to v202611-00052
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:18:11 +01:00
Richard ZinkandClaude Opus 5.5 a00ae6a06b Real AI masters: rivals, AI seats and the co-op partner play by the player's rules (imps dig, mine and claim, rooms, towers, tunnels and the attack flag); other masters are saved; portals belong to whoever claims them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:18:09 +01:00
Richard ZinkandClaude Opus 5.5 b940a959f1 Roadmap: next steps (AI masters, cursor, heroes, talents, weather, names)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:58:12 +01:00
Richard ZinkandClaude Opus 5.5 4b77ffd3b2 Bump version to v202611-00051
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:54:37 +01:00
Richard ZinkandClaude Opus 5.5 7709226a58 Savegames hold only the game state: no settings, no camera (settings stay in local storage)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:54:36 +01:00
Richard ZinkandClaude Opus 5.5 68e878265c Bump version to v202611-00050
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:47:23 +01:00
Richard ZinkandClaude Opus 5.5 c9dafb1606 Guard and attack markers (shields and swords, up to eight each, right click removes); the world and the interface are copied opaque so glows keep their brightness
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:47:21 +01:00
Richard ZinkandClaude Opus 5.5 a1a53273cd Bump version to v202611-00049
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:33:27 +01:00
Richard ZinkandClaude Opus 5.5 e7fc0a4751 Interface on the pixel grid: a virtual screen of whole multiples of the world's rows, sharp-scaled to every device; compact title menu on short screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:33:25 +01:00
Richard ZinkandClaude Opus 5.5 aa39e66da9 Bump version to v202611-00048
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:14:19 +01:00
Richard ZinkandClaude Opus 5.5 ed1b87e5f3 Pixel look: the world always renders 360 rows high (270/480 selectable), sharp scaling to any screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:14:17 +01:00
Richard ZinkandClaude Opus 5.5 42281b67a5 Bump version to v202611-00047
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:37:27 +01:00
Richard ZinkandClaude Opus 5.5 160935bf40 Tutorial: Crookfinger leads a new master through the first steps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:37:25 +01:00
Richard ZinkandClaude Opus 5.5 1fb6a6ab58 Bump version to v202611-00046
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:27:01 +01:00
Richard ZinkandClaude Opus 5.5 4c440fb97f Creature equipment: the forge makes iron, steel and rune gear for the strongest creatures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:26:59 +01:00
Richard ZinkandClaude Opus 5.5 82dd8c73e1 Bump version to v202611-00045
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:18:49 +01:00
Richard ZinkandClaude Opus 5.5 4c9fbe47e4 Traders come through the portal and sell rune plans, rare creatures, wood and mana
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:18:47 +01:00
Richard ZinkandClaude Opus 5.5 74837f1c34 Seasons: snow above ground in winter, glowing pumpkins at Halloween
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:14:51 +01:00
Richard ZinkandClaude Opus 5.5 95f9512b83 Bump version to v202611-00044
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:09:20 +01:00
Richard ZinkandClaude Opus 5.5 655e625eb0 Wild beasts: cave bears and giant beetles in dens, tamed when beaten while a prison has room
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:09:18 +01:00
Richard ZinkandClaude Opus 5.5 af21c1ae46 Bump version to v202611-00043
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:58:30 +01:00
Richard ZinkandClaude Opus 5.5 c6d82027dd Random events: earthquakes, overflowing rivers, new gold veins (free games and versus, can be switched off)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:58:28 +01:00
Richard ZinkandClaude Opus 5.5 5239e4b5a3 Bump version to v202611-00042
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:51:12 +01:00
Richard ZinkandClaude Opus 5.5 1e6a5645a1 Screenshot scene with upgraded rooms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:51:11 +01:00
Richard ZinkandClaude Opus 5.5 9b49bc2c9d Room levels 2 and 3, researched in the library: a quarter better each, brighter, with gold trim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:44:03 +01:00
Richard ZinkandClaude Opus 5.5 f7b550a6b1 Bump version to v202611-00041
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:36:21 +01:00
Richard ZinkandClaude Opus 5.5 9931ca9b54 Statistics with a chart at the end of a game, 64 achievements kept locally and on the server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:36:19 +01:00
Richard ZinkandClaude Opus 5.5 193b199875 Bump version to v202611-00040
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:17:33 +01:00
Richard ZinkandClaude Opus 5.5 9535e68df0 Champions of the Order lead every fifth wave: four bosses with abilities, a health bar and loot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:17:20 +01:00
Richard ZinkandClaude Opus 5.5 9debc08a0a Bump version to v202611-00039
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:09:51 +01:00
Richard ZinkandClaude Opus 5.5 7da61d3d50 Title demo: the knights come from a forest above ground; lighter meadow, darker trees, no lava above ground
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:09:49 +01:00
Richard ZinkandClaude Opus 5.5 d54de78f37 Music: six more dungeon pieces and six surface pieces with flute, harp, birdsong and wind
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:58:41 +01:00
Richard ZinkandClaude Opus 5.5 86c707e151 Bump version to v202611-00038
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:50:38 +01:00
Richard ZinkandClaude Opus 5.5 f27dbea2f1 Final missions above ground (M30, co-op M20), surface option for versus matches, docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:50:24 +01:00
Richard ZinkandClaude Opus 5.5 1ae41228ab Siege engines: knights bring rams and catapults, masters build their own in the forge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:46:06 +01:00
Richard ZinkandClaude Opus 5.5 5a8ebb984e Fortresses above ground stand in a clearing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:37:37 +01:00
Richard ZinkandClaude Opus 5.5 8a35a903de Surface world: grass in daylight, forests, cliffs with cave mouths; free-game and editor option
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:35:46 +01:00
Richard ZinkandClaude Opus 5.5 ef10b5c432 Bump version to v202611-00037
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:19:06 +01:00
Richard ZinkandClaude Opus 5.5 3138753f2d Screenshot scene with giant mushrooms and planned walls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:19:05 +01:00
Richard ZinkandClaude Opus 5.5 ed3bc437e5 Raise walls: plan them on own floor, imps build reinforced earth
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:11:51 +01:00
Richard ZinkandClaude Opus 5.5 398bb95681 Wood: giant mushrooms underground, felled by imps; wooden bridges and doors cost wood
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:07:59 +01:00
Richard ZinkandClaude Opus 5.5 c5adc3e902 Bump version to v202611-00036
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:52:15 +01:00
Richard ZinkandClaude Opus 5.5 34b4640d85 Drawbridges (down, up, automatic), frost spell and obsidian over cooled lava
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:52:12 +01:00
Richard ZinkandClaude Opus 5.5 76c23a06f0 Bridge tear test without imps (they would mend the bridge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:36:00 +01:00
Richard ZinkandClaude Opus 5.5 29e4970ab1 Wooden bridges burn; knights wear bridges down, imps mend them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:34:33 +01:00
Richard ZinkandClaude Opus 5.5 c11d281281 Editor: remove tiles from painted zones, scrollable zone list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:28:38 +01:00
Richard ZinkandClaude Opus 5.5 a8d1c4d6ea Bump version to v202611-00035
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:21:12 +01:00
Richard ZinkandClaude Opus 5.5 88061a8628 New screenshots; fit long button labels, no HUD behind the title's match page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:20:58 +01:00
Richard ZinkandClaude Opus 5.5 c7c3e979d6 Resume interrupted editor co-op runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:08:33 +01:00
Richard ZinkandClaude Opus 5.5 f87b59390e Co-op offline with an AI partner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:04:49 +01:00
Richard ZinkandClaude Opus 5.5 e3a9d32d61 Hero waves march on the strongest master
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:58:46 +01:00
Richard ZinkandClaude Opus 5.5 988f4a3333 Roadmap: giant mushrooms for wood underground, raising walls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:53:33 +01:00
Richard ZinkandClaude Opus 5.5 afcac35872 Add roadmap of agreed next features
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:47:08 +01:00
Richard ZinkandClaude Opus 5.5 82e75e8f3f Bump version to v202611-00034
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:26:48 +01:00
Richard ZinkandClaude Opus 5.5 652cb4a0f3 Add 13 language packs: PT, NL, DA, NO, SV, FI, HR, HU, SR, RO, SK, CS, EL
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:26:38 +01:00
Richard ZinkandClaude Opus 5.5 64b77b2596 Bump version to v202611-00033
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:11:44 +01:00
Richard ZinkandClaude Opus 5.5 f039c35216 Translate co-op campaign and editor co-op labels to ES, IT, RU
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:10:24 +01:00
Richard ZinkandClaude Opus 5.5 5a63354e2f Document the co-op campaign
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:08:07 +01:00
Richard ZinkandClaude Opus 5.5 a889825768 Open the co-op campaign in the editor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:07:47 +01:00
Richard ZinkandClaude Opus 5.5 4e49c2fdc9 Co-op campaign "League against the Lightwatch": 20 missions, selectable on the Match page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:07:04 +01:00
Richard ZinkandClaude Opus 5.5 405f8f60b2 Bump version to v202611-00032
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:00:22 +01:00
115 changed files with 36712 additions and 1153 deletions

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ Cargo.lock.bak
www/dungeoncraft.wasm www/dungeoncraft.wasm
www/mq_js_bundle.js www/mq_js_bundle.js
play/ play/
dist/
Generated
+829 -4
View File
@@ -14,6 +14,28 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags 2.13.2",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
dependencies = [
"libc",
"pkg-config",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@@ -26,6 +48,27 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.2" version = "1.25.2"
@@ -38,6 +81,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.5" version = "1.0.5"
@@ -50,6 +99,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
dependencies = [
"bytes",
"memchr",
]
[[package]] [[package]]
name = "core_maths" name = "core_maths"
version = "0.1.1" version = "0.1.1"
@@ -59,6 +118,50 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "coreaudio-rs"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
dependencies = [
"bitflags 2.13.2",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "cpal"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
dependencies = [
"alsa",
"block2",
"coreaudio-rs",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
"objc2",
"objc2-audio-toolbox",
"objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
"web-sys",
"windows",
"windows-core",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.4" version = "1.5.4"
@@ -68,11 +171,31 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"block2",
"libc",
"objc2",
]
[[package]] [[package]]
name = "dungeoncraft" name = "dungeoncraft"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cpal",
"macroquad", "macroquad",
"rfd",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -115,10 +238,34 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e796cb6894e6418daacea84b28dd4f98d3b32c74d5bb452ba1ecfbf5ad8e84ca" checksum = "e796cb6894e6418daacea84b28dd4f98d3b32c74d5bb452ba1ecfbf5ad8e84ca"
dependencies = [ dependencies = [
"hashbrown", "hashbrown 0.15.5",
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]] [[package]]
name = "glam" name = "glam"
version = "0.27.0" version = "0.27.0"
@@ -136,6 +283,12 @@ dependencies = [
"foldhash", "foldhash",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "image" name = "image"
version = "0.24.9" version = "0.24.9"
@@ -149,12 +302,91 @@ dependencies = [
"png", "png",
] ]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "js-sys"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.191" version = "0.2.191"
@@ -167,6 +399,18 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "mach2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
[[package]] [[package]]
name = "macroquad" name = "macroquad"
version = "0.4.16" version = "0.4.16"
@@ -209,7 +453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcbaea978d741f3b60ced666b06a2a1e4a2bc81cb8b9dd29f4a40965e1f59e65" checksum = "dcbaea978d741f3b60ced666b06a2a1e4a2bc81cb8b9dd29f4a40965e1f59e65"
dependencies = [ dependencies = [
"libc", "libc",
"ndk-sys", "ndk-sys 0.2.2",
"objc-rs", "objc-rs",
"winapi", "winapi",
] ]
@@ -234,12 +478,52 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "ndk"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.13.2",
"jni-sys 0.3.1",
"log",
"ndk-sys 0.6.0+11769913",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]] [[package]]
name = "ndk-sys" name = "ndk-sys"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121" checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121"
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -249,6 +533,28 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "num_enum"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
]
[[package]]
name = "num_enum_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "objc-rs" name = "objc-rs"
version = "0.2.8" version = "0.2.8"
@@ -258,19 +564,154 @@ dependencies = [
"malloc_buf", "malloc_buf",
] ]
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.13.2",
"libc",
"objc2",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-avf-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
"objc2",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.13.2",
"objc2",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"block2",
"dispatch2",
"libc",
"objc2",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [ dependencies = [
"bitflags", "bitflags 1.3.2",
"crc32fast", "crc32fast",
"fdeflate", "fdeflate",
"flate2", "flate2",
"miniz_oxide 0.8.9", "miniz_oxide 0.8.9",
] ]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@@ -295,6 +736,66 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rfd"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
dependencies = [
"block2",
"dispatch2",
"js-sys",
"libc",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"percent-encoding",
"pollster",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
@@ -322,7 +823,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -344,6 +845,39 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.9" version = "3.0.9"
@@ -355,6 +889,85 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "tokio"
version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "toml_datetime"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.19+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@@ -370,6 +983,81 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
dependencies = [
"cfg-if",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -386,12 +1074,149 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
+5
View File
@@ -17,3 +17,8 @@ codegen-units = 1
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1
# desktop builds: system file dialog (xdg portal on Linux) and sound output
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
cpal = "0.18"
+2
View File
@@ -31,4 +31,6 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY www/ /usr/share/nginx/html/ COPY www/ /usr/share/nginx/html/
COPY --from=build /out/ /usr/share/nginx/html/ COPY --from=build /out/ /usr/share/nginx/html/
COPY --from=standalone /out/standalone/ /usr/share/nginx/html/standalone/ COPY --from=standalone /out/standalone/ /usr/share/nginx/html/standalone/
# packed once for gzip_static (nginx.conf)
RUN cd /usr/share/nginx/html && gzip -k -9 dungeoncraft.wasm mq_js_bundle.js standalone/DungeonCraft.html
EXPOSE 80 EXPOSE 80
+27
View File
@@ -37,6 +37,33 @@ cp ~/.cargo/registry/src/*/macroquad-*/js/mq_js_bundle.js www/
python3 -m http.server 8080 -d www # open http://localhost:8080 python3 -m http.server 8080 -d www # open http://localhost:8080
``` ```
### Desktop build
The game also runs as a native program (Linux, Windows, macOS), without server and multiplayer:
```sh
cargo build --release
./target/release/dungeoncraft
```
On Linux the build needs the ALSA headers (`alsa-lib` / `libasound2-dev`); the sound goes through ALSA (PipeWire and
PulseAudio take it). The settings, the autosave, the campaign progress and the profile are kept as files in the
data folder: `~/.local/share/dungeoncraft/` (Linux, or `$XDG_DATA_HOME/dungeoncraft`), `%APPDATA%\DungeonCraft`
(Windows), `~/Library/Application Support/DungeonCraft` (macOS); `DUNGEONCRAFT_DATA` chooses another one.
*Save Game* and saved campaigns land in its `saves/` folder; *Load Game* opens the system file dialog there (on
Linux through the desktop portal), and dropping a file onto the window works as well.
Release packages are built by `python3 tools/build_desktop.py [linux] [windows] [macos]` into `dist/`:
- **Linux**: `DungeonCraft-<version>-x86_64.AppImage`, built against glibc 2.28 (distributions from about 2019 on).
- **Windows**: `DungeonCraft-<version>-windows-x64.exe`, a single file for Windows 10 and newer, cross-compiled on Linux.
- **macOS**: `DungeonCraft-<version>-macos.zip` with a universal `DungeonCraft.app` (Apple silicon and Intel); only on a
Mac. It is signed ad hoc, not notarised: on first start open it with right click -> *Open*.
Linux and Windows need [zig](https://ziglang.org/) (`mise install zig@0.15` works) and
`cargo install --locked cargo-zigbuild`, plus `rustup target add x86_64-pc-windows-gnu`; the AppImage tool is downloaded
once into `~/.cache/dungeoncraft/`.
### Testing the game logic ### Testing the game logic
The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine: The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine:
+40 -4
View File
@@ -13,7 +13,7 @@ An evil little dungeon game for the browser - in glorious pixels, in 3D, for fre
<img src="https://img.shields.io/badge/made%20with-Claude%20Code-d97757?logo=anthropic&logoColor=white" alt="Made with Claude Code"> <img src="https://img.shields.io/badge/made%20with-Claude%20Code-d97757?logo=anthropic&logoColor=white" alt="Made with Claude Code">
<img src="https://img.shields.io/badge/made%20with-Qwen%20%2B%20OpenCode-7b6cf6" alt="Made with qwen3.8-flash-next-iq3xxs and Strata in OpenCode"> <img src="https://img.shields.io/badge/made%20with-Qwen%20%2B%20OpenCode-7b6cf6" alt="Made with qwen3.8-flash-next-iq3xxs and Strata in OpenCode">
<img src="https://img.shields.io/badge/license-MIT-2ea44f" alt="MIT License"> <img src="https://img.shields.io/badge/license-MIT-2ea44f" alt="MIT License">
<img src="https://img.shields.io/badge/languages-EN%20%7C%20DE-555" alt="English and German"> <img src="https://img.shields.io/badge/languages-18-555" alt="18 languages">
</p> </p>
<p align="center"> <p align="center">
@@ -72,7 +72,7 @@ to *step through shadows*, the chainbreaker to spin like a *whirlwind*.
frost glows - and doors of wood, iron and magic. Lock them, and nobody gets through. Not even the giant. Well. Not for a while. frost glows - and doors of wood, iron and magic. Lock them, and nobody gets through. Not even the giant. Well. Not for a while.
**✨ Cast nasty spells.** Lightning, healing, seer's eye, cave-in, haste, stoneskin, gold rain, invisibility, **✨ Cast nasty spells.** Lightning, healing, seer's eye, cave-in, haste, stoneskin, gold rain, invisibility,
plague and quake. Your scholars **research** them in the library - one discovery after another. plague, quake and frost. Your scholars **research** them in the library - one discovery after another.
**🌫 Explore the dark.** Everything beyond your walls is black until your creatures go there. **🌫 Explore the dark.** Everything beyond your walls is black until your creatures go there.
Somewhere out there: a cave of neutral **dwarves** guarding gold and gems - and sometimes a chest with the plans Somewhere out there: a cave of neutral **dwarves** guarding gold and gems - and sometimes a chest with the plans
@@ -84,10 +84,28 @@ of a dwarven **rune forge tower**. Islands in lava rivers. Mana springs. Creatur
<br><i>Left: the forge and its little coglings. Right: prison, graveyard, arena, guard posts and the altar. Cosy.</i> <br><i>Left: the forge and its little coglings. Right: prison, graveyard, arena, guard posts and the altar. Cosy.</i>
</p> </p>
**🌳 Up into the daylight.** Some maps reach the surface: grass, birds, sunshine - disgusting - and forests your
imps fell for **wood**, the second resource (down below only the odd giant mushroom gives some). Wood builds bridges
and wooden doors. Raise walls again where you dug too much, burn the knights' wooden bridges, pull up your
**drawbridges** when they come, freeze lava into obsidian to cross it - and roll **battering rams and catapults** out
of your forge to break a fortress in the forest.
<p align="center">
<img src="docs/screenshots/surface.png" width="400" alt="The cliff between the dungeon and a sunny forest above ground">
<img src="docs/screenshots/bridges.png" width="400" alt="A raised drawbridge, a lowered one, and obsidian on a lava river">
<br><i>Left: the world up there. Too bright. Right: a drawbridge up, a drawbridge down, and lava that thinks it is a floor.</i>
</p>
**🏆 Champions and trophies.** Every fifth wave a champion of the Order leads the knights - Sir Aldric, Dame
Seraphine, Brother Halvard or Captain Rothgar, each with a trick of his own and loot for whoever brings him down.
After every game a chart shows how it went, and 64 achievements wait - some of them secret, all of them petty.
**👑 Other masters want your realm, too.** Mossgrim the Mouldy (green, smells like a wet cellar) and Lady Nightwell **👑 Other masters want your realm, too.** Mossgrim the Mouldy (green, smells like a wet cellar) and Lady Nightwell
(blue, writes threatening letters on scented paper) dig their own dungeons in free play and in the campaign. (blue, writes threatening letters on scented paper) dig their own dungeons in free play and in the campaign.
And the humans attack *everybody*. Let them bite each other first. And the humans attack *everybody*. Let them bite each other first.
**🎓 New down here?** The *Tutorial* in the title menu: Crookfinger shows you the ropes, one step at a time.
**📜 A campaign of thirty missions.** Take back the shards of your soul core - from the first little outpost by the **📜 A campaign of thirty missions.** Take back the shards of your soul core - from the first little outpost by the
border brook to the citadel of Grand Master Valerian Brightshield. Rivers, fortresses, treasure islands, sealed border brook to the citadel of Grand Master Valerian Brightshield. Rivers, fortresses, treasure islands, sealed
caves, rival masters, and me, telling you the story. With tea. caves, rival masters, and me, telling you the story. With tea.
@@ -128,6 +146,20 @@ caves, rival masters, and me, telling you the story. With tea.
campaigns. *Open the demo* in the editor shows every trigger at work. Details: [docs/CAMPAIGN_EDITOR.md](docs/CAMPAIGN_EDITOR.md), campaigns. *Open the demo* in the editor shows every trigger at work. Details: [docs/CAMPAIGN_EDITOR.md](docs/CAMPAIGN_EDITOR.md),
[docs/MISSION_SCRIPTING.md](docs/MISSION_SCRIPTING.md). [docs/MISSION_SCRIPTING.md](docs/MISSION_SCRIPTING.md).
## Together
- **Versus** for 2-4 masters, with teams, gold for allies and fallen masters watching the rest of the match.
- **Co-op**: the 20-mission campaign *League against the Lightwatch*, the built-in missions or any editor campaign,
each master with an own dungeon; empty seats are played by the AI. Hero waves march on the strongest master.
- **Offline with an AI partner**: every co-op mission also runs without the server - the partner builds his own
dungeon, follows your attack flag and helps out with gold. Interrupted runs go on with *Continue*.
Details: [docs/MULTIPLAYER.md](docs/MULTIPLAYER.md).
<p align="center">
<img src="docs/screenshots/coop-league.png" width="400" alt="The Match page with the co-op league and the AI partner">
<img src="docs/screenshots/languages.png" width="400" alt="The language dropdown with pixel flags for 18 languages">
</p>
## Meet the household ## Meet the household
<p align="center"> <p align="center">
@@ -176,9 +208,13 @@ On phones: tap to use a tool, drag to mark or move, two fingers to zoom and turn
- **Rust**, compiled to **WebAssembly**, with [macroquad](https://github.com/not-fl3/macroquad) - one small module, no engine, no plugins. - **Rust**, compiled to **WebAssembly**, with [macroquad](https://github.com/not-fl3/macroquad) - one small module, no engine, no plugins.
- A real 3D isometric world in pixel art: chunked meshes, flickering point lights, a glow pass, particles, - A real 3D isometric world in pixel art: chunked meshes, flickering point lights, a glow pass, particles,
billboard sprites with eight viewing directions, every frame of it drawn by code - **no image files** except the font. billboard sprites with eight viewing directions, every frame of it drawn by code - **no image files** except the font.
- Procedural chiptune soundtrack as FastTracker 2 modules with its own XM replayer and synthesised sound effects. - Procedural chiptune soundtrack as FastTracker 2 modules with its own XM replayer and synthesised sound effects:
47 pieces around one leitmotif - in the dungeon dark and minor, above ground bright, with flute, harp, birdsong
and wind; the music follows the camera between the two worlds.
- A tiny Rust savegame server (axum, Argon2 passwords) - optional; without it the game uses savegame files. - A tiny Rust savegame server (axum, Argon2 passwords) - optional; without it the game uses savegame files.
- English and German, more languages are one text file away. - 18 languages: German, English, Spanish, Portuguese, Dutch, Danish, Norwegian, Swedish, Finnish,
Russian, Croatian, Hungarian, Serbian, Romanian, Slovak, Czech, Italian and Greek – chosen from a
dropdown with pixel flags; another language is one text file away.
## Made with Claude Code ## Made with Claude Code
+1 -1
View File
@@ -1 +1 @@
v202611-00031 v202611-00076
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
# Balance runs
Whole games played headless by the AI brain on every side (`src/balance.rs`), deterministic per seed:
```bash
cargo test --release balance_ -- --ignored --nocapture --test-threads=3
BALANCE_SEEDS=16 cargo test --release balance_difficulty -- --ignored --nocapture # more seeds
BALANCE_PAIR=0,1 ... # one difficulty pairing only (0 easy, 1 medium, 2 hard)
BALANCE_TRACE=1 ... # wave runs: a status line every two minutes
```
- **balance_difficulty** – duels of two AI masters (112x112, no hero waves, 45 min); the sides swap
every other seed. Undecided games count as draws.
- **balance_talents** – four AI masters (medium), each held to one talent branch, the branches moving
round the four starts; placement by survival and strength.
- **balance_waves** – one AI master alone against the hero waves (96x96, 40 min).
## Findings and changes (2026-11-06)
First runs showed:
- AI masters stopped growing after their build order (99 room tiles, 24 creatures from minute 10).
- They mined one vein at a time and only below 1500 gold.
- They fought hero waves piecemeal and never cast spells.
- "Hard" attacked even when weaker (boldness 0.9) and was no stronger than "easy".
Changes:
- AI masters keep building lairs and gardens up to 24 / 50 / 80 beds (easy / medium / hard), and a
bigger treasury when the old one is nearly full.
- They mine several veins (one per three imps) whenever gold is below 1.5 paydays + 1000.
- Defence: with intruders in the dungeon the rally flag goes on them, the master casts lightning on
them and heals his hurt creatures; an attack is called off to defend.
- Boldness (own army / enemy army needed to attack): easy 1.0, medium 1.2, hard 1.35.
- Builder talent: imps dig +15% and rooms cost -10% per level (was +10% / -6%).
Results after the changes:
| | before | after |
|---|---|---|
| one master vs waves: fallen of 8 (easy/medium/hard) | 2 / 3 / 4 | 2 / 2 / 2 |
| duels, 16 seeds: easy vs medium | 6 : 6 | 3 : 6 |
| medium vs hard | 5 : 6 (8 seeds 3 : 2) | 5 : 6 |
| easy vs hard | 3 : 7 | 3 : 7 |
| talents, average place (builder / warlord / warlock / merchant) | 3.3 / 1.9 / 3.0 / 1.9 | 3.1 / 2.1 / 2.5 / 2.3 |
Notes:
- The noise is about ±2 wins at 16 seeds; medium vs medium ended 5 : 7.
- The AI uses only lightning and heal, so Warlock is still worth more to a human player than the
tournament shows.
- Builder pays off where space, not gold, limits building; AI dungeons are bounded by their radius.
- One map seed (1333) never brings heroes to the master. It shows as "wave 11, heroes 0" in the
wave table and is a map outlier.
+2 -1
View File
@@ -33,6 +33,7 @@ the file format in `src/campaign_doc.rs`, the scripts in `src/blocks.rs` and `sr
- a `.dccamp` file (or drop the file onto the window), - a `.dccamp` file (or drop the file onto the window),
- **Open the demo** – the demo campaign, which uses every trigger kind and every special script, - **Open the demo** – the demo campaign, which uses every trigger kind and every special script,
- **League against the Lightwatch** – the built-in 20-mission co-op campaign, as a copy to change or learn from,
- **a mission of the built-in campaign as a copy** – its map as it starts, its texts and goals, - **a mission of the built-in campaign as a copy** – its map as it starts, its texts and goals,
added to the current campaign (the original stays untouched), added to the current campaign (the original stays untouched),
- **my campaigns (online)** and **published campaigns** of all players (needs a login on a server - **my campaigns (online)** and **published campaigns** of all players (needs a login on a server
@@ -94,7 +95,7 @@ The map in the game's 3D view; tool bar on the left, options on top.
| Towers | arrow, cannon, magic, rune forge tower; the dungeon's or the knights', level 1–3 | | Towers | arrow, cannon, magic, rune forge tower; the dungeon's or the knights', level 1–3 |
| Traps | the seven traps and three doors, armed | | Traps | the seven traps and three doors, armed |
| Forts | a knight fortress (with or without a shard), rival masters (Mossgrim, Lady Nightwell, rival 3); level = strength | | Forts | a knight fortress (with or without a shard), rival masters (Mossgrim, Lady Nightwell, rival 3); level = strength |
| Zones | trigger zones: rectangle, circle (drag from the middle), painted tiles (paint onto the selected tiles zone), line (drag from start to end); name, rename, delete | | Zones | trigger zones: rectangle, circle (drag from the middle), painted tiles (paint onto the selected tiles zone; *Remove tiles* takes them out again), line (drag from start to end); name, rename, delete (the "x"); the list scrolls with the wheel |
| Erase | removes units, towers, traps and doors on a tile | | Erase | removes units, towers, traps and doors on a tile |
Options: brush size 1–5, rectangle, fill (connected area of the same ground), mirror (left/right, Options: brush size 1–5, rectangle, fill (connected area of the same ground), mirror (left/right,
+4 -2
View File
@@ -58,10 +58,12 @@ creatures and heroes.
| Units | Spawn | count, kind, level, side, where (the core, the trigger unit, a zone), optional name | | Units | Spawn | count, kind, level, side, where (the core, the trigger unit, a zone), optional name |
| | Hero wave | count (sent at once) | | | Hero wave | count (sent at once) |
| | Remove | side, zone (removes those units) | | | Remove | side, zone (removes those units) |
| World | Change ground | zone → floor, earth, rock, water, lava, gold (the core, portals, towers and the gate stay) | | World | Change ground | zone → floor, earth, rock, water, lava, gold, mushroom grove (the core, portals, towers and the gate stay) |
| | Reveal | zone (lifts the fog of war) | | | Reveal | zone (lifts the fog of war) |
| | Show | zone (moves the camera there) | | | Show | zone (moves the camera there) |
| | Gold + / Mana + | amount (negative takes) | | | Gold + / Mana + | amount (negative takes) |
| | Weather above ground | fair, rain, fog or storm, for seconds (0: until a block changes it); it fades in softly. Only maps with land above ground have weather |
| | Talent experience + | amount (experience for the player's master talents) |
| Logic | If / else | condition; blocks inside | | Logic | If / else | condition; blocks inside |
| | Repeat | times (0–100); blocks inside | | | Repeat | times (0–100); blocks inside |
| | Repeat until | condition, checked **before** each run (already true: the inside never runs); blocks inside | | | Repeat until | condition, checked **before** each run (already true: the inside never runs); blocks inside |
@@ -80,7 +82,7 @@ Every number field holds a **value**; the switch in front of it changes the valu
side/kind), *Count* in a zone, *Random* (whole number between a and b, deterministic), side/kind), *Count* in a zone, *Random* (whole number between a and b, deterministic),
*Calculation* (`+ - * /`, division by 0 gives 0), *Calculation* (`+ - * /`, division by 0 gives 0),
- conditions (for *If*): *Comparison* (`< <= = != >= >`), *and*, *or*, *not*, *Yes/no*, - conditions (for *If*): *Comparison* (`< <= = != >= >`), *and*, *or*, *not*, *Yes/no*,
*Trigger unit is a* (kind). *Trigger unit is a* (kind), *Weather above ground is* (fair, rain, fog, storm).
Variables are numbers the mission remembers (saved with the game); a variable is known once some Variables are numbers the mission remembers (saved with the game); a variable is known once some
script of the mission sets it. script of the mission sets it.
+19 -3
View File
@@ -10,6 +10,8 @@ Multiplayer is only available for users of a server (not in the standalone file)
- The match **pauses while a player is missing** (connection lost); a player who **leaves** is replaced by an AI. - The match **pauses while a player is missing** (connection lost); a player who **leaves** is replaced by an AI.
- Before the start, the host manually marks a free seat as AI with `{"t":"set_ai","side":n,"ai":true}` and clears it again with `ai:false`; side 0 stays human. - Before the start, the host manually marks a free seat as AI with `{"t":"set_ai","side":n,"ai":true}` and clears it again with `ai:false`; side 0 stays human.
- The humans (hero waves of the Order) as a third party (can be switched off), chat, alliances/teams. - The humans (hero waves of the Order) as a third party (can be switched off), chat, alliances/teams.
Every wave marches on the strongest master still standing (creatures, towers, gold) – player or
AI rival – and is measured against him; the other players learn whom it attacks.
- Coop is "separate but helpful": own heart, gold, mana, research and creatures; allies never fight each - Coop is "separate but helpful": own heart, gold, mana, research and creatures; allies never fight each
other, their creatures help each other, gold can be sent, allied ground can be walked; a fallen heart only other, their creatures help each other, gold can be sent, allied ground can be walked; a fallen heart only
knocks out that player (who may keep watching); the allies win when all enemies have fallen. knocks out that player (who may keep watching); the allies win when all enemies have fallen.
@@ -65,8 +67,10 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
- **Menu**: *Multiplayer* in the title menu with the match list, create (versus or co-op mission), join by code, - **Menu**: *Multiplayer* in the title menu with the match list, create (versus or co-op mission), join by code,
seat choice, AI seat toggles for the host, ready/start, chat; the match keeps running in the background while seat choice, AI seat toggles for the host, ready/start, chat; the match keeps running in the background while
menus are open. menus are open.
- **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left are played - **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left keep their
by the simplified rival AI on every client, so they stay deterministic. master (dungeon, gold, creatures) and are played by the AI brain (`src/ai.rs`) on every client - the same
orders a player gives (dig, rooms, imps, towers, the attack flag, shields), from the shared state and the
shared random numbers, so they stay deterministic. Free-play and campaign rivals are such AI masters too.
- **Co-op** (`Game::coop`): the master sides are allies against the heroes; allied creatures never target each - **Co-op** (`Game::coop`): the master sides are allies against the heroes; allied creatures never target each
other, allied ground does not block claims. Co-op campaign matches use `campaign::start_coop_mission`. other, allied ground does not block claims. Co-op campaign matches use `campaign::start_coop_mission`.
- **Editor campaigns in co-op**: on the Match page the host can switch from the built-in campaign to the - **Editor campaigns in co-op**: on the Match page the host can switch from the built-in campaign to the
@@ -88,6 +92,18 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
five strongest creatures and 20% of his gold, like the single-player `Carry`. The host stores the progress five strongest creatures and 20% of his gold, like the single-player `Carry`. The host stores the progress
(missions done, chosen branches, run seed) in local storage under `dc.coop_editor_progress` (campaign name -> (missions done, chosen branches, run seed) in local storage under `dc.coop_editor_progress` (campaign name ->
`CampaignProgress`). A run ends when no next mission fits co-op. `CampaignProgress`). A run ends when no next mission fits co-op.
- **Resuming a run**: the host's progress also keeps where the run stands (`CampaignProgress::next`, set when a
mission starts and after a victory) and the carry into that mission. While a run is open the Match page shows
*Continue Mn*: with the relay it creates a room whose `create` carries `"seed"` (the run seed) and `"carry"`;
the relay's `start` then has that seed, the carry and `"resume":true`, and the clients treat it like `next`
(the progress is not reset). Without the relay the run goes on offline with the AI partner.
- **Co-op offline with an AI partner** (*AI partner* on the Match page, works without the server): the selected
co-op mission (built-in, the league or the editor campaign) starts like a match with the player on side 0 and
an AI seat on side 2 (`App::start_offline_coop`: `start_match` + `sync_ai_sides`), then `end_match` drops the
turn clock and the world runs on this computer like a single-player game. The partner (`Game::is_partner`) is
an allied rival-style master with his own dungeon: two of three healthy creatures follow the player's attack
flag (and attack the gate next to it), and every minute he sends 400 gold to an ally below 300. An editor
campaign run goes on locally: the next mission (or the same after a defeat) with the run seed and the carry.
- **Teams in versus**: on the Match page every seat has a team A-D (default: a team of its own), set with - **Teams in versus**: on the Match page every seat has a team A-D (default: a team of its own), set with
`{"t":"set_team","team":n}` for the own seat; the host may add `"side"` for a free or AI seat. Only before the `{"t":"set_team","team":n}` for the own seat; the host may add `"side"` for a free or AI seat. Only before the
start. Room updates carry `team` per seat, the room list `teams`, and `start` a `teams` list in the order of start. Room updates carry `team` per seat, the room list `teams`, and `start` a `teams` list in the order of
@@ -119,7 +135,7 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat, ping | built | | C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat, ping | built |
| D | Client: *Multiplayer* menu (lobby, create, join, seats, ready), WebSocket bridge in `dc_plugin.js`, chat, ping | built | | D | Client: *Multiplayer* menu (lobby, create, join, seats, ready), WebSocket bridge in `dc_plugin.js`, chat, ping | built |
| E | Alliances and co-op rules, AI masters for free and abandoned seats | built: co-op alliance, teams A-D in versus, sending gold, fallen hearts as spectators, AI seats | | E | Alliances and co-op rules, AI masters for free and abandoned seats | built: co-op alliance, teams A-D in versus, sending gold, fallen hearts as spectators, AI seats |
| F | Co-op campaign: 20 missions with story (EN/DE) | partly built: co-op matches reuse the single player missions that fit the master areas (`coop_mission_indexes`); no own co-op missions or story yet | | F | Co-op campaign: 20 missions with story (EN/DE) | built: "League against the Lightwatch" (`src/editor/coop_campaign.rs`), an editor campaign with painted maps, branches, raids and bosses; on the Match page the co-op source cycles built-in missions → the league → the editor's campaign |
| G | Editor campaigns in co-op over several missions (branches, carry, host progress), campaign names in the room list | built | | G | Editor campaigns in co-op over several missions (branches, carry, host progress), campaign names in the room list | built |
+148
View File
@@ -0,0 +1,148 @@
# Roadmap
Agreed on 2026-11-05. Worked through in this order; every block is committed, pushed and deployed
on its own. Details not listed here are decided along the way.
## 1. Open items – done (v202611-00035)
1. **Heroes attack every master** – with rival masters or several players, each hero wave marches
to the heart of the *strongest* living master (most creatures and gold), not only the player's.
2. **Co-op campaign offline with an AI partner** – "League against the Lightwatch" playable
without the server: the partner is an allied AI master with its own dungeon (built like a
rival); its creatures join attacks and it sends gold when the player runs short.
3. **Resume editor co-op runs** – an interrupted multiplayer run of an editor campaign goes on at
the reached mission; the host keeps the progress.
4. **New screenshots** – title demo, language dropdown, co-op campaign.
## 2. Bridges – done (v202611-00036)
Stone bridges over lava exist already (75 gold per tile).
1. **Wooden bridges burn** – fire spells, fire traps and dragon fire set them alight; after a few
seconds they collapse and units on them fall into the water. Stone bridges are fireproof.
2. **Drawbridge** – an expensive bridge (about 150 gold per tile) with a per-bridge switch:
*manual* (a click raises or lowers the whole bridge) or *automatic* (rises when heroes come
near, lowers afterwards). Knights can only tear it down while it is lowered.
3. **Cooling lava** – frost spells and frost traps turn lava into walkable obsidian for a while.
4. **Repairing bridges** – imps repair damaged bridges; knights wear bridges down step by step
instead of in one blow.
Built as: `src/bridges.rs` (fire, wear, drawbridges, obsidian), the *Drawbridge* tool (click: down → up →
automatic) and the *Frost* spell (lava → obsidian for 40 s); the frost glow trap keeps lava next to it cooled.
## 3. The surface world – done (v202611-00038)
1. **Surface terrain** – grass, trees, paths, daylight and sky. Trees act like earth: imps fell
them.
2. **Wood** – a second resource. Above ground it comes from trees (plenty: a fortress is besieged
there), underground from giant mushrooms in caves and by the water (scarce, slowly growing back,
so few wooden doors down there). Wooden bridges, wooden doors, siege engines and room upgrades
cost wood; iron and magic doors and stone bridges still cost gold.
3. **Raising walls** – imps can raise a wall again on the player's own empty floor, for gold, so
tunnels can be closed and doors placed where they matter.
4. **Fortresses above ground** – walls, towers, a gate, a clearing with torches.
5. **Siege weapons** – heroes bring battering rams (against doors) and catapults (against towers);
the player builds his own siege engines in a workshop.
6. **Missions** – the last mission of the main campaign (M30) and of the co-op campaign (M20) lead
to the surface: the dungeon half lies in the rock (gold and more), the other half is a forest
with the High Marshal's fortress to destroy. Free play and multiplayer get a map option
"Surface". The map editor gets the new terrain.
Built as: `src/wood.rs` (wood, giant mushrooms), `src/walls.rs` (the wall tool), `src/surface.rs` (sky layer
`Map::sky`, forests, cliffs and cave mouths, clearings around fortresses), `src/siege.rs` (rams and catapults:
in hero waves from wave 4/6, buildable with a forge). The fortresses above ground are the knights' usual
fortresses (fortress.rs) in a clearing. Final missions: campaign M30 and co-op M20; free play, versus matches
and editor random maps have the option "half above ground".
## 3b. Music – done (v202611-00039)
Six more underground tracks and a block of six surface tracks: the same leitmotif brighter and in major, synthetic
birdsong and wind, flute leads and harp arpeggios. The music follows the camera (surface or underground) with a
crossfade.
Built in `tools/compose_music.py` (instruments flute, bird and wind; birds and wind on two extra channels only in
surface songs, so the older pieces stay byte for byte the same): build16-19, battle9-10, surface1-4,
surface_battle1-2; `audio::Category::Surface` / `SurfaceBattle`, chosen in `MusicDirector` when most of the
camera's surroundings lie above ground (4 s crossfade).
## 4. Additions – done (v202611-00047)
Status: creature abilities (personality.rs: an ability per creature at level 3-7, used by itself) and
creature relationships (likes, hates, brawls) existed already. Done in this block so far:
bosses (`src/bosses.rs`: four champions of the Order lead every fifth wave - Sir Aldric's shield wall,
Dame Seraphine's radiant burst, Brother Halvard's call to arms, Captain Rothgar's charge - with a health bar
and loot), statistics (`src/stats.rs`: counters and a chart at the end of a game) and achievements
(`src/achievements.rs`: 64 of them, kept in the browser and merged with `/api/profile` on the server after a
login; the menu page *Achievements*), room levels 2 and 3 (researched in the library once every tool is known:
+25% per level, brighter, gold trim) and random events (`src/events.rs`: earthquake, lava or water over the
banks, a new gold vein; every 8-12 minutes in free games and versus matches, switch in the game settings; the
host's choice goes to every client with `"events"` in the create message) and wild beasts (`src/beasts.rs`:
dens of cave bears and giant beetles in the caverns of bigger random maps; neutral like the dwarves; beaten while
a prison has room, a beast gives in and joins), traders (`src/traders.rs`: every ~10 minutes in single player a
merchant waits a minute at an own portal; a click with the hand buys rune plans, an ember wyrm, a chainbreaker,
wood or a mana potion), seasons (snow above ground in winter, pumpkins at Halloween, from the real date) and
creature equipment (`src/armory.rs`: without orders the forge makes iron, steel and rune gear for gold; each
piece goes to the strongest creature without one that good: +10/22/38% health and attack, a coloured mark at
the health bar). The dynamic music (calm and battle) existed already and now also follows the camera to the
surface. The tutorial (*Tutorial* in the title menu, `editor::templates::tutorial_campaign`): Crookfinger leads a
new master through digging, claiming, lair, mushroom garden, the first creatures, a slap and two knights - one
painted mission whose triggers switch each other on. **Block 4 is done.**
1. **Random events** – earthquake (tunnels cave in), lava eruption, flood, gold vein found. Rare
(every 8–12 minutes), free play and multiplayer only, can be switched off in the menu; in
campaigns only through new script blocks.
2. **Creature abilities** – from level 5 every creature has an active ability (ogre stomp, spider
web, mage chain lightning, ...), used automatically and wisely, with a cooldown.
3. **Bosses and elite heroes** – every 5th wave brings a boss hero with a health bar at the top,
an ability of its own and loot (gold, runes).
4. **Wild beasts** – beasts in caves (cave bears, giant beetles, ...) that can be tamed in the
prison and become creatures.
5. **Room upgrades** – rooms can be upgraded to level 2 and 3 for gold and wood (a library that
researches faster, ...), shown by ornaments and brighter light.
6. **Creature equipment** – the forge makes weapons and armour in tiers (iron, steel, rune);
creatures fetch the best fitting piece themselves, the strongest first; equipment is visible.
7. **Creature relationships** – creatures like or hate each other, quarrel, and work better with
friends; the lair shows who likes whom.
8. **Dungeon visitors** – neutral traders and smugglers come through the portal and sell rare
traps, runes or creatures for gold.
9. **Dynamic music** – procedural music that changes between calm and battle and swells when
heroes come.
10. **Tutorial mission** – an interactive introduction with Crookfinger (digging, rooms,
creatures, traps), built with the editor and script blocks.
11. **Statistics** – charts at the end of a game (gold over time, creatures, kills, buildings; for
every player in multiplayer) and lifetime totals in the profile.
12. **Achievements** – 50+ achievements with pixel badges, including hidden and humorous ones in
Crookfinger's style and progress bars, stored per user on the server.
13. **Seasonal events** – cosmetic touches by date (pumpkins at Halloween, snow on the surface in
winter, ...).
## 5. Next (agreed 2026-11-06)
Done: the pixel look (the world 360 rows on every device, 270/480 selectable; the interface on the same
grid), guard and attack markers (shields and swords), savegames with the game state only.
1. **Real AI masters** (done, v202611-00052) - rivals, AI seats and the co-op partner play by the player's rules: imps dig,
claim, mine and set torches; rooms by plan, traps, towers, research; the attack flag, shields and swords;
strength by the difficulty.
2. **Mouse cursor** (done, v202611-00053) - clearer hands for the hand tool (open, holding, slap, punch).
3. **New heroes** (done, v202611-00063, rogues.rs) - thief (sneaks to a treasury, steals up to 250 gold and runs
to the gate; slain, his loot is yours; from wave 3), monk (spells do not touch him, 5), ranger (shoots, lays
snares that hurt and stun a creature, 7), inquisitor (fragile, every 20 s turns a nearby kobold or spider to
the heroes, 9). In the campaign from missions 5, 9, 12 and 16 on.
4. **Master talents** (done, v202611-00064, talents.rs) - experience from slain heroes, digging, rooms, walls,
traps, towers, spells and gold; points get dearer (300, 450, 600 ...). Builder (imps dig +10%, rooms -6% per
level), Warlord (creatures +5% damage, -5% damage taken), Warlock (mana +15%, spells -6%), Merchant (wages
-6%, treasure +6%); 5 levels each. The "Master" button (number of free points, pulsing) opens the window.
AI masters spend their points themselves; per game, carried over in the campaign and co-op runs.
5. **Weather above ground** (done, v202611-00066, weather.rs) - about 60% fair, 20% rain, 12% fog, 8% storm,
minutes each with soft 20 s changes, only on maps with land above ground. Rain (and storms) put out burning
bridges and keep fire from catching; fog halves the sight of units outside; storms strike units (40 damage)
or trees with lightning every 6-20 s. Dimmed daylight, rain streaks, fog veils, flashes; a HUD label.
6. **Named creatures** (done, v202611-00068, names.rs) - every creature gets a name from its kind's list in the
language files (20 per kind, per language), a humorous epithet from level 5 and a grand one from level 8;
names in the hover info, the creature overview and the messages. A creature of level 10 that falls leaves a
memorial stone on its master's ground (hover: who lies there; at most 30 per map).
7. **Hall of the fallen** (done, v202611-00073) - every named creature of level 3+ that falls is remembered
with its level, heroes slain and how it fell (slain by a kind, lava, water, snare, lightning): in the game
(creature overview "Fallen", or a click with the hand on a memorial stone) and over all games on the
achievements page (kept in the profile, at most 200, merged with the server copy).
Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

After

Width:  |  Height:  |  Size: 938 KiB

+1770
View File
File diff suppressed because it is too large. Load diff
+1769
View File
File diff suppressed because it is too large. Load diff
+292 -7
View File
@@ -52,17 +52,29 @@ 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_partial = Zahltag! {0} Kreaturen bezahlt ({1} Gold), {2} gingen leer aus und sind wütend!
msg.payday = Zahltag! {0} Kreaturen erhielten {1} Gold. msg.payday = Zahltag! {0} Kreaturen erhielten {1} Gold.
msg.wave_boss = Welle {0}: Ein Lichtpaladin führt {1} Helden in dein Reich! msg.wave_boss = Welle {0}: Ein Lichtpaladin führt {1} Helden in dein Reich!
boss.aldric = Sir Aldric der Unbeugsame
boss.seraphine = Dame Seraphine von der Morgenröte
boss.halvard = Bruder Halvard der Fromme
boss.rothgar = Hauptmann Rothgar Eisenflanke
msg.boss_comes = Ein Champion des Ordens führt die Welle an: {0}!
msg.boss_slain = {0} ist gefallen! Beute: {1} Gold und die Pläne eines Runenschmiede-Turms.
msg.wave = Welle {0}: {1} Helden dringen durch das Tor! 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_boss = Das Tor wankt! Der Torwächter erscheint mit {0} Verteidigern!
msg.gate_defenders = Das Tor ruft {0} Verteidiger herbei! msg.gate_defenders = Das Tor ruft {0} Verteidiger herbei!
msg.level_up = {0} erreicht Stufe {1}. msg.level_up = {0} erreicht Stufe {1}.
msg.hero_slain = {0} besiegt! Beute: {1} Gold. msg.hero_slain = {0} besiegt! Beute: {1} Gold.
msg.thief_in_treasury = Ein Dieb ist in deiner Schatzkammer!
msg.thief_escaped = Ein Dieb ist mit {0} Gold entkommen!
msg.snare_caught = In eine Falle getreten: {0}!
msg.converted = Der Inquisitor hat eine deiner Kreaturen bekehrt: {0}!
msg.creature_died = Dein {0} ist gefallen. msg.creature_died = Dein {0} ist gefallen.
msg.portal_claimed = Das Portal gehört nun dir! Kreaturen werden davon angelockt. 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_removed = Angriffsflagge entfernt. Deine Kreaturen kehren zurück.
msg.flag_set = Angriffsflagge gesetzt! Kampfbereite Kreaturen sammeln sich dort. msg.flag_set = Angriffsflagge gesetzt! Kampfbereite Kreaturen sammeln sich dort.
msg.creature_leaves = Ein unzufriedener {0} verlässt dein Reich! msg.creature_leaves = Ein unzufriedener {0} verlässt dein Reich!
msg.no_gold = Nicht genug Gold! msg.no_gold = Nicht genug Gold!
msg.no_wood = Nicht genug Holz! Fälle Riesenpilze (oder Bäume) mit der Spitzhacke.
msg.siege_built = Ein {0} ist bereit – setze die Angriffsflagge, um ihn loszuschicken.
msg.sold = Räume verkauft: +{0} Gold. msg.sold = Räume verkauft: +{0} Gold.
msg.no_mana_bolt = Nicht genug Mana für den Blitz. 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_heal = Nicht genug Mana für die Heilung.
@@ -72,6 +84,7 @@ msg.no_mana_cost = Nicht genug Mana ({0} benötigt).
hint.portal_no_lair = Das Portal ist offen, aber ohne Quartier kommen keine Kreaturen. hint.portal_no_lair = Das Portal ist offen, aber ohne Quartier kommen keine Kreaturen.
hint.no_farm = Kreaturen wollen essen: Baue einen Pilzgarten. hint.no_farm = Kreaturen wollen essen: Baue einen Pilzgarten.
hint.lair_full = Alle Quartierplätze sind belegt. Vergrößere dein Quartier. hint.lair_full = Alle Quartierplätze sind belegt. Vergrößere dein Quartier.
hint.creature_max = Dein Kerker beherbergt 100 Kreaturen - mehr kommen nicht durch das Portal.
hint.gate_attack = Deine Kreaturen greifen das Heldentor an! hint.gate_attack = Deine Kreaturen greifen das Heldentor an!
hint.treasury_full = Deine Schatzkammern sind voll! Baue mehr Schatzkammer-Felder. 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_unreachable = Die Angriffsflagge ist für deine Kreaturen nicht erreichbar.
@@ -123,6 +136,7 @@ ui.key = Taste: {0}
# ---- Anzeige # ---- Anzeige
hud.gold = Gold hud.gold = Gold
hud.wood = Holz
hud.mana = Mana hud.mana = Mana
hud.core = Kern hud.core = Kern
hud.gate = Tor hud.gate = Tor
@@ -144,6 +158,7 @@ help.k_right = Rechtsklick Einheit absetzen (Gefangene auch in den Kerker)
help.k_slap = Shift+Links Hand: Rückhand für eigene, Faust für Helden auf eigenem Gebiet help.k_slap = Shift+Links Hand: Rückhand für eigene, Faust für Helden auf eigenem Gebiet
help.k_pan = Rechts ziehen Karte verschieben (auch WASD/Pfeile, Bildrand) 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_rot = Mitte ziehen Kamera drehen und neigen (auch Q/E, R/F)
help.k_reset = Mitte 2x klicken Blickwinkel und Drehung zurücksetzen (Ort bleibt)
help.k_zoom = Mausrad Zoomen help.k_zoom = Mausrad Zoomen
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Werkzeug help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Werkzeug
help.k_misc = P / Leertaste Pause M Ton an/aus +/- Lautstärke help.k_misc = P / Leertaste Pause M Ton an/aus +/- Lautstärke
@@ -236,6 +251,7 @@ set.lights = Lichtquellen
set.ui_scale = Oberflächengröße set.ui_scale = Oberflächengröße
set.game_speed = Spieltempo set.game_speed = Spieltempo
set.hints = Hinweise set.hints = Hinweise
set.events = Zufallsereignisse
set.keys_hint = Aktion anklicken und Taste drücken. Bereits belegte Tasten werden getauscht. set.keys_hint = Aktion anklicken und Taste drücken. Bereits belegte Tasten werden getauscht.
set.press_key = Taste drücken für: {0} set.press_key = Taste drücken für: {0}
set.esc_cancel = Esc bricht ab set.esc_cancel = Esc bricht ab
@@ -247,6 +263,8 @@ val.medium = Mittel
val.high = Hoch val.high = Hoch
val.normal = Normal val.normal = Normal
val.large = Groß val.large = Groß
val.small = Klein
val.auto_plain = Automatisch
# ---- Namen der Tastenbelegung # ---- Namen der Tastenbelegung
key.cam_forward = Kamera vor key.cam_forward = Kamera vor
@@ -312,12 +330,30 @@ hint.tower_max = Dieser Turm ist voll ausgebaut.
hint.tower_where = Türme können nur auf eroberten, leeren Bodenfeldern gebaut werden. hint.tower_where = Türme können nur auf eroberten, leeren Bodenfeldern gebaut werden.
hint.tower_mana = Deinen Magietürmen ist das Mana ausgegangen. hint.tower_mana = Deinen Magietürmen ist das Mana ausgegangen.
job.repair = Repariert einen Turm job.repair = Repariert einen Turm
job.mend_bridge = Flickt eine Brücke
job.raise_wall = Mauert eine Wand
job.march = Marschiert zu einem Schwert
job.attack_tower = Greift einen Turm an job.attack_tower = Greift einen Turm an
unit.jailer = Kerkermeister unit.jailer = Kerkermeister
unit.priestess = Flammenpriesterin unit.priestess = Flammenpriesterin
msg.hero_captured = {0} bewusstlos geschlagen und gefangen genommen! Beute: {1} Gold. msg.hero_captured = {0} bewusstlos geschlagen und gefangen genommen! Beute: {1} Gold.
msg.defected = {0} ist zu dir übergelaufen! msg.defected = {0} ist zu dir übergelaufen!
msg.beast_tamed = Ein wilder {0} ergibt sich und schließt sich deinem Kerker an!
gear.1 = Eisenausrüstung
gear.2 = Stahlausrüstung
gear.3 = Runenausrüstung
msg.gear_given = Die Schmiede übergibt {0} an: {1}.
msg.trader_arrives = Ein Händler kam durch dein Portal: {0} für {1} Gold. Klick ihn mit der Hand an, um zu kaufen – er wartet eine Minute.
msg.trader_sold = Gekauft: {0}. Ein Vergnügen, sagt der Händler.
msg.trader_left = Der Händler zuckt mit den Schultern und geht durchs Portal.
trader.info = Bietet {0} für {1} Gold (mit der Hand anklicken)
trader.left = Geht in {0} s
trader.rune = die Pläne eines Runenschmiede-Turms
trader.wyrm = einen Glutwurm
trader.chainbreaker = einen Kettenbrecher
trader.wood = 30 Holz
trader.mana = einen Trank, der dein Mana füllt
msg.prisoner_died = {0} ist unter der Folter gestorben. msg.prisoner_died = {0} ist unter der Folter gestorben.
job.carry = Trägt einen Gefangenen job.carry = Trägt einen Gefangenen
job.pray = Betet job.pray = Betet
@@ -328,6 +364,10 @@ info.tortured = wird gefoltert
info.prison = Gefangene: {0}/{1} info.prison = Gefangene: {0}/{1}
msg.wave_warn_dir = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden wollen von {1} einfallen. msg.wave_warn_dir = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden wollen von {1} einfallen.
msg.event_quake = Erdbeben! Im {0} sind Gänge eingestürzt.
msg.event_lava = Im {0} tritt die Lava über die Ufer!
msg.event_flood = Hochwasser! Im {0} tritt der Fluss über die Ufer.
msg.event_vein = Die Imps melden eine neue Goldader im {0}!
msg.wave_warn_two = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden teilen sich auf und fallen von {1} und von {2} ein. msg.wave_warn_two = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden teilen sich auf und fallen von {1} und von {2} ein.
dir.n = Norden dir.n = Norden
dir.ne = Nordosten dir.ne = Nordosten
@@ -339,7 +379,8 @@ dir.w = Westen
dir.nw = Nordwesten dir.nw = Nordwesten
val.auto = Auto ({0}x) val.auto = Auto ({0}x)
set.render_scale = 3D-Auflösung set.render_scale = Pixel-Look
set.pixel_rows_val = {0} Zeilen
set.debug_fps = FPS anzeigen set.debug_fps = FPS anzeigen
set.debug_frame_time = Framezeit anzeigen set.debug_frame_time = Framezeit anzeigen
set.debug_ping = Ping anzeigen set.debug_ping = Ping anzeigen
@@ -397,8 +438,25 @@ help.start_touch = Tippen zum Starten
tool.bridge.label = Brücke tool.bridge.label = Brücke
tool.bridge.name = Brücke bauen tool.bridge.name = Brücke bauen
tool.bridge.desc = Baut eine Brücke über Wasser oder Lava. Ziehe von deinem Gebiet aus über den Fluss; die Brücke wächst Feld für Feld von deinem Reich aus. tool.bridge.desc = Baut eine Brücke über Wasser oder Lava. Ziehe von deinem Gebiet aus über den Fluss; die Brücke wächst Feld für Feld von deinem Reich aus.
cost.bridge = {0} Gold/Feld über Wasser, {1} über Lava tool.drawbridge.label = Zugbrücke
tool.drawbridge.name = Zugbrücke bauen
tool.drawbridge.desc = Eine Brücke, die du hochziehen kannst: Ziehe über den Fluss, um sie zu bauen, klicke sie an zum Umschalten: unten, oben, automatisch (hebt sich, wenn Feinde nahen). Holz über Wasser brennt, Stein über Lava nicht.
tool.wall.label = Wand
tool.wall.name = Wand aufrichten
tool.wall.desc = Plane Wände auf deinem eigenen leeren Boden (nochmal ziehen hebt den Plan auf): Imps mauern sie als verstärkte Erde. Schließe Gänge und setze Türen dorthin, wo sie zählen.
tool.ram.label = Ramme
tool.ram.name = Rammbock bauen
tool.ram.desc = Ein Rammbock rollt aus deiner Schmiede (Gold und Holz). Er folgt deiner Angriffsflagge und zertrümmert Tor und Türme der Ritter.
tool.catapult.label = Katapult
tool.catapult.name = Katapult bauen
tool.catapult.desc = Ein Katapult rollt aus deiner Schmiede (Gold und Holz). Es beschießt die Türme der Ritter aus der Ferne und folgt deiner Angriffsflagge zu ihrem Tor.
cost.bridge = {0} Gold + {1} Holz/Kachel über Wasser, {2} Gold über Lava
cost.drawbridge = {0} Gold + {1} Holz/Kachel über Wasser, {2} Gold über Lava
cost.gold_wood = {0} Gold + {1} Holz
hint.bridge_where = Brücken müssen an deinem Gebiet beginnen: Ziehe von deinem Reich aus über den Fluss. hint.bridge_where = Brücken müssen an deinem Gebiet beginnen: Ziehe von deinem Reich aus über den Fluss.
hint.bridge_fire = Holzbrücken brennen: Halte Feuerfallen, Feueratem und Blitze von ihnen fern – Steinbrücken über Lava sind feuerfest.
hint.wall_where = Wände lassen sich nur auf deinem eigenen leeren Boden aufrichten (kein Raum, keine Falle, keine Tür).
hint.siege_where = Belagerungsgerät rollt auf deinem eigenen freien Boden aus – und du brauchst eine Schmiede.
hint.bridge_river = Brücken lassen sich nur über Wasser oder Lava bauen. hint.bridge_river = Brücken lassen sich nur über Wasser oder Lava bauen.
msg.hero_burned = Ein {0} ist in die Lava gefallen! +{1} Gold. msg.hero_burned = Ein {0} ist in die Lava gefallen! +{1} Gold.
msg.creature_burned = Dein {0} ist in die Lava gefallen. msg.creature_burned = Dein {0} ist in die Lava gefallen.
@@ -408,6 +466,22 @@ tile.lava = Lava
tile.lava_info = Was hineinfällt, verbrennt. Steinbrücken führen hinüber. tile.lava_info = Was hineinfällt, verbrennt. Steinbrücken führen hinüber.
tile.bridge = Holzbrücke tile.bridge = Holzbrücke
tile.lavabridge = Steinbrücke tile.lavabridge = Steinbrücke
tile.mushroom = Riesenpilz
tile.mushroom_grown = Ausgewachsen: bereit zum Fällen.
tile.mushroom_growing = Wächst: {0}% - erst ausgewachsen kann er gefällt werden.
tile.grove = Pilzhain
tile.grove_info = Dieses Moos kann niemand erobern. Hier wachsen Riesenpilze nach.
tile.tree = Baum
tile.plant_info = Mit der Spitzhacke markieren: Ein Imp fällt ihn für {0} Holz.
tile.drawbridge = Zugbrücke
tile.drawbridge_up = Hochgezogen
tile.drawbridge_down = Herabgelassen
tile.drawbridge_auto = Automatisch (unten)
tile.drawbridge_auto_up = Automatisch (oben)
tile.obsidian = Obsidian
tile.obsidian_info = Erkaltete Lava – schmilzt in {0} s wieder
tile.bridge_burning = Brennt!
tile.bridge_worn = Beschädigt {0}%
tile.shard = Splitter deines Seelenkerns tile.shard = Splitter deines Seelenkerns
tile.shard_info = Erobere diese Kammer, um den Splitter zurückzuholen. tile.shard_info = Erobere diese Kammer, um den Splitter zurückzuholen.
@@ -429,6 +503,11 @@ msg.enemy_tower_destroyed = Ein Ritter-{0} ist gefallen!
msg.vault_taken = Ritterschatz erobert: Schätze im Wert von {0} gehören dir. msg.vault_taken = Ritterschatz erobert: Schätze im Wert von {0} gehören dir.
msg.shard_taken = Ein Splitter deines Seelenkerns ist wieder in deiner Hand! msg.shard_taken = Ein Splitter deines Seelenkerns ist wieder in deiner Hand!
msg.bridge_torn = Die Ritter haben eine deiner Brücken eingerissen! msg.bridge_torn = Die Ritter haben eine deiner Brücken eingerissen!
msg.bridge_fire = Eine deiner Brücken brennt!
msg.drawbridge_up = Die Zugbrücke ist hochgezogen.
msg.drawbridge_down = Die Zugbrücke ist herabgelassen.
msg.drawbridge_auto = Die Zugbrücke hebt sich von selbst, wenn Feinde nahen.
msg.drawbridge_rises = Feinde nahen: Eine Zugbrücke hebt sich!
job.tear_bridge = Reißt eine Brücke ein job.tear_bridge = Reißt eine Brücke ein
job.patrol = Auf Patrouille job.patrol = Auf Patrouille
job.guard = Hält Wache job.guard = Hält Wache
@@ -532,7 +611,7 @@ camp.m29.story = Lavainseln, Meister, mit kochenden Manaquellen in der Mitte, un
camp.m20.title = Der Großmeister camp.m20.title = Der Großmeister
camp.m20.story = Großmeister Valerian Hellschild erwartet dich in seiner Zitadelle jenseits des großen Flusses, mit dem letzten Splitter und dem Tor des Ordens. Hundert Jahre lang hat er gebetet, poliert und sich vorbereitet. | Zerstöre sein Tor und hol dir den Splitter. Dann ist dein Kern ganz - und jeder Meister, der ihn je haben wollte, wird auf einmal danach greifen. Eins nach dem anderen. camp.m20.story = Großmeister Valerian Hellschild erwartet dich in seiner Zitadelle jenseits des großen Flusses, mit dem letzten Splitter und dem Tor des Ordens. Hundert Jahre lang hat er gebetet, poliert und sich vorbereitet. | Zerstöre sein Tor und hol dir den Splitter. Dann ist dein Kern ganz - und jeder Meister, der ihn je haben wollte, wird auf einmal danach greifen. Eins nach dem anderen.
camp.m30.title = Der Thron der Tiefe camp.m30.title = Der Thron der Tiefe
camp.m30.story = Es kam, wie ich gesagt habe: Dein Kern ist ganz, und jetzt wollen ihn alle. Mossgrim und Lady Nightwell marschieren gemeinsam, und was vom Orden übrig ist, öffnet ein letztes Mal sein Tor. | Zerstöre beide Meisterkerne und das Tor des Ordens. Danach hat die Tiefe genau einen Meister. Rate, wen. Kleiner Tipp: nicht mich, ich bin der Teemann. camp.m30.story = Es kam, wie ich sagte: Dein Kern ist ganz, und jetzt will ihn jeder. Mossgrim und Lady Nightwell haben sich bis an die Oberfläche gegraben, und der Hochmarschall wartet in seiner Festung im Wald – Tageslicht, Vögel, das volle Programm. Widerlich. | Steig aus dem Fels, zerstöre beide Meisterkerne und das Tor des Ordens in der Festung. Danach hat die Welt genau einen Meister. Rate mal, wen. Tipp: nicht mich, ich bin der Teemann.
camp.epilogue = Der grüne Kern zerbröselt, der blaue erlischt, und das Tor des Ordens verstummt. Irgendwo dort oben schreibt die Lichtwacht einen sehr langen Bericht, und Lady Nightwells Briefe bleiben aus. | Krummfinger hat Tee gekocht. Du bist wieder ganz, Meister. Das Unterreich gehört dir. camp.epilogue = Der grüne Kern zerbröselt, der blaue erlischt, und das Tor des Ordens verstummt. Irgendwo dort oben schreibt die Lichtwacht einen sehr langen Bericht, und Lady Nightwells Briefe bleiben aus. | Krummfinger hat Tee gekocht. Du bist wieder ganz, Meister. Das Unterreich gehört dir.
lair.arrivals = {0} freie Plätze: Neue Kreaturen kommen mit {1}% Tempo lair.arrivals = {0} freie Plätze: Neue Kreaturen kommen mit {1}% Tempo
job.help = Hilft mit job.help = Hilft mit
@@ -656,10 +735,15 @@ net.error = {0}
net.rooms = Offene Partien net.rooms = Offene Partien
net.no_rooms = Keine offenen Partien. net.no_rooms = Keine offenen Partien.
net.create = Partie erstellen net.create = Partie erstellen
net.map_size = Karte {0}x{1} · {2} Spieler
net.coop_mission = Koop-Kampagne: Mission {0} ({1}) net.coop_mission = Koop-Kampagne: Mission {0} ({1})
net.coop_create = Koop-Kampagne net.coop_create = Koop-Kampagne
net.coop_editor = Editor-Kampagne net.coop_editor = Editor-Kampagne
net.coop_builtin = Eingebaute Kampagne net.coop_builtin = Eingebaute Kampagne
net.coop_league = Bund gegen die Lichtwacht
net.coop_solo = KI-Partner
net.coop_solo_started = Koop ohne Server: Dein KI-Partner baut einen eigenen Kerker, folgt deiner Angriffsflagge und hilft mit Gold aus.
net.coop_resume = Weiter: M{0}
net.coop_editor_mission = Editor-Kampagne {0}: Mission {1} ({2}) net.coop_editor_mission = Editor-Kampagne {0}: Mission {1} ({2})
net.coop_editor_none = Keine Mission der Editor-Kampagne passt für Koop (Karte mindestens 72x72, ein Ziel außer Rivalen-Meistern) net.coop_editor_none = Keine Mission der Editor-Kampagne passt für Koop (Karte mindestens 72x72, ein Ziel außer Rivalen-Meistern)
net.join = Beitreten net.join = Beitreten
@@ -690,6 +774,9 @@ net.ai_off = Aus
# rival masters (free game) # rival masters (free game)
newgame.difficulty = Gegner newgame.difficulty = Gegner
newgame.land = Gelände
newgame.land0 = Unterirdisch
newgame.land1 = Halb über der Erde
diff.0 = Leicht diff.0 = Leicht
diff.1 = Mittel diff.1 = Mittel
diff.2 = Schwer diff.2 = Schwer
@@ -702,9 +789,14 @@ msg.rival1_attack = Mossgrim, der grüne Meister, schickt {0} Kreaturen gegen di
msg.rival2_attack = Lady Nightwell, die blaue Meisterin, schickt {0} Kreaturen gegen dich! msg.rival2_attack = Lady Nightwell, die blaue Meisterin, schickt {0} Kreaturen gegen dich!
msg.rival1_out = Mossgrim ist besiegt - sein Seelenkern ist zersplittert! msg.rival1_out = Mossgrim ist besiegt - sein Seelenkern ist zersplittert!
msg.rival2_out = Lady Nightwell ist besiegt - ihr Seelenkern ist zersplittert! msg.rival2_out = Lady Nightwell ist besiegt - ihr Seelenkern ist zersplittert!
msg.rival3_attack = Saffrax, der gelbe Meister, schickt {0} Kreaturen gegen dich!
msg.rival3_out = Saffrax ist besiegt - sein Seelenkern ist zersplittert!
msg.rival_unit_slain = Ein {0} eines Rivalen fällt! +{1} Gold. msg.rival_unit_slain = Ein {0} eines Rivalen fällt! +{1} Gold.
msg.wave_on_rival1 = Welle {0}: {1} Helden ziehen gegen den grünen Meister. msg.wave_on_rival1 = Welle {0}: {1} Helden ziehen gegen den grünen Meister.
msg.wave_on_rival2 = Welle {0}: {1} Helden ziehen gegen den blauen Meister. msg.wave_on_rival2 = Welle {0}: {1} Helden ziehen gegen den blauen Meister.
msg.wave_warn_on_master = Späher melden: Heldenwelle {0} kommt in 30 Sekunden – sie zieht gegen Meister {1}.
msg.wave_on_master = Welle {0}: {1} Helden ziehen gegen Meister {2}.
msg.partner_out = Das Herz deines KI-Partners ist gefallen – kämpfe allein weiter!
hud.rival1 = Grün hud.rival1 = Grün
hud.rival2 = Blau hud.rival2 = Blau
msg.creature_freed = Ein {0} sprengt seine Ketten und schließt sich dir an! msg.creature_freed = Ein {0} sprengt seine Ketten und schließt sich dir an!
@@ -793,6 +885,8 @@ best.b.torture.text = Gefangene Helden landen hier. Mit der Zeit und einem Kerke
best.b.temple.text = Gebete heilen den Seelenkern und heben die Stimmung. Die Flammenpriesterin kommt für einen Tempel. best.b.temple.text = Gebete heilen den Seelenkern und heben die Stimmung. Die Flammenpriesterin kommt für einen Tempel.
best.b.aerie.text = Sitzstangen und Nester für Flieger. Fledermäuse, Gargoyles, Eulen und sogar der Glutlindwurm brauchen hier Platz. best.b.aerie.text = Sitzstangen und Nester für Flieger. Fledermäuse, Gargoyles, Eulen und sogar der Glutlindwurm brauchen hier Platz.
best.b.bridge.text = Holz über Wasser, Stein über Lava. Die Ritter reißen deine Brücken ein - nimm dir ihre. best.b.bridge.text = Holz über Wasser, Stein über Lava. Die Ritter reißen deine Brücken ein - nimm dir ihre.
best.b.drawbridge.text = Hochgezogen ist der Fluss wieder eine Mauer. Auf Automatik hebt sie sich, sobald Feinde nahen – und senkt sich für deine eigenen Kreaturen.
best.b.wall.text = Ein gegrabener Gang ist nicht für immer: Mauere ihn wieder zu und lass den Helden nur einen Weg hinein – durch deine Tür.
best.b.steel.text = Genietete Eisenplatten auf einer verstärkten Mauer. Da gräbt sich keiner schnell durch, nicht einmal Zwerge. best.b.steel.text = Genietete Eisenplatten auf einer verstärkten Mauer. Da gräbt sich keiner schnell durch, nicht einmal Zwerge.
best.b.arrow.text = Der Pfeil durchbohrt bis zu zwei weitere Feinde hinter dem Ziel. Billig und zuverlässig. best.b.arrow.text = Der Pfeil durchbohrt bis zu zwei weitere Feinde hinter dem Ziel. Billig und zuverlässig.
best.b.cannon.text = Flächenschaden und Rückstoß - schieb die Ritter in die Lava. best.b.cannon.text = Flächenschaden und Rückstoß - schieb die Ritter in die Lava.
@@ -804,7 +898,7 @@ room.forge = Schmiede
tool.forge.label = Schmiede tool.forge.label = Schmiede
tool.forge.desc = Kreaturen fertigen hier Fallen und Türen - Arbeit, kein Gold. Fertige Stücke warten in Kisten, bis ein Imp sie einbaut. Coglinge kommen für eine Schmiede ab 6 Feldern. tool.forge.desc = Kreaturen fertigen hier Fallen und Türen - Arbeit, kein Gold. Fertige Stücke warten in Kisten, bis ein Imp sie einbaut. Coglinge kommen für eine Schmiede ab 6 Feldern.
unit.cogling = Cogling unit.cogling = Cogling
cat.traps = Fallen cat.traps = Bauen
cost.work = {0} Arbeit in der Schmiede cost.work = {0} Arbeit in der Schmiede
job.forge = Schmiedet job.forge = Schmiedet
job.install = Baut eine Falle ein job.install = Baut eine Falle ein
@@ -881,6 +975,7 @@ research.now = Erforscht: {0} ({1}%)
research.next = danach: {0} research.next = danach: {0}
research.done = Hier gibt es nichts mehr zu erforschen. research.done = Hier gibt es nichts mehr zu erforschen.
msg.researched = Erforscht: {0}! Jetzt im Baumenü. msg.researched = Erforscht: {0}! Jetzt im Baumenü.
msg.room_upgraded = Forschung: {0} erreicht Stufe {1} – arbeitet ein Viertel besser, und man sieht es.
msg.tool_research = Noch nicht erforscht - Kreaturen in der Bibliothek arbeiten daran. msg.tool_research = Noch nicht erforscht - Kreaturen in der Bibliothek arbeiten daran.
msg.script = {0} msg.script = {0}
@@ -918,6 +1013,9 @@ tool.plague.desc = Eine Krankheit, die an den Feinden rund um die Stelle zehrt u
tool.quake.label = Beben tool.quake.label = Beben
tool.quake.name = Beben tool.quake.name = Beben
tool.quake.desc = Der Boden bebt: Feinde rund um die Stelle werden verletzt und zwei Sekunden betäubt. Deine Kreaturen bleiben standfest. tool.quake.desc = Der Boden bebt: Feinde rund um die Stelle werden verletzt und zwei Sekunden betäubt. Deine Kreaturen bleiben standfest.
tool.freeze.label = Frost
tool.freeze.name = Frost
tool.freeze.desc = Eisige Kälte: Lava rund um die Stelle erkaltet für 40 Sekunden zu begehbarem Obsidian, Feinde werden unterkühlt und verlangsamt.
tile.unexplored = Unerforschte Finsternis tile.unexplored = Unerforschte Finsternis
# personality # personality
@@ -970,6 +1068,9 @@ tool.arena.desc = Kreaturen kämpfen paarweise zur Übung: doppelt so viel Erfah
tool.altar.label = Altar tool.altar.label = Altar
tool.altar.desc = Lass eine Kreatur auf den Altar fallen, um sie zu opfern. Bestimmte Opferpaare bringen eine seltene Kreatur, sonst wird das Verlies gesegnet: bessere Laune und Mana. tool.altar.desc = Lass eine Kreatur auf den Altar fallen, um sie zu opfern. Bestimmte Opferpaare bringen eine seltene Kreatur, sonst wird das Verlies gesegnet: bessere Laune und Mana.
unit.revenant = Wiedergänger unit.revenant = Wiedergänger
unit.cavebear = Höhlenbär
unit.beetle = Riesenkäfer
unit.trader = Händler
job.guard_post = Hält Wache job.guard_post = Hält Wache
job.spar = Übungskampf job.spar = Übungskampf
msg.revenant_rises = Ein Wiedergänger erhebt sich vom Friedhof und dient dir! msg.revenant_rises = Ein Wiedergänger erhebt sich vom Friedhof und dient dir!
@@ -978,6 +1079,10 @@ msg.altar_gift = Der Altar nimmt das Paar an: ein {0} erscheint!
msg.altar_blessing = {0} wird geopfert. Das Verlies ist gesegnet. msg.altar_blessing = {0} wird geopfert. Das Verlies ist gesegnet.
best.revenant.lure = erhebt sich vom Friedhof best.revenant.lure = erhebt sich vom Friedhof
best.revenant.text = Ein Wiedergänger isst nicht, schläft nicht und beschwert sich nicht - der perfekte Angestellte, wenn man den Geruch aushält. | Mit etwas Erfahrung trinkt er die Kraft derer, die er trifft. Mit der Priesterin versteht er sich aus naheliegenden Gründen nicht. best.revenant.text = Ein Wiedergänger isst nicht, schläft nicht und beschwert sich nicht - der perfekte Angestellte, wenn man den Geruch aushält. | Mit etwas Erfahrung trinkt er die Kraft derer, die er trifft. Mit der Priesterin versteht er sich aus naheliegenden Gründen nicht.
best.cavebear.lure = in seinem Bau besiegen, solange du ein Gefängnis hast
best.cavebear.text = Ein Bär, der nie die Sonne gesehen hat und sie nicht vermisst. Zottelig, stark und bis zum Frühstück schlecht gelaunt. | Besiege ihn in seinem Bau, solange ein Gefängnis Platz hat, und er ergibt sich und schließt sich dir an. Er mag den Pilzgarten – und Spinnen, als Snack.
best.beetle.lure = in seinem Bau besiegen, solange du ein Gefängnis hast
best.beetle.text = Ein Käfer so groß wie ein Fass, gepanzert wie ein Ritter und doppelt so höflich. | Besiege ihn in seinem Bau, solange ein Gefängnis Platz hat, und er schließt sich dir an. Er schläft im Lager und kann Fledermäuse nicht ausstehen.
best.b.prison.text = Zellenreihen. Helden verrotten hier in Ruhe, bis jemand entscheidet, was mit ihnen geschieht. best.b.prison.text = Zellenreihen. Helden verrotten hier in Ruhe, bis jemand entscheidet, was mit ihnen geschieht.
best.b.graveyard.text = Moos, Steine und frische Erde. Hier unten bleibt niemand lange tot. best.b.graveyard.text = Moos, Steine und frische Erde. Hier unten bleibt niemand lange tot.
best.b.guardpost.text = Ein Feuerbecken, ein Banner und eine gelangweilte Kreatur mit scharfen Augen. best.b.guardpost.text = Ein Feuerbecken, ein Banner und eine gelangweilte Kreatur mit scharfen Augen.
@@ -990,23 +1095,39 @@ ability.drain.desc = heilt sich um ein Drittel des Schadens, den er austeilt.
unit.giant = Riese unit.giant = Riese
unit.fairy = Feenspion unit.fairy = Feenspion
unit.battlemage = Kampfmagier unit.battlemage = Kampfmagier
unit.thief = Dieb
unit.monk = Mönch
unit.ranger = Waldläufer
unit.inquisitor = Inquisitor
unit.ram = Rammbock
unit.catapult = Katapult
msg.trap_wrecked = Die Helden haben eine Falle zerstört: {0}. msg.trap_wrecked = Die Helden haben eine Falle zerstört: {0}.
best.giant.text = Ein Riese ist, was passiert, wenn ein Ritter dreihundert Jahre lang sein Gemüse isst. Langsam, gewaltig, und Türen hält er für einen Vorschlag. | Fallen zertrümmert er, indem er einfach danebensteht. Widerliche Manieren. Bearbeite ihn mit allem, am besten aus der Ferne. best.giant.text = Ein Riese ist, was passiert, wenn ein Ritter dreihundert Jahre lang sein Gemüse isst. Langsam, gewaltig, und Türen hält er für einen Vorschlag. | Fallen zertrümmert er, indem er einfach danebensteht. Widerliche Manieren. Bearbeite ihn mit allem, am besten aus der Ferne.
best.fairy.text = Der kleine Spitzel des Ordens: eine leuchtende Mücke mit Flügeln, vorausgeschickt, um deine Fallen zu finden und es allen zu erzählen. Hat sie eine Falle gesehen, schlagen die Helden sie kaputt. | Außerdem streut sie Heilstaub auf die Helden. Erschlag sie zuerst. Zweimal, sicherheitshalber. best.fairy.text = Der kleine Spitzel des Ordens: eine leuchtende Mücke mit Flügeln, vorausgeschickt, um deine Fallen zu finden und es allen zu erzählen. Hat sie eine Falle gesehen, schlagen die Helden sie kaputt. | Außerdem streut sie Heilstaub auf die Helden. Erschlag sie zuerst. Zweimal, sicherheitshalber.
best.battlemage.text = Ein Magier in der Robe des Ordens, der Mauern als persönliche Beleidigung empfindet. Verstärkte Wände zerplatzen unter seinen Feuerbällen, und er hört nicht auf, davon zu reden. | Aus der Nähe zerbrechlich. Stahlwände ärgern ihn am meisten - gut so. best.battlemage.text = Ein Magier in der Robe des Ordens, der Mauern als persönliche Beleidigung empfindet. Verstärkte Wände zerplatzen unter seinen Feuerbällen, und er hört nicht auf, davon zu reden. | Aus der Nähe zerbrechlich. Stahlwände ärgern ihn am meisten - gut so.
best.thief.text = Flinke Finger, noch flinkere Füße. Er kommt nicht zum Kämpfen: Er schleicht in deine Schatzkammer, füllt seinen Sack und rennt zurück zum Tor. | Erwisch ihn, bevor er entkommt, und das Gold gehört wieder dir - mit Zinsen.
best.monk.text = Jahre des Fastens und Betens haben ihn taub für deine Magie gemacht: Blitz, Seuche und Frost perlen an ihm ab wie Regen am Dach. | Fäuste tun auch weh. Begegne ihm mit Stahl, Klauen und Zähnen.
best.ranger.text = Ein Waldmann des Ordens, der aus der Ferne schießt und eiserne Fallen in deinen Gängen auslegt. Wer hineintritt, wird verletzt und steckt eine Weile fest. | Die Fallen bleiben, bis jemand hineintritt. Lieber ein Kobold als dein Oger.
best.inquisitor.text = Gebrechlich, aber seine Predigten sind gefährlich: Alle zwanzig Sekunden redet er eine deiner schwächsten Kreaturen - einen Kobold oder eine Spinne - auf die Seite der Helden. | Starke Kreaturen lachen ihn nur aus. Schlag zuerst zu.
best.ram.text = Ein Baumstamm mit Eisenkappe und einem Dach gegen Pfeile. Deine Kreaturen sind ihm egal – nur deine Türen, Wände und Türme nicht. | Halte ihn auf, bevor er die Tür erreicht, oder besorg dir eine neue.
best.catapult.text = Ein Holzarm, ein Eimer Steine und ein Ritter mit zu viel Zeit. Es schießt weiter als deine Türme und nimmt sie zuerst ins Visier. | Stürm drauf los: aus der Nähe tut es keiner Fliege etwas.
# orders # orders
tool.order_attack.label = Angriff tool.order_attack.label = Angriff
tool.order_attack.name = Angriff! tool.order_attack.name = Angriff!
tool.order_attack.desc = Klicke einen Feind oder einen feindlichen Turm an: Alle kampfbereiten Kreaturen im Umkreis von 18 Feldern gehen darauf los. Rechtsklick mit einem Befehlswerkzeug hebt die Wachbefehle auf. tool.order_attack.desc = Klicke einen Feind oder einen feindlichen Turm an: Alle kampfbereiten Kreaturen im Umkreis von 18 Feldern gehen darauf los. Klicke freien Boden an: ein Schwert-Marker – die kampfbereiten Kreaturen verteilen sich auf alle Schwerter und kämpfen dort. Rechtsklick auf einen Marker entfernt ihn.
tool.order_guard.label = Bewachen tool.order_guard.label = Bewachen
tool.order_guard.name = Raum bewachen tool.order_guard.name = Raum bewachen
tool.order_guard.desc = Klicke einen Raum an: Bis zu drei Kreaturen in der Nähe beziehen dort für zwei Minuten Stellung und greifen alles an, was in Sicht kommt. Rechtsklick hebt auf. tool.order_guard.desc = Klicke deinen eigenen Boden an: ein Schild-Marker, den drei Kreaturen halten und verteidigen. Bis zu acht Schilde; Rechtsklick auf einen Schild entfernt ihn.
hint.order_attack = Klicke einen Feind oder einen feindlichen Turm an. hint.order_attack = Klicke einen Feind oder einen feindlichen Turm an.
hint.order_guard = Klicke einen deiner Räume (oder deinen Boden) an. hint.order_guard = Klicke einen deiner Räume (oder deinen Boden) an.
hint.order_nobody = Keine Kreatur ist für diesen Befehl frei. hint.order_nobody = Keine Kreatur ist für diesen Befehl frei.
msg.order_attack = {0} Kreaturen greifen an! msg.order_attack = {0} Kreaturen greifen an!
msg.order_guard = {0} Kreaturen beziehen Stellung. msg.order_guard = {0} Kreaturen beziehen Stellung.
msg.mark_attack = Ein Schwert markiert die Stelle: Deine kampfbereiten Kreaturen marschieren hin.
msg.mark_guard = Ein Schild markiert die Stelle: Drei Kreaturen halten sie.
msg.mark_removed = Marker entfernt – seine Kreaturen sind wieder frei.
hint.marks_full = Mehr als acht Marker dieser Art gehen nicht. Entferne einen mit Rechtsklick.
msg.orders_cancelled = Wachbefehle aufgehoben. msg.orders_cancelled = Wachbefehle aufgehoben.
# creature overview # creature overview
@@ -1016,6 +1137,8 @@ ov.summary = {0} Kreaturen - Lohn {1} Gold pro Zahltag - Laune {2}%
ov.none = Noch keine Kreaturen. Baue ein Quartier und einen Pilzgarten und nimm ein Portal ein. ov.none = Noch keine Kreaturen. Baue ein Quartier und einen Pilzgarten und nimm ein Portal ein.
ov.hint = St., Laune, Lohn, Tätigkeit, Leben - Klick: hingehen ov.hint = St., Laune, Lohn, Tätigkeit, Leben - Klick: hingehen
set.voice = Krummfingers Stimme set.voice = Krummfingers Stimme
set.track = Musikstück
val.track_auto = Automatisch
# ---- campaign editor, block scripts, demo campaign # ---- campaign editor, block scripts, demo campaign
menu.back_editor = Zurück zum Editor menu.back_editor = Zurück zum Editor
@@ -1088,6 +1211,7 @@ ed.forts = Ritterfestungen
ed.rivals = Rivalen-Meister ed.rivals = Rivalen-Meister
ed.difficulty = Schwierigkeit ed.difficulty = Schwierigkeit
ed.portals = Portale ed.portals = Portale
ed.surface = Oberwelt (Osten)
ed.edit_map = Karte bearbeiten ed.edit_map = Karte bearbeiten
ed.make_random = Zur Zufallskarte machen ed.make_random = Zur Zufallskarte machen
ed.paint_map = Karte erzeugen und malen ed.paint_map = Karte erzeugen und malen
@@ -1167,6 +1291,10 @@ ed.terrain.silver = Silberader
ed.terrain.ruby = Rubinader ed.terrain.ruby = Rubinader
ed.terrain.diamond = Diamantader ed.terrain.diamond = Diamantader
ed.terrain.steel = Stahlwand ed.terrain.steel = Stahlwand
ed.terrain.mushroom = Riesenpilz
ed.terrain.grove = Pilzhain
ed.terrain.grass = Gras (Oberwelt)
ed.terrain.tree = Baum
ed.place.core = Seelenkern ed.place.core = Seelenkern
ed.place.portal = Portal ed.place.portal = Portal
ed.place.gate = Heldentor ed.place.gate = Heldentor
@@ -1197,6 +1325,7 @@ ed.level = Stufe
ed.zone_shape = Form einer neuen Zone ed.zone_shape = Form einer neuen Zone
ed.zone_new_name = Name einer neuen Zone ed.zone_new_name = Name einer neuen Zone
ed.zones = Zonen ed.zones = Zonen
ed.zone_erase = Kacheln entfernen
ed.shape.rect = Rechteck ed.shape.rect = Rechteck
ed.shape.circle = Kreis ed.shape.circle = Kreis
ed.shape.tiles = Kacheln ed.shape.tiles = Kacheln
@@ -1246,6 +1375,8 @@ blk.reveal = Aufdecken
blk.camera = Zeigen blk.camera = Zeigen
blk.gold = Gold + blk.gold = Gold +
blk.mana = Mana + blk.mana = Mana +
blk.weather = Wetter über der Erde
blk.talentxp = Talent-Erfahrung +
blk.if = Wenn blk.if = Wenn
blk.repeat = Wiederhole blk.repeat = Wiederhole
blk.trigger = Trigger schalten blk.trigger = Trigger schalten
@@ -1265,6 +1396,8 @@ blk.help.reveal = Hebt den Nebel über einer Zone.
blk.help.camera = Bewegt die Kamera zu einer Zone. blk.help.camera = Bewegt die Kamera zu einer Zone.
blk.help.gold = Gibt (oder nimmt) Gold. blk.help.gold = Gibt (oder nimmt) Gold.
blk.help.mana = Gibt (oder nimmt) Mana. blk.help.mana = Gibt (oder nimmt) Mana.
blk.help.weather = Setzt das Wetter über der Erde (heiter, Regen, Nebel oder Sturm) für so viele Sekunden; 0 hält es, bis ein anderer Block es ändert.
blk.help.talentxp = Gibt dem Spieler Erfahrung für die Meister-Talente.
blk.help.if = Führt die Blöcke darin nur aus, wenn die Bedingung gilt, sonst die unter "sonst". blk.help.if = Führt die Blöcke darin nur aus, wenn die Bedingung gilt, sonst die unter "sonst".
blk.help.repeat = Führt die Blöcke darin mehrmals aus. blk.help.repeat = Führt die Blöcke darin mehrmals aus.
blk.help.trigger = Schaltet einen Trigger ein (er darf erneut auslösen) oder aus. blk.help.trigger = Schaltet einen Trigger ein (er darf erneut auslösen) oder aus.
@@ -1287,6 +1420,7 @@ blk.v.or = oder
blk.v.not = nicht blk.v.not = nicht
blk.v.bool = Ja/Nein blk.v.bool = Ja/Nein
blk.v.eventkind = Auslöser ist ein blk.v.eventkind = Auslöser ist ein
blk.v.weatheris = Wetter über der Erde ist
blk.w_at = bei blk.w_at = bei
blk.w_by = um blk.w_by = um
blk.w_in = in blk.w_in = in
@@ -1335,6 +1469,7 @@ blk.until = Wiederhole bis
blk.dountil = Wiederhole, prüfe danach: bis blk.dountil = Wiederhole, prüfe danach: bis
blk.wait = Warte blk.wait = Warte
blk.w_seconds = Sekunden blk.w_seconds = Sekunden
blk.w_for = für
blk.help.until = Führt die Blöcke darin immer wieder aus, bis die Bedingung gilt - geprüft vor jedem Durchlauf; gilt sie schon, läuft der Inhalt gar nicht. blk.help.until = Führt die Blöcke darin immer wieder aus, bis die Bedingung gilt - geprüft vor jedem Durchlauf; gilt sie schon, läuft der Inhalt gar nicht.
blk.help.dountil = Führt die Blöcke darin aus, prüft dann die Bedingung und wiederholt, bis sie gilt - der Inhalt läuft mindestens einmal. blk.help.dountil = Führt die Blöcke darin aus, prüft dann die Bedingung und wiederholt, bis sie gilt - der Inhalt läuft mindestens einmal.
blk.help.wait = Hält dieses Skript einige Sekunden an, danach geht es mit dem nächsten Block weiter (auch in Schleifen). blk.help.wait = Hält dieses Skript einige Sekunden an, danach geht es mit dem nächsten Block weiter (auch in Schleifen).
@@ -1476,9 +1611,159 @@ coop.m19.boss = Der Hochmarschall ist gefallen! Seine Rüstung scheppert wie ein
coop.m19.t1 = Er wartet im Osten. Lasst ihn warten. Erst die Garde. coop.m19.t1 = Er wartet im Osten. Lasst ihn warten. Erst die Garde.
coop.m19.t2 = Wieder Ritter und Schützen. Dem Orden gehen die Ideen aus. coop.m19.t2 = Wieder Ritter und Schützen. Dem Orden gehen die Ideen aus.
coop.m20.title = Mossgrims Fall coop.m20.title = Mossgrims Fall
coop.m20.story = Ohne den Hochmarschall steht Mossgrim allein. Er hat die Gestalt eines Glutwurms angenommen und jede Kreatur versammelt, die ihm noch gehorcht.\n\nBeendet den Verräter. Beendet seine Armee. Und dann, Meister - Tee. coop.m20.story = Mit dem Hochmarschall ist Mossgrim allein – oben im Wald, in der Sonne, ausgerechnet. Er hat die Gestalt eines Glutwurms angenommen und jede Kreatur versammelt, die noch auf ihn hört.\n\nSteigt aus dem Fels und beendet den Verräter. Beendet sein Heer. Und dann, Meister – Tee.
coop.m20.start = Mossgrim, du alter Pilz. Der Bund lässt grüßen. coop.m20.start = Mossgrim, du alter Pilz. Der Bund lässt grüßen.
coop.m20.goal = Besiegt Mossgrim und alle seine Kreaturen. coop.m20.goal = Besiegt Mossgrim und alle seine Kreaturen.
coop.m20.boss = Mossgrim ist nicht mehr. Das Moos wächst nach. Er nicht. coop.m20.boss = Mossgrim ist nicht mehr. Das Moos wächst nach. Er nicht.
coop.m20.t1 = Der Wurm im Osten ist Mossgrim. Ja, er hat sich gehen lassen. coop.m20.t1 = Der Wurm im Osten ist Mossgrim. Ja, er hat sich gehen lassen.
coop.m20.t2 = Jede Kreatur, die ihr besiegt, kann diese Geschichte nicht mehr falsch erzählen. coop.m20.t2 = Jede Kreatur, die ihr besiegt, kann diese Geschichte nicht mehr falsch erzählen.
# achievements and statistics
menu.achievements = Erfolge
ach.count = {0} von {1} Erfolgen freigeschaltet · {2} Minuten gespielt
ach.hidden = Ein Geheimnis. Crookfinger verrät nichts.
msg.achievement = Erfolg freigeschaltet: {0}!
end.stats2 = Helden {0} · Kreaturen verloren {1} · Kacheln gegraben {2} · Gold {3} · Holz {4}
ach.heroes.name = Heldenschreck {0}
ach.heroes.desc = Besiege {0} Helden.
ach.bosses.name = Championbrecher {0}
ach.bosses.desc = Bring {0} Champions des Ordens zu Fall.
ach.dug.name = Tiefgräber {0}
ach.dug.desc = Grabe {0} Kacheln aus.
ach.gold.name = Hortmeister {0}
ach.gold.desc = Verdiene {0} Gold.
ach.wood.name = Holzfürst {0}
ach.wood.desc = Fälle {0} Holz.
ach.walls.name = Maurer {0}
ach.walls.desc = Richte {0} Wände auf.
ach.rooms.name = Architekt der Schatten {0}
ach.rooms.desc = Baue {0} Raumkacheln.
ach.spells.name = Dunkler Zauberer {0}
ach.spells.desc = Wirke {0} Zauber.
ach.slaps.name = Strenge Hand {0}
ach.slaps.desc = Gib deinen Kreaturen {0} Klapse. Sie lieben es. Vermutlich.
ach.traps.name = Fallenschmied {0}
ach.traps.desc = Gib {0} Fallen und Türen in der Schmiede in Auftrag.
ach.towers.name = Turmherr {0}
ach.towers.desc = Baue {0} Türme.
ach.drowned.name = Schwimmkurs {0}
ach.drowned.desc = Lass {0} Helden in Wasser oder Lava stürzen.
ach.burnt.name = Brückenbrenner {0}
ach.burnt.desc = Brenne {0} Brückenplanken nieder.
ach.siege.name = Belagerungsmeister {0}
ach.siege.desc = Baue {0} Belagerungsgeräte.
ach.captured.name = Kerkermeister {0}
ach.captured.desc = Nimm {0} Helden gefangen.
ach.drawbridge.name = Hoch damit {0}
ach.drawbridge.desc = Ziehe {0}-mal Zugbrücken hoch.
ach.wins.name = Eroberer {0}
ach.wins.desc = Gewinne {0} freie Spiele.
ach.missions.name = Splittersucher {0}
ach.missions.desc = Gewinne {0} Kampagnenmissionen.
ach.coop.name = Bundesmitglied {0}
ach.coop.desc = Gewinne {0} Koop-Missionen.
ach.minutes.name = Nachteule {0}
ach.minutes.desc = Spiele {0} Minuten lang.
ach.flawless.name = Kein Kratzer
ach.flawless.desc = Gewinne, ohne eine einzige Kreatur zu verlieren.
ach.speedrun.name = Bevor der Tee kalt wird
ach.speedrun.desc = Gewinne innerhalb von fünfzehn Minuten.
ach.campaign.name = Der eine Meister
ach.campaign.desc = Gewinne die letzte Mission der Kampagne.
ach.surface.name = Sonnenbrand
ach.surface.desc = Gewinne ein Spiel, das bis an die Oberfläche reicht.
ach.defeat.name = Tee und Mitgefühl
ach.defeat.desc = Verliere ein Spiel. Crookfinger setzt Wasser auf.
ach.legend.name = Lebende Legende
ach.legend.desc = Eine Kreatur erreicht Stufe zehn.
ach.wyrm.name = Hier seien Drachen
ach.wyrm.desc = Ein Glutwurm schließt sich deinem Kerker an.
ach.fullhand.name = Alle Hände voll
ach.fullhand.desc = Halte acht Einheiten gleichzeitig in der Hand.
# tutorial
menu.tutorial = Einführung
tut.name = Erste Schritte
tut.title = Crookfinger zeigt dir, wie es geht
tut.story = Du bist also der neue Meister. Klein, aber der Kern schlägt noch, und das zählt.\n\nIch zeige dir das Wichtigste: Graben, Räume, Kreaturen und was man mit Rittern macht. Tu, was ich sage, und niemandem passiert etwas. Außer den Rittern.
tut.start = Crookfinger: Erste Lektion – Graben. Nimm die Spitzhacke (Taste 1), zieh über die Erde östlich des Herzens, und deine Imps graben sie aus.
tut.obj1 = Grabe den Gang östlich des Herzens aus.
tut.dug = Crookfinger: Sieh an, eine Halle dahinter! Deine Imps beanspruchen den Boden von selbst. Öffne jetzt Räume und baue ein Lager auf deinem Boden – dort schlafen deine Kreaturen.
tut.obj2 = Baue ein Lager (Räume).
tut.lair = Crookfinger: Betten, erledigt. Kreaturen essen auch: Baue daneben einen Pilzgarten.
tut.obj3 = Baue einen Pilzgarten (Räume).
tut.farm = Crookfinger: Kost und Logis – jetzt schickt dir das Portal Kreaturen. Geduld, sie kommen von selbst. Warte auf zwei.
tut.obj4 = Warte, bis zwei Kreaturen durchs Portal gekommen sind.
tut.creatures = Crookfinger: Da sind sie! Ein wenig Ansporn schadet nie: Nimm die Hand (Taste 2) und klick eine Kreatur mit gedrückter Umschalttaste an (am Touchscreen: zweimal antippen), um ihr einen Klaps zu geben. Eine Weile arbeitet sie schneller.
tut.obj5 = Gib einer deiner Kreaturen einen Klaps (Hand, Umschalt + Klick).
tut.slap = Crookfinger: Ha! Hast du das gehört? Aus dem Osten kommt etwas. Mach dich bereit: Der Blitz (Zauber) trifft hart, und deine Kreaturen kämpfen von selbst.
tut.obj6 = Besiege die beiden Ritter.
tut.knights = Crookfinger: Zwei Ritter! Schick deine Kreaturen mit der Angriffsflagge (Befehle) oder triff sie mit dem Blitz.
tut.win = Crookfinger: Gut gemacht, Meister! Mehr ist es nicht. Na ja – fast. Den Rest erzählt die Kampagne. Ich setze Wasser auf.
# master talents
cat.master = Meister
talent.title = Meister-Talente
talent.points = Freie Talentpunkte: {0}
talent.next = {0}/{1} Erfahrung bis zum nächsten Punkt
talent.all = Alle Talente sind gelernt.
talent.builder = Baumeister
talent.warlord = Kriegsherr
talent.warlock = Hexer
talent.merchant = Händler
talent.builder.desc = Imps graben {0}% schneller, Räume kosten {1}% weniger.
talent.warlord.desc = Kreaturen schlagen {0}% härter zu und nehmen {1}% weniger Schaden.
talent.warlock.desc = Mana fließt {0}% schneller, Zauber kosten {1}% weniger.
talent.merchant.desc = Löhne sind {0}% niedriger, gelieferte Schätze sind {1}% mehr wert.
talent.none = Noch nicht gelernt.
talent.then = Nächste Stufe: {0}
msg.talent_point = Ein neuer Talentpunkt! Verteile ihn im Meister-Fenster.
msg.talent_learned = {0} Stufe {1} gelernt.
# weather above ground
weather.fair = Heiter
weather.rain = Regen
weather.fog = Nebel
weather.storm = Sturm
msg.weather_clear = Über der Erde klart es auf.
msg.weather_rain = Über der Erde beginnt es zu regnen - Feuer fängt nicht.
msg.weather_fog = Nebel zieht über das Land - draußen sieht jeder weniger.
msg.weather_storm = Über der Erde zieht ein Sturm auf! Blitze treffen Einheiten und Bäume.
msg.lightning_unit = Vom Blitz getroffen: {0}!
# creature names
names.imp = Krümel, Schnodder, Buddel, Matsch, Fussel, Knirps, Wühli, Schmutzi, Popel, Kratzer, Grummel, Bröckel, Zappel, Schlammi, Pfriem, Knubbel, Grabi, Rotzi, Wusel, Schaufel
names.kobold = Kläff, Zwick, Raffzahn, Krätz, Schnapp, Rostig, Zack, Beißer, Klau, Ratz, Fetz, Zausel, Kralle, Stänker, Wurz, Fiesling, Rempel, Zinken, Knick, Schrammi
names.spider = Krabbel, Wollbein, Spinnchen, Kitzel, Netti, Achtfuß, Fädchen, Gruselchen, Tüdel, Seidi, Webbi, Huschel, Krümelbein, Tekla, Spindel, Fangi, Zwirn, Klebi, Wuschel, Nadelchen
names.mage = Bartholomäus, Ignatius, Leopold, Theobald, Adalbert, Kunibert, Wendelin, Gottlieb, Hieronymus, Ottokar, Florian, Engelbert, Pankratius, Ruprecht, Konstantin, Eusebius, Sebaldus, Cornelius, Archibald, Waldemar
names.ogre = Grunz, Klotz, Bums, Brocken, Rumms, Dumpf, Wumme, Klopper, Trampel, Holzkopf, Plumps, Mops, Bolle, Wanst, Knüppel, Ochs, Rumpel, Batzen, Hauruck, Gorbo
names.jailer = Grimm, Riegel, Kettler, Schloss, Kerker, Eisenbart, Fessel, Bolzen, Streng, Gitter, Mürrisch, Haken, Strick, Schlüssel, Finster, Klirr, Stockmann, Wächter, Zwinger, Bitter
names.priestess = Glut, Flamma, Asche, Seraphine, Lodera, Feuria, Morwen, Brunhild, Ignia, Kriemhild, Scharlach, Funkia, Rabea, Lilith, Zunder, Isolde, Brennhild, Nyx, Vesper, Glimma
names.chainbreaker = Kettenbann, Ulfgar, Eisenseele, Brannok, Kragmor, Valdrek, Dorn, Gorath, Ungebunden, Rurik, Torvald, Hagen, Durnok, Sprengel, Brand, Wulfrich, Korrun, Arvak, Sigbert, Halvard
names.bat = Piepsi, Flatter, Fiepe, Schwirr, Echo, Flapp, Dämmer, Zischi, Zirp, Zähnchen, Wirbel, Flederli, Motti, Piep, Sausi, Baumel, Samtchen, Huschi, Quiek, Blitzi
names.gargoyle = Granit, Schiefer, Basalt, Grimmbert, Mörtel, Kiesel, Schotter, Feuerstein, Fuge, Geröll, Quarz, Bimsstein, Sims, Knorz, Steinbart, Grantig, Giebel, Wasserspeier, Meißel, Sockel
names.owl = Uhuwald, Weisling, Kiel, Bartholdo, Minerva, Tobias, Huhu, Eulalia, Federich, Hauch, Grübel, Älteste, Kauzi, Noctua, Eulbert, Pergament, Dämmer, Waldkauz, Professor, Plusch
names.wyrm = Vermithrax, Aschenmaul, Sengbart, Calderon, Ignivar, Glimmhorn, Drakkar, Pyrrhus, Obsidian, Glutmaul, Vulkanor, Brennzahn, Malgrath, Tyrax, Inferno, Schwefel, Kaldor, Fafnir, Ragnor, Zinderos
names.cogling = Rädchen, Zahnrad, Schräubchen, Ratsche, Bolzen, Getriebe, Tüftel, Dingsbums, Schlüssel, Ritzel, Klimper, Fummel, Niete, Surr, Kurbel, Drehwurm, Flansch, Klackerle, Ticktack, Nippel
names.revenant = Morgen, Gram, Aschgrau, Klage, Hohl, Trauer, Bleich, Kummer, Schemen, Moder, Grabmann, Dämmer, Gruft, Wehmut, Wache, Hügel, Leichentuch, Elegie, Neige, Öde
names.beast = Zottel, Brummbär, Bruno, Struppi, Mampf, Moosrücken, Brummel, Ursa, Knorr, Knacks, Klicker, Krabbelbert, Borste, Moos, Rumpel, Dornbusch, Zirpel, Grummler, Wuchtel, Schnuffel
names.human = Gerhard, Roland, Konrad, Albrecht, Tristan, Eckhart, Gawan, Lienhard, Parzival, Hugo, Bertram, Gottfried, Ortwin, Balduin, Reinhold, Wilfried, Iwein, Gilbert, Ruprecht, Dietrich
epithets.mid = Knochenbrecher, Schlammfuß, Rattenbeißer, Dreckgesicht, Kieselkauer, Schädelklopfer, Stinkefaust, Rostknöchel, Bauchboxer, Schienbeintreter, Schimmelbart, Dreckkralle, Rotzschleuder, Ohrenkauer, Kniebrecher, Wurmfresser
epithets.high = Königsschreck, Unheilsfaust, Schreckenszahn, Weltenbrecher, Heldenbann, Sturmfaust, Nachtgrauen, Seelenschnitter, Eisenherz, Burgenbrecher, Drachenbann, Höllenwanderer, Schattenfürst, Blutkrone, Grimmflut, Sternbrecher
name.epithet = {0} {1}
msg.creature_fallen = {0} ist gefallen. Ein Gedenkstein bewahrt den Namen.
tile.memorial = Gedenkstein
tile.memorial_info = Hier ruht {0} ({1}, Stufe {2}).
# hall of the fallen
hall.title = Halle der Gefallenen
hall.count = {0} gefallene Kreaturen in Erinnerung
hall.empty = Noch ist niemand gefallen. Möge es so bleiben.
hall.kills = {0} Helden erschlagen
ov.fallen = Gefallene ({0})
ov.living = Lebende
cause.slain = erschlagen von: {0}
cause.lava = in Lava verbrannt
cause.water = ertrunken
cause.snare = in eine Falle getreten
cause.lightning = vom Blitz getroffen
cause.fell = gefallen
+1770
View File
File diff suppressed because it is too large. Load diff
+292 -7
View File
@@ -53,17 +53,29 @@ 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_partial = Payday! {0} creatures paid ({1} gold), {2} got nothing and are angry!
msg.payday = Payday! {0} creatures received {1} gold. 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_boss = Wave {0}: a Paladin of Light leads {1} heroes into your realm!
boss.aldric = Sir Aldric the Unbending
boss.seraphine = Dame Seraphine of the Dawn
boss.halvard = Brother Halvard the Pious
boss.rothgar = Captain Rothgar Ironside
msg.boss_comes = A champion of the Order leads the wave: {0}!
msg.boss_slain = {0} has fallen! Loot: {1} gold and the plans of a rune forge tower.
msg.wave = Wave {0}: {1} heroes break through the gate! 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_boss = The gate is crumbling! The Gate Warden appears with {0} defenders!
msg.gate_defenders = The gate summons {0} defenders! msg.gate_defenders = The gate summons {0} defenders!
msg.level_up = {0} reaches level {1}. msg.level_up = {0} reaches level {1}.
msg.hero_slain = {0} defeated! Loot: {1} gold. msg.hero_slain = {0} defeated! Loot: {1} gold.
msg.thief_in_treasury = A thief is in your treasury!
msg.thief_escaped = A thief escaped with {0} gold!
msg.snare_caught = Your {0} stepped into a snare!
msg.converted = The inquisitor turned your {0} against you!
msg.creature_died = Your {0} has fallen. msg.creature_died = Your {0} has fallen.
msg.portal_claimed = The portal is yours! It attracts creatures. msg.portal_claimed = The portal is yours! It attracts creatures.
msg.flag_removed = Attack flag removed. Your creatures return. msg.flag_removed = Attack flag removed. Your creatures return.
msg.flag_set = Attack flag set! Battle-ready creatures gather there. msg.flag_set = Attack flag set! Battle-ready creatures gather there.
msg.creature_leaves = An unhappy {0} leaves your realm! msg.creature_leaves = An unhappy {0} leaves your realm!
msg.no_gold = Not enough gold! msg.no_gold = Not enough gold!
msg.no_wood = Not enough wood! Fell giant mushrooms (or trees) with the pick.
msg.siege_built = A {0} is ready – set the attack flag to send it.
msg.sold = Rooms sold: +{0} gold. msg.sold = Rooms sold: +{0} gold.
msg.no_mana_bolt = Not enough mana for Lightning. msg.no_mana_bolt = Not enough mana for Lightning.
msg.no_mana_heal = Not enough mana for Heal. msg.no_mana_heal = Not enough mana for Heal.
@@ -73,6 +85,7 @@ msg.no_mana_cost = Not enough mana ({0} needed).
hint.portal_no_lair = The portal is open, but without a lair no creatures will come. 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.no_farm = Creatures want to eat: build a mushroom garden.
hint.lair_full = All beds in the lair are taken. Enlarge your lair. hint.lair_full = All beds in the lair are taken. Enlarge your lair.
hint.creature_max = Your dungeon holds 100 creatures - no more come through the portal.
hint.gate_attack = Your creatures are attacking the Hero Gate! hint.gate_attack = Your creatures are attacking the Hero Gate!
hint.treasury_full = Your treasuries are full! Build more treasury tiles. hint.treasury_full = Your treasuries are full! Build more treasury tiles.
hint.flag_unreachable = Your creatures cannot reach the attack flag. hint.flag_unreachable = Your creatures cannot reach the attack flag.
@@ -124,6 +137,7 @@ ui.key = Key: {0}
# ---- HUD # ---- HUD
hud.gold = Gold hud.gold = Gold
hud.wood = Wood
hud.mana = Mana hud.mana = Mana
hud.core = Core hud.core = Core
hud.gate = Gate hud.gate = Gate
@@ -145,6 +159,7 @@ help.k_right = Right click Drop a unit (prisoners: also into the torture cha
help.k_slap = Shift+Left Hand: backhand own units, punch heroes on your ground help.k_slap = Shift+Left Hand: backhand own units, punch heroes on your ground
help.k_pan = Right drag Move the map (also WASD/arrows, screen edge) 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_rot = Middle drag Rotate and tilt the camera (also Q/E, R/F)
help.k_reset = Middle 2x click Reset angle and turn (the place stays)
help.k_zoom = Mouse wheel Zoom help.k_zoom = Mouse wheel Zoom
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Select tool help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Select tool
help.k_misc = P / Space Pause M Sound on/off +/- Volume help.k_misc = P / Space Pause M Sound on/off +/- Volume
@@ -237,6 +252,7 @@ set.lights = Light sources
set.ui_scale = Interface size set.ui_scale = Interface size
set.game_speed = Game speed set.game_speed = Game speed
set.hints = Hints set.hints = Hints
set.events = Random events
set.keys_hint = Click an action and press a key. Keys already in use are swapped. set.keys_hint = Click an action and press a key. Keys already in use are swapped.
set.press_key = Press a key for: {0} set.press_key = Press a key for: {0}
set.esc_cancel = Esc cancels set.esc_cancel = Esc cancels
@@ -248,6 +264,8 @@ val.medium = Medium
val.high = High val.high = High
val.normal = Normal val.normal = Normal
val.large = Large val.large = Large
val.small = Small
val.auto_plain = Auto
# ---- key binding names # ---- key binding names
key.cam_forward = Camera forward key.cam_forward = Camera forward
@@ -313,12 +331,30 @@ hint.tower_max = This tower is fully upgraded.
hint.tower_where = Towers can only be built on claimed, empty floor tiles. hint.tower_where = Towers can only be built on claimed, empty floor tiles.
hint.tower_mana = Your magic towers have run out of mana. hint.tower_mana = Your magic towers have run out of mana.
job.repair = Repairing a tower job.repair = Repairing a tower
job.mend_bridge = Mending a bridge
job.raise_wall = Raising a wall
job.march = Marching to a sword
job.attack_tower = Attacking a tower job.attack_tower = Attacking a tower
unit.jailer = Jailer unit.jailer = Jailer
unit.priestess = Flame Priestess unit.priestess = Flame Priestess
msg.hero_captured = {0} knocked out and taken prisoner! Loot: {1} gold. msg.hero_captured = {0} knocked out and taken prisoner! Loot: {1} gold.
msg.defected = {0} has defected to your side! msg.defected = {0} has defected to your side!
msg.beast_tamed = A wild {0} gives in and joins your dungeon!
gear.1 = iron gear
gear.2 = steel gear
gear.3 = rune gear
msg.gear_given = The forge hands {0} to a {1}.
msg.trader_arrives = A trader came through your portal: {0} for {1} gold. Click him with the hand to buy - he waits a minute.
msg.trader_sold = Bought: {0}. Pleasure doing business, says the trader.
msg.trader_left = The trader shrugs and leaves through the portal.
trader.info = Offers {0} for {1} gold (click with the hand)
trader.left = Leaves in {0} s
trader.rune = the plans of a rune forge tower
trader.wyrm = an ember wyrm
trader.chainbreaker = a chainbreaker
trader.wood = 30 wood
trader.mana = a potion that fills your mana
msg.prisoner_died = {0} died under torture. msg.prisoner_died = {0} died under torture.
job.carry = Carrying a prisoner job.carry = Carrying a prisoner
job.pray = Praying job.pray = Praying
@@ -329,6 +365,10 @@ info.tortured = being tortured
info.prison = Prisoners: {0}/{1} info.prison = Prisoners: {0}/{1}
msg.wave_warn_dir = Scouts report: hero wave {0} reaches the gate in 30 seconds! They plan to break in from the {1}. msg.wave_warn_dir = Scouts report: hero wave {0} reaches the gate in 30 seconds! They plan to break in from the {1}.
msg.event_quake = Earthquake! Tunnels in the {0} have caved in.
msg.event_lava = The lava overflows its banks in the {0}!
msg.event_flood = Flood! The river overflows its banks in the {0}.
msg.event_vein = The imps report a new gold vein in the {0}!
msg.wave_warn_two = Scouts report: hero wave {0} reaches the gate in 30 seconds! They split up and break in from the {1} and the {2}. msg.wave_warn_two = Scouts report: hero wave {0} reaches the gate in 30 seconds! They split up and break in from the {1} and the {2}.
dir.n = north dir.n = north
dir.ne = north-east dir.ne = north-east
@@ -340,7 +380,8 @@ dir.w = west
dir.nw = north-west dir.nw = north-west
val.auto = Auto ({0}x) val.auto = Auto ({0}x)
set.render_scale = 3D resolution set.render_scale = Pixel look
set.pixel_rows_val = {0} rows
set.debug_fps = Show FPS set.debug_fps = Show FPS
set.debug_frame_time = Show frame time set.debug_frame_time = Show frame time
set.debug_ping = Show ping set.debug_ping = Show ping
@@ -398,8 +439,25 @@ help.start_touch = Tap to start
tool.bridge.label = Bridge tool.bridge.label = Bridge
tool.bridge.name = Build Bridge tool.bridge.name = Build Bridge
tool.bridge.desc = Builds a bridge over water or lava. Drag from your own ground across the river; the bridge grows tile by tile from your territory. tool.bridge.desc = Builds a bridge over water or lava. Drag from your own ground across the river; the bridge grows tile by tile from your territory.
cost.bridge = {0} gold/tile over water, {1} over lava tool.drawbridge.label = Drawbridge
tool.drawbridge.name = Build Drawbridge
tool.drawbridge.desc = A bridge you can raise: drag across the river to build it, click it to switch: down, up, automatic (rises when enemies come near). Wood over water burns, stone over lava does not.
tool.wall.label = Wall
tool.wall.name = Raise Wall
tool.wall.desc = Plan walls on your own empty floor (drag again to cancel): imps raise them as reinforced earth. Close tunnels, then put doors where they matter.
tool.ram.label = Ram
tool.ram.name = Build Battering Ram
tool.ram.desc = A battering ram rolls out of your forge (gold and wood). It follows your attack flag and smashes the knights' gate and towers.
tool.catapult.label = Catapult
tool.catapult.name = Build Catapult
tool.catapult.desc = A catapult rolls out of your forge (gold and wood). It shoots the knights' towers from afar and follows your attack flag to their gate.
cost.bridge = {0} gold + {1} wood/tile over water, {2} gold over lava
cost.drawbridge = {0} gold + {1} wood/tile over water, {2} gold over lava
cost.gold_wood = {0} gold + {1} wood
hint.bridge_where = Bridges must start at your own ground: drag from your territory across the river. hint.bridge_where = Bridges must start at your own ground: drag from your territory across the river.
hint.bridge_fire = Wooden bridges burn: keep fire traps, fire breath and lightning away from them – stone bridges over lava are fireproof.
hint.wall_where = Walls can only be raised on your own empty floor (no room, trap or door).
hint.siege_where = Siege engines roll out on your own open ground – and you need a forge.
hint.bridge_river = Bridges can only be built over water or lava. hint.bridge_river = Bridges can only be built over water or lava.
msg.hero_burned = A {0} fell into the lava! +{1} gold. msg.hero_burned = A {0} fell into the lava! +{1} gold.
msg.creature_burned = Your {0} fell into the lava. msg.creature_burned = Your {0} fell into the lava.
@@ -409,6 +467,22 @@ tile.lava = Lava
tile.lava_info = Whatever falls in burns. Stone bridges cross it. tile.lava_info = Whatever falls in burns. Stone bridges cross it.
tile.bridge = Wooden bridge tile.bridge = Wooden bridge
tile.lavabridge = Stone bridge tile.lavabridge = Stone bridge
tile.mushroom = Giant mushroom
tile.mushroom_grown = Fully grown: ready for felling.
tile.mushroom_growing = Growing: {0}% - it can only be felled when fully grown.
tile.grove = Mushroom grove
tile.grove_info = Nobody can claim this moss. Giant mushrooms grow back here.
tile.tree = Tree
tile.plant_info = Mark it with the pick: an imp fells it for {0} wood.
tile.drawbridge = Drawbridge
tile.drawbridge_up = Raised
tile.drawbridge_down = Lowered
tile.drawbridge_auto = Automatic (down)
tile.drawbridge_auto_up = Automatic (up)
tile.obsidian = Obsidian
tile.obsidian_info = Cooled lava – melts again in {0} s
tile.bridge_burning = Burning!
tile.bridge_worn = Worn {0}%
tile.shard = Shard of your soul core tile.shard = Shard of your soul core
tile.shard_info = Claim this chamber to take the shard back. tile.shard_info = Claim this chamber to take the shard back.
@@ -430,6 +504,11 @@ msg.enemy_tower_destroyed = A knight {0} has fallen!
msg.vault_taken = Knight vault taken: {0} worth of treasure is yours. msg.vault_taken = Knight vault taken: {0} worth of treasure is yours.
msg.shard_taken = A shard of your soul core is back in your hands! msg.shard_taken = A shard of your soul core is back in your hands!
msg.bridge_torn = The knights have torn down one of your bridges! msg.bridge_torn = The knights have torn down one of your bridges!
msg.bridge_fire = One of your bridges is on fire!
msg.drawbridge_up = The drawbridge is raised.
msg.drawbridge_down = The drawbridge is lowered.
msg.drawbridge_auto = The drawbridge rises by itself when enemies come near.
msg.drawbridge_rises = Enemies near: a drawbridge rises!
job.tear_bridge = Tearing down a bridge job.tear_bridge = Tearing down a bridge
job.patrol = On patrol job.patrol = On patrol
job.guard = On guard job.guard = On guard
@@ -533,7 +612,7 @@ camp.m29.story = Lava islands, master, with springs of mana boiling in the middl
camp.m20.title = The Grand Master camp.m20.title = The Grand Master
camp.m20.story = Grand Master Valerian Brightshield waits in his citadel beyond the great river, with the last shard and the gate of the order. He has prayed, polished and prepared for a hundred years. | Destroy his gate and take back the shard. Then your core is whole - and every master who ever wanted it will come for it at once. One thing at a time. camp.m20.story = Grand Master Valerian Brightshield waits in his citadel beyond the great river, with the last shard and the gate of the order. He has prayed, polished and prepared for a hundred years. | Destroy his gate and take back the shard. Then your core is whole - and every master who ever wanted it will come for it at once. One thing at a time.
camp.m30.title = The Throne of the Deep camp.m30.title = The Throne of the Deep
camp.m30.story = It happened as I said: your core is whole, and now everybody wants it. Mossgrim and Lady Nightwell march together, and what is left of the Order opens its gate one last time. | Destroy both master cores and the gate of the order. After this the deep has exactly one master. Guess who. Hint: not me, I am the tea man. camp.m30.story = It happened as I said: your core is whole, and now everybody wants it. Mossgrim and Lady Nightwell have dug their way up to the surface, and the High Marshal waits in his fortress in the forest – daylight, birds, the works. Disgusting. | Climb out of the rock, destroy both master cores and the gate of the order inside the fortress. After this the world has exactly one master. Guess who. Hint: not me, I am the tea man.
camp.epilogue = The green core crumbles, the blue one goes dark, and the gate of the order falls silent. Somewhere above, the Lightwatch is writing a very long report, and Lady Nightwell's letters have stopped coming. | Crookfinger has made tea. You are whole, master. The underworld is yours. camp.epilogue = The green core crumbles, the blue one goes dark, and the gate of the order falls silent. Somewhere above, the Lightwatch is writing a very long report, and Lady Nightwell's letters have stopped coming. | Crookfinger has made tea. You are whole, master. The underworld is yours.
lair.arrivals = {0} free beds: new creatures arrive at {1}% speed lair.arrivals = {0} free beds: new creatures arrive at {1}% speed
job.help = Helping job.help = Helping
@@ -657,10 +736,15 @@ net.error = {0}
net.rooms = Open matches net.rooms = Open matches
net.no_rooms = No open matches. net.no_rooms = No open matches.
net.create = Create match net.create = Create match
net.map_size = Map {0}x{1} · {2} players
net.coop_mission = Co-op campaign: mission {0} ({1}) net.coop_mission = Co-op campaign: mission {0} ({1})
net.coop_create = Co-op campaign net.coop_create = Co-op campaign
net.coop_editor = Editor campaign net.coop_editor = Editor campaign
net.coop_builtin = Built-in campaign net.coop_builtin = Built-in campaign
net.coop_league = League against the Lightwatch
net.coop_solo = AI partner
net.coop_solo_started = Co-op without the server: your AI partner builds its own dungeon, follows your attack flag and helps out with gold.
net.coop_resume = Continue M{0}
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
net.join = Join net.join = Join
@@ -691,6 +775,9 @@ net.ai_off = Off
# rival masters (free game) # rival masters (free game)
newgame.difficulty = Opponents newgame.difficulty = Opponents
newgame.land = Land
newgame.land0 = Underground
newgame.land1 = Half above ground
diff.0 = Easy diff.0 = Easy
diff.1 = Medium diff.1 = Medium
diff.2 = Hard diff.2 = Hard
@@ -703,9 +790,14 @@ msg.rival1_attack = Mossgrim, the green master, sends {0} creatures against you!
msg.rival2_attack = Lady Nightwell, the blue master, sends {0} creatures against you! msg.rival2_attack = Lady Nightwell, the blue master, sends {0} creatures against you!
msg.rival1_out = Mossgrim is defeated - his soul core has shattered! msg.rival1_out = Mossgrim is defeated - his soul core has shattered!
msg.rival2_out = Lady Nightwell is defeated - her soul core has shattered! msg.rival2_out = Lady Nightwell is defeated - her soul core has shattered!
msg.rival3_attack = Saffrax, the yellow master, sends {0} creatures against you!
msg.rival3_out = Saffrax is defeated - his soul core has shattered!
msg.rival_unit_slain = A {0} of a rival master falls! +{1} gold. msg.rival_unit_slain = A {0} of a rival master falls! +{1} gold.
msg.wave_on_rival1 = Wave {0}: {1} heroes march on the green master. msg.wave_on_rival1 = Wave {0}: {1} heroes march on the green master.
msg.wave_on_rival2 = Wave {0}: {1} heroes march on the blue master. msg.wave_on_rival2 = Wave {0}: {1} heroes march on the blue master.
msg.wave_warn_on_master = Scouts report: hero wave {0} comes in 30 seconds – it heads for master {1}.
msg.wave_on_master = Wave {0}: {1} heroes march on master {2}.
msg.partner_out = Your AI partner's heart has fallen – fight on alone!
hud.rival1 = Green hud.rival1 = Green
hud.rival2 = Blue hud.rival2 = Blue
msg.creature_freed = A {0} breaks its chains and joins you! msg.creature_freed = A {0} breaks its chains and joins you!
@@ -794,6 +886,8 @@ best.b.torture.text = Captured heroes end up here. With time and a jailer, some
best.b.temple.text = Prayers heal the soul core and keep spirits high. The flame priestess comes for a temple. best.b.temple.text = Prayers heal the soul core and keep spirits high. The flame priestess comes for a temple.
best.b.aerie.text = Perches and nests for flying creatures. Bats, gargoyles, owls and even the ember wyrm need room here. best.b.aerie.text = Perches and nests for flying creatures. Bats, gargoyles, owls and even the ember wyrm need room here.
best.b.bridge.text = Wood over water, stone over lava. The knights tear down your bridges - claim theirs. best.b.bridge.text = Wood over water, stone over lava. The knights tear down your bridges - claim theirs.
best.b.drawbridge.text = Pull it up and the river is a wall again. Set to automatic, it rises whenever enemies come close – and comes down for your own creatures.
best.b.wall.text = A tunnel you dug is not forever: wall it up again and leave the heroes one way in – through your door.
best.b.steel.text = Riveted iron plates on a reinforced wall. Nobody digs through quickly, not even dwarves. best.b.steel.text = Riveted iron plates on a reinforced wall. Nobody digs through quickly, not even dwarves.
best.b.arrow.text = The arrow pierces up to two more enemies behind the target. Cheap and reliable. best.b.arrow.text = The arrow pierces up to two more enemies behind the target. Cheap and reliable.
best.b.cannon.text = Splash damage and knockback - push the knights into the lava. best.b.cannon.text = Splash damage and knockback - push the knights into the lava.
@@ -805,7 +899,7 @@ room.forge = Forge
tool.forge.label = Forge tool.forge.label = Forge
tool.forge.desc = Creatures manufacture traps and doors here - work, no gold. Finished pieces wait in crates until an imp installs them. Coglings come for a forge of 6+ tiles. tool.forge.desc = Creatures manufacture traps and doors here - work, no gold. Finished pieces wait in crates until an imp installs them. Coglings come for a forge of 6+ tiles.
unit.cogling = Cogling unit.cogling = Cogling
cat.traps = Traps cat.traps = Build
cost.work = {0} work in the forge cost.work = {0} work in the forge
job.forge = Forging job.forge = Forging
job.install = Installing a trap job.install = Installing a trap
@@ -882,6 +976,7 @@ research.now = Researching: {0} ({1}%)
research.next = then: {0} research.next = then: {0}
research.done = Nothing left to research here. research.done = Nothing left to research here.
msg.researched = Researched: {0}! It is in the build menu now. msg.researched = Researched: {0}! It is in the build menu now.
msg.room_upgraded = Research: {0} reaches level {1} – it works a quarter better, and it shows.
msg.tool_research = Not researched yet - creatures studying in the library work on it. msg.tool_research = Not researched yet - creatures studying in the library work on it.
msg.script = {0} msg.script = {0}
@@ -919,6 +1014,9 @@ tool.plague.desc = A sickness that eats at the enemies around the spot and jumps
tool.quake.label = Quake tool.quake.label = Quake
tool.quake.name = Quake tool.quake.name = Quake
tool.quake.desc = The ground shakes: enemies around the spot are hurt and stunned for two seconds. Your creatures keep their footing. tool.quake.desc = The ground shakes: enemies around the spot are hurt and stunned for two seconds. Your creatures keep their footing.
tool.freeze.label = Frost
tool.freeze.name = Frost
tool.freeze.desc = Icy cold: lava around the spot cools into walkable obsidian for 40 seconds, enemies are chilled and slowed.
tile.unexplored = Unexplored darkness tile.unexplored = Unexplored darkness
# personality # personality
@@ -971,6 +1069,9 @@ tool.arena.desc = Creatures spar in pairs: twice as much experience as training
tool.altar.label = Altar tool.altar.label = Altar
tool.altar.desc = Drop a creature on the altar to sacrifice it. Certain pairs of sacrifices bring a rare creature, otherwise the dungeon is blessed: better mood and mana. tool.altar.desc = Drop a creature on the altar to sacrifice it. Certain pairs of sacrifices bring a rare creature, otherwise the dungeon is blessed: better mood and mana.
unit.revenant = Revenant unit.revenant = Revenant
unit.cavebear = Cave bear
unit.beetle = Giant beetle
unit.trader = Trader
job.guard_post = On guard job.guard_post = On guard
job.spar = Sparring job.spar = Sparring
msg.revenant_rises = A revenant rises from the graveyard and serves you! msg.revenant_rises = A revenant rises from the graveyard and serves you!
@@ -979,6 +1080,10 @@ msg.altar_gift = The altar accepts the pair: a {0} appears!
msg.altar_blessing = The {0} is sacrificed. The dungeon is blessed. msg.altar_blessing = The {0} is sacrificed. The dungeon is blessed.
best.revenant.lure = rises from the graveyard best.revenant.lure = rises from the graveyard
best.revenant.text = A revenant does not eat, does not sleep and does not complain - the perfect employee, if you can stand the smell. | It drinks the strength of those it strikes once it has some experience. The priestess and it do not get along, for obvious reasons. best.revenant.text = A revenant does not eat, does not sleep and does not complain - the perfect employee, if you can stand the smell. | It drinks the strength of those it strikes once it has some experience. The priestess and it do not get along, for obvious reasons.
best.cavebear.lure = beat it in its den while you have a prison
best.cavebear.text = A bear that never saw the sun and does not miss it. Shaggy, strong and grumpy until breakfast. | Beat it in its den while a prison has room, and it gives in and joins you. It likes the mushroom garden, and spiders - as a snack.
best.beetle.lure = beat it in its den while you have a prison
best.beetle.text = A beetle the size of a barrel, armoured like a knight and twice as polite. | Beat it in its den while a prison has room, and it joins you. It sleeps in the lair and cannot stand bats.
best.b.prison.text = Rows of cells. Heroes rot here quietly until somebody decides what to do with them. best.b.prison.text = Rows of cells. Heroes rot here quietly until somebody decides what to do with them.
best.b.graveyard.text = Moss, stones and fresh earth. Nobody stays dead for long down here. best.b.graveyard.text = Moss, stones and fresh earth. Nobody stays dead for long down here.
best.b.guardpost.text = A brazier, a banner and a bored creature with sharp eyes. best.b.guardpost.text = A brazier, a banner and a bored creature with sharp eyes.
@@ -991,23 +1096,39 @@ ability.drain.desc = heals itself by a third of the damage it deals.
unit.giant = Giant unit.giant = Giant
unit.fairy = Fairy Spy unit.fairy = Fairy Spy
unit.battlemage = Battle Mage unit.battlemage = Battle Mage
unit.thief = Thief
unit.monk = Monk
unit.ranger = Ranger
unit.inquisitor = Inquisitor
unit.ram = Battering ram
unit.catapult = Catapult
msg.trap_wrecked = The heroes wrecked a trap: {0}. msg.trap_wrecked = The heroes wrecked a trap: {0}.
best.giant.text = A giant is what happens when a knight eats his vegetables for three hundred years. Slow, enormous, and it treats doors as a suggestion. | It wrecks traps simply by standing next to them. Revolting manners. Hit it with everything, preferably from afar. best.giant.text = A giant is what happens when a knight eats his vegetables for three hundred years. Slow, enormous, and it treats doors as a suggestion. | It wrecks traps simply by standing next to them. Revolting manners. Hit it with everything, preferably from afar.
best.fairy.text = The Order's little informer: a glowing gnat with wings, sent ahead to find your traps and tell everybody. Once it has seen a trap, the heroes smash it. | It also sprinkles healing dust on the heroes. Swat it first. Twice, to be sure. best.fairy.text = The Order's little informer: a glowing gnat with wings, sent ahead to find your traps and tell everybody. Once it has seen a trap, the heroes smash it. | It also sprinkles healing dust on the heroes. Swat it first. Twice, to be sure.
best.battlemage.text = A mage in the Order's robes who thinks walls are a personal insult. Reinforced walls burst under his fireballs, and he never stops talking about it. | Fragile up close. Steel walls annoy him most - good. best.battlemage.text = A mage in the Order's robes who thinks walls are a personal insult. Reinforced walls burst under his fireballs, and he never stops talking about it. | Fragile up close. Steel walls annoy him most - good.
best.thief.text = Quick fingers, quicker feet. He does not come to fight: he sneaks into your treasury, fills his sack and runs back to the gate. | Kill him before he gets away and the gold is yours again - with interest.
best.monk.text = Years of fasting and praying have made him deaf to your magic: lightning, plague and frost slide off him like rain off a roof. | Fists hurt too. Meet him with steel, claws and teeth.
best.ranger.text = A woodsman of the Order who shoots from afar and leaves iron snares in your corridors. A creature that steps into one is hurt and stuck for a while. | Snares stay until something steps in. Better a kobold than your ogre.
best.inquisitor.text = Frail, but his sermons are dangerous: every twenty seconds he talks one of your weakest creatures - a kobold or a spider - into joining the heroes. | Strong creatures just laugh at him. Hit him first.
best.ram.text = A tree trunk with an iron cap and a roof against arrows. It does not care for your creatures – only for your doors, walls and towers. | Stop it before it reaches the door, or get a new door.
best.catapult.text = A wooden arm, a bucket of rocks and a knight with too much spare time. It outranges your towers and aims for them first. | Rush it: up close it cannot hurt a fly.
# orders # orders
tool.order_attack.label = Attack tool.order_attack.label = Attack
tool.order_attack.name = Attack! tool.order_attack.name = Attack!
tool.order_attack.desc = Click an enemy or an enemy tower: all fighting creatures within 18 tiles go for it. Right click with an order tool cancels the guard orders. tool.order_attack.desc = Click an enemy or an enemy tower: all fighting creatures within 18 tiles go for it. Click open ground: a sword marker - the ready creatures share out over all swords and fight there. Right click on a marker removes it.
tool.order_guard.label = Guard tool.order_guard.label = Guard
tool.order_guard.name = Guard this room tool.order_guard.name = Guard this room
tool.order_guard.desc = Click a room: up to three creatures nearby take positions there for two minutes and attack whatever comes into view. Right click cancels. tool.order_guard.desc = Click your own ground: a shield marker that three creatures hold and defend. Up to eight shields; right click on a shield removes it.
hint.order_attack = Click an enemy or an enemy tower. hint.order_attack = Click an enemy or an enemy tower.
hint.order_guard = Click one of your rooms (or your own floor). hint.order_guard = Click one of your rooms (or your own floor).
hint.order_nobody = No creature is free for that order. hint.order_nobody = No creature is free for that order.
msg.order_attack = {0} creatures attack! msg.order_attack = {0} creatures attack!
msg.order_guard = {0} creatures take their positions. msg.order_guard = {0} creatures take their positions.
msg.mark_attack = A sword marks the spot: your ready creatures march there.
msg.mark_guard = A shield marks the spot: three creatures will hold it.
msg.mark_removed = Marker removed - its creatures are free again.
hint.marks_full = Eight markers of that kind are the most. Remove one with a right click.
msg.orders_cancelled = Guard orders cancelled. msg.orders_cancelled = Guard orders cancelled.
# creature overview # creature overview
@@ -1017,6 +1138,8 @@ ov.summary = {0} creatures - wages {1} gold per payday - mood {2}%
ov.none = No creatures yet. Build a lair and a mushroom garden and claim a portal. ov.none = No creatures yet. Build a lair and a mushroom garden and claim a portal.
ov.hint = Lv, mood, wage, job, health - click: go there ov.hint = Lv, mood, wage, job, health - click: go there
set.voice = Crookfinger's voice set.voice = Crookfinger's voice
set.track = Music track
val.track_auto = Automatic
# ---- campaign editor, block scripts, demo campaign # ---- campaign editor, block scripts, demo campaign
menu.back_editor = Back to the editor menu.back_editor = Back to the editor
@@ -1089,6 +1212,7 @@ ed.forts = Knight fortresses
ed.rivals = Rival masters ed.rivals = Rival masters
ed.difficulty = Difficulty ed.difficulty = Difficulty
ed.portals = Portals ed.portals = Portals
ed.surface = Above ground (east)
ed.edit_map = Edit the map ed.edit_map = Edit the map
ed.make_random = Make it a random map ed.make_random = Make it a random map
ed.paint_map = Generate and paint the map ed.paint_map = Generate and paint the map
@@ -1168,6 +1292,10 @@ ed.terrain.silver = Silver vein
ed.terrain.ruby = Ruby vein ed.terrain.ruby = Ruby vein
ed.terrain.diamond = Diamond vein ed.terrain.diamond = Diamond vein
ed.terrain.steel = Steel wall ed.terrain.steel = Steel wall
ed.terrain.mushroom = Giant mushroom
ed.terrain.grove = Mushroom grove
ed.terrain.grass = Grass (above ground)
ed.terrain.tree = Tree
ed.place.core = Soul core ed.place.core = Soul core
ed.place.portal = Portal ed.place.portal = Portal
ed.place.gate = Hero gate ed.place.gate = Hero gate
@@ -1198,6 +1326,7 @@ ed.level = Level
ed.zone_shape = Shape of a new zone ed.zone_shape = Shape of a new zone
ed.zone_new_name = Name of a new zone ed.zone_new_name = Name of a new zone
ed.zones = Zones ed.zones = Zones
ed.zone_erase = Remove tiles
ed.shape.rect = Rectangle ed.shape.rect = Rectangle
ed.shape.circle = Circle ed.shape.circle = Circle
ed.shape.tiles = Tiles ed.shape.tiles = Tiles
@@ -1247,6 +1376,8 @@ blk.reveal = Reveal
blk.camera = Show blk.camera = Show
blk.gold = Gold + blk.gold = Gold +
blk.mana = Mana + blk.mana = Mana +
blk.weather = Weather above ground
blk.talentxp = Talent experience +
blk.if = If blk.if = If
blk.repeat = Repeat blk.repeat = Repeat
blk.trigger = Switch trigger blk.trigger = Switch trigger
@@ -1266,6 +1397,8 @@ blk.help.reveal = Lifts the fog of war over a zone.
blk.help.camera = Moves the camera to a zone. blk.help.camera = Moves the camera to a zone.
blk.help.gold = Gives (or takes) gold. blk.help.gold = Gives (or takes) gold.
blk.help.mana = Gives (or takes) mana. blk.help.mana = Gives (or takes) mana.
blk.help.weather = Sets the weather above ground (fair, rain, fog or storm) for so many seconds; 0 keeps it until another block changes it.
blk.help.talentxp = Gives the player experience for the master talents.
blk.help.if = Runs the blocks inside only if the condition holds, otherwise the ones under "else". blk.help.if = Runs the blocks inside only if the condition holds, otherwise the ones under "else".
blk.help.repeat = Runs the blocks inside several times. blk.help.repeat = Runs the blocks inside several times.
blk.help.trigger = Switches a trigger on (it may fire again) or off. blk.help.trigger = Switches a trigger on (it may fire again) or off.
@@ -1288,6 +1421,7 @@ blk.v.or = or
blk.v.not = not blk.v.not = not
blk.v.bool = Yes/no blk.v.bool = Yes/no
blk.v.eventkind = Trigger unit is a blk.v.eventkind = Trigger unit is a
blk.v.weatheris = Weather above ground is
blk.w_at = at blk.w_at = at
blk.w_by = by blk.w_by = by
blk.w_in = in blk.w_in = in
@@ -1336,6 +1470,7 @@ blk.until = Repeat until
blk.dountil = Repeat, then check: until blk.dountil = Repeat, then check: until
blk.wait = Wait blk.wait = Wait
blk.w_seconds = seconds blk.w_seconds = seconds
blk.w_for = for
blk.help.until = Runs the blocks inside again and again until the condition holds - checked before each run, so if it already holds the inside does not run at all. blk.help.until = Runs the blocks inside again and again until the condition holds - checked before each run, so if it already holds the inside does not run at all.
blk.help.dountil = Runs the blocks inside, then checks the condition and repeats until it holds - the inside always runs at least once. blk.help.dountil = Runs the blocks inside, then checks the condition and repeats until it holds - the inside always runs at least once.
blk.help.wait = Pauses this script for some seconds, then it goes on with the next block (also inside loops). blk.help.wait = Pauses this script for some seconds, then it goes on with the next block (also inside loops).
@@ -1477,9 +1612,159 @@ coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first. coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first.
coop.m19.t2 = Knights and archers again. The Order is running out of ideas. coop.m19.t2 = Knights and archers again. The Order is running out of ideas.
coop.m20.title = Mossgrim's Fall coop.m20.title = Mossgrim's Fall
coop.m20.story = With the High Marshal gone, Mossgrim stands alone. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nEnd the traitor. End his army. And then, masters - tea. coop.m20.story = With the High Marshal gone, Mossgrim stands alone – up in the forest, in the sun, of all places. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nClimb out of the rock and end the traitor. End his army. And then, masters - tea.
coop.m20.start = Mossgrim, you old fungus. The league sends its regards. coop.m20.start = Mossgrim, you old fungus. The league sends its regards.
coop.m20.goal = Defeat Mossgrim and all his creatures. coop.m20.goal = Defeat Mossgrim and all his creatures.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not. coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go. coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong. coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong.
# achievements and statistics
menu.achievements = Achievements
ach.count = {0} of {1} achievements unlocked · {2} minutes played
ach.hidden = A secret. Crookfinger is not telling.
msg.achievement = Achievement unlocked: {0}!
end.stats2 = Heroes {0} · Creatures lost {1} · Tiles dug {2} · Gold {3} · Wood {4}
ach.heroes.name = Hero Bane {0}
ach.heroes.desc = Defeat {0} heroes.
ach.bosses.name = Champion Breaker {0}
ach.bosses.desc = Bring down {0} champions of the Order.
ach.dug.name = Deep Digger {0}
ach.dug.desc = Dig out {0} tiles.
ach.gold.name = Hoarder {0}
ach.gold.desc = Earn {0} gold.
ach.wood.name = Lumberlord {0}
ach.wood.desc = Fell {0} wood.
ach.walls.name = Mason {0}
ach.walls.desc = Raise {0} walls.
ach.rooms.name = Architect of Shadows {0}
ach.rooms.desc = Build {0} room tiles.
ach.spells.name = Dark Caster {0}
ach.spells.desc = Cast {0} spells.
ach.slaps.name = Firm Hand {0}
ach.slaps.desc = Slap your creatures {0} times. They love it. Probably.
ach.traps.name = Trapsmith {0}
ach.traps.desc = Order {0} traps and doors in the forge.
ach.towers.name = Tower Lord {0}
ach.towers.desc = Build {0} towers.
ach.drowned.name = Swimming Lessons {0}
ach.drowned.desc = Let {0} heroes fall into water or lava.
ach.burnt.name = Bridge Burner {0}
ach.burnt.desc = Burn down {0} bridge planks.
ach.siege.name = Siege Master {0}
ach.siege.desc = Build {0} siege engines.
ach.captured.name = Warden {0}
ach.captured.desc = Capture {0} heroes.
ach.drawbridge.name = Up She Goes {0}
ach.drawbridge.desc = Raise drawbridges {0} times.
ach.wins.name = Conqueror {0}
ach.wins.desc = Win {0} free games.
ach.missions.name = Shard Seeker {0}
ach.missions.desc = Win {0} campaign missions.
ach.coop.name = League Member {0}
ach.coop.desc = Win {0} co-op missions.
ach.minutes.name = Night Owl {0}
ach.minutes.desc = Play for {0} minutes.
ach.flawless.name = Not a Scratch
ach.flawless.desc = Win without losing a single creature.
ach.speedrun.name = Before the Tea Gets Cold
ach.speedrun.desc = Win within fifteen minutes.
ach.campaign.name = The One Master
ach.campaign.desc = Win the last mission of the campaign.
ach.surface.name = Sunburn
ach.surface.desc = Win a game that reaches the surface.
ach.defeat.name = Tea and Sympathy
ach.defeat.desc = Lose a game. Crookfinger puts the kettle on.
ach.legend.name = Living Legend
ach.legend.desc = A creature reaches level ten.
ach.wyrm.name = Here Be Dragons
ach.wyrm.desc = An ember wyrm joins your dungeon.
ach.fullhand.name = Hands Full
ach.fullhand.desc = Hold eight units in your hand at once.
# tutorial
menu.tutorial = Tutorial
tut.name = First Steps
tut.title = Crookfinger Shows You the Ropes
tut.story = So you are the new master. Small, but the core still beats, and that is what counts.\n\nI will show you the basics: digging, rooms, creatures, and what to do with knights. Do what I say, and nobody gets hurt. Except the knights.
tut.start = Crookfinger: First lesson - digging. Take the pick (key 1), drag over the earth east of the heart and your imps dig it out.
tut.obj1 = Dig out the tunnel east of the heart.
tut.dug = Crookfinger: There, a hall behind it! Your imps claim the ground by themselves. Now open Rooms and build a lair on your own floor - your creatures sleep there.
tut.obj2 = Build a lair (Rooms).
tut.lair = Crookfinger: Beds, check. Creatures also eat: build a mushroom garden next to it.
tut.obj3 = Build a mushroom garden (Rooms).
tut.farm = Crookfinger: Bed and board - now the portal sends you creatures. Be patient, they come by themselves. Wait for two.
tut.obj4 = Wait until two creatures have come through the portal.
tut.creatures = Crookfinger: Here they are! A little motivation never hurts: take the hand (key 2) and click a creature with Shift held down (on a touch screen: tap it twice) to slap it. It works faster for a while.
tut.obj5 = Slap one of your creatures (hand, Shift + click).
tut.slap = Crookfinger: Ha! Did you hear that? Something is coming from the east. Get ready: lightning (Spells) hits hard, and your creatures fight on their own.
tut.obj6 = Defeat the two knights.
tut.knights = Crookfinger: Two knights! Send your creatures with the attack flag (Orders) or strike them with lightning.
tut.win = Crookfinger: Well done, master! That is all there is to it. Well - almost. The campaign tells the rest. I'll put the kettle on.
# master talents
cat.master = Master
talent.title = Master talents
talent.points = Free talent points: {0}
talent.next = {0}/{1} experience to the next point
talent.all = Every talent is learned.
talent.builder = Builder
talent.warlord = Warlord
talent.warlock = Warlock
talent.merchant = Merchant
talent.builder.desc = Imps dig {0}% faster, rooms cost {1}% less.
talent.warlord.desc = Creatures hit {0}% harder and take {1}% less damage.
talent.warlock.desc = Mana flows {0}% faster, spells cost {1}% less.
talent.merchant.desc = Wages are {0}% lower, delivered treasure is worth {1}% more.
talent.none = Not learned yet.
talent.then = Next level: {0}
msg.talent_point = A new talent point! Spend it in the Master window.
msg.talent_learned = {0} level {1} learned.
# weather above ground
weather.fair = Fair
weather.rain = Rain
weather.fog = Fog
weather.storm = Storm
msg.weather_clear = The sky clears up above ground.
msg.weather_rain = It starts to rain above ground - fire will not catch.
msg.weather_fog = Fog rolls over the land - everyone outside sees less.
msg.weather_storm = A storm is brewing above ground! Lightning strikes units and trees.
msg.lightning_unit = Lightning struck: {0}!
# creature names
names.imp = Grubnik, Snot, Pickles, Scrabble, Dimple, Muck, Nibbles, Grot, Twitch, Smudge, Pebble, Wort, Scuttle, Fizzle, Gristle, Sniv, Burrow, Crumb, Dribble, Tinker
names.kobold = Yip, Skrag, Ratchet, Snagtooth, Kibble, Rusk, Nettle, Skiv, Gnash, Scrapper, Flint, Yark, Biter, Sprocket, Dreg, Skulk, Tatter, Grizz, Nab, Scabby
names.spider = Webbly, Itsy, Nibsy, Silkie, Creepkin, Tickle, Venna, Lacey, Spindle, Skitter, Fangsy, Mopsy, Widow, Tangles, Weaver, Pip, Gossamer, Twiddle, Snare, Muffet
names.mage = Bartholomew, Ignatius, Percival, Mordecai, Fennimore, Cornelius, Aldous, Thaddeus, Barnabus, Erasmus, Montague, Ambrose, Septimus, Horatio, Quentin, Lucan, Archibald, Florian, Zebulon, Crispin
names.ogre = Grunk, Thud, Bonk, Lump, Mudge, Klod, Hulk, Gorb, Dunk, Brog, Oaf, Plonk, Boulder, Grog, Mungo, Slab, Wump, Tonk, Duh, Gullet
names.jailer = Grimm, Lockjaw, Chains, Dredd, Morrow, Shackle, Vex, Bolt, Garrote, Stern, Rook, Hask, Iron, Grist, Clank, Mordo, Bleak, Snare, Kessel, Warden
names.priestess = Ember, Seraphine, Pyra, Ashlyn, Scarlet, Morwen, Vesper, Cinder, Ravenna, Lilith, Blaze, Isolde, Brimma, Nyx, Sable, Velvet, Flare, Ophelia, Hestia, Rowan
names.chainbreaker = Fetterbane, Ulfgar, Ironsoul, Brannoc, Kragmor, Valdrek, Thorn, Gorath, Unbound, Rurik, Torvald, Hagan, Durnok, Shatter, Brand, Wulfric, Korrun, Arvak, Stigr, Halvard
names.bat = Squeak, Flutter, Pippin, Nibs, Echo, Flap, Dusk, Zippy, Chirp, Fang, Twirl, Batsy, Mothy, Peep, Swoop, Dangle, Velvet, Tiddles, Squee, Blip
names.gargoyle = Granite, Slate, Basalt, Grimsby, Mortar, Cobble, Gravel, Flint, Grout, Rubble, Quartz, Pumice, Lintel, Gnarl, Stoneface, Dour, Gable, Spout, Chisel, Ledge
names.owl = Hootsworth, Sage, Quill, Barnaby, Minerva, Tobias, Hoots, Ollivant, Feathers, Wisp, Ponder, Elder, Mopsy, Noctua, Owlbert, Scroll, Dusk, Tawny, Professor, Ruffle
names.wyrm = Vermithrax, Ashmaw, Scorch, Calderon, Ignivar, Smoulder, Drakkar, Pyrrhus, Obsidian, Embermaw, Vulcanor, Searfang, Malgrath, Tyrax, Inferno, Brimstone, Kaldor, Fyrnak, Ragnor, Cindros
names.cogling = Gizmo, Sprocket, Widget, Ratchet, Bolt, Gearwin, Tinker, Doohickey, Spanner, Pinion, Clink, Fiddle, Rivet, Whirr, Cogsworth, Twiddle, Flange, Gadget, Ticktock, Nubbin
names.revenant = Morrow, Grieve, Ashen, Lament, Hollow, Mourn, Pallor, Sorrow, Wraithe, Dirge, Barrow, Gloam, Tomb, Weep, Vigil, Cairn, Shroud, Elegy, Dregs, Bleak
names.beast = Fuzzbert, Grizzle, Bruno, Shaggs, Chomper, Mossback, Bumble, Ursa, Burl, Crunch, Clicker, Scuttles, Bristle, Moss, Rumble, Bramble, Chitter, Grumbles, Hulkster, Snuffle
names.human = Gareth, Roland, Cedric, Aldric, Tristan, Edmund, Gawain, Lionel, Percy, Hugo, Bertram, Godfrey, Osric, Baldwin, Reginald, Wilfred, Ector, Gilbert, Rupert, Duncan
epithets.mid = Bonecrusher, Mudfoot, Ratbiter, Grimeface, Pebblechewer, Skullthumper, Stinkfist, Rustknuckle, Gutpuncher, Shinkicker, Mouldbeard, Dirtclaw, Snotslinger, Earchewer, Kneebreaker, Wormeater
epithets.high = Kingslayer, Doomhand, Dreadfang, Worldbreaker, Herobane, Stormfist, Nightterror, Soulreaper, Ironheart, Castlecrusher, Dragonbane, Hellstrider, Shadowlord, Bloodcrown, Grimtide, Starbreaker
name.epithet = {0} {1}
msg.creature_fallen = {0} has fallen. A memorial stone keeps the name.
tile.memorial = Memorial stone
tile.memorial_info = Here lies {0} ({1}, level {2}).
# hall of the fallen
hall.title = Hall of the fallen
hall.count = {0} fallen creatures remembered
hall.empty = No one has fallen yet. Long may it last.
hall.kills = {0} heroes slain
ov.fallen = Fallen ({0})
ov.living = Living
cause.slain = slain by: {0}
cause.lava = burned in lava
cause.water = drowned
cause.snare = caught in a snare
cause.lightning = struck by lightning
cause.fell = fell
+422 -137
View File
@@ -53,17 +53,29 @@ msg.creature_arrived = Un {0} ha cruzado el portal.
msg.payday_partial = ¡Día de paga! {0} criaturas cobran ({1} de oro), ¡{2} no reciben nada y están furiosas! msg.payday_partial = ¡Día de paga! {0} criaturas cobran ({1} de oro), ¡{2} no reciben nada y están furiosas!
msg.payday = ¡Día de paga! {0} criaturas reciben {1} de oro. msg.payday = ¡Día de paga! {0} criaturas reciben {1} de oro.
msg.wave_boss = Oleada {0}: ¡un Paladín de la Luz guía a {1} héroes hacia tu reino! msg.wave_boss = Oleada {0}: ¡un Paladín de la Luz guía a {1} héroes hacia tu reino!
boss.aldric = Sir Aldric el Inflexible
boss.seraphine = Dama Seraphine del Alba
boss.halvard = Hermano Halvard el Piadoso
boss.rothgar = Capitán Rothgar Flanco de Hierro
msg.boss_comes = Un campeón de la Orden encabeza la oleada: ¡{0}!
msg.boss_slain = ¡{0} ha caído! Botín: {1} de oro y los planos de una torre de forja rúnica.
msg.wave = Oleada {0}: ¡{1} héroes atraviesan la puerta! msg.wave = Oleada {0}: ¡{1} héroes atraviesan la puerta!
msg.gate_boss = ¡La puerta se desmorona! ¡El Guardián de la Puerta aparece con {0} defensores! msg.gate_boss = ¡La puerta se desmorona! ¡El Guardián de la Puerta aparece con {0} defensores!
msg.gate_defenders = ¡La puerta invoca a {0} defensores! msg.gate_defenders = ¡La puerta invoca a {0} defensores!
msg.level_up = {0} alcanza el nivel {1}. msg.level_up = {0} alcanza el nivel {1}.
msg.hero_slain = ¡{0} derrotado! Botín: {1} de oro. msg.hero_slain = ¡{0} derrotado! Botín: {1} de oro.
msg.thief_in_treasury = ¡Hay un ladrón en tu tesorería!
msg.thief_escaped = ¡Un ladrón ha escapado con {0} de oro!
msg.snare_caught = ¡Ha caído en un cepo: {0}!
msg.converted = ¡El inquisidor ha convertido a una de tus criaturas: {0}!
msg.creature_died = Tu {0} ha caído. msg.creature_died = Tu {0} ha caído.
msg.portal_claimed = ¡El portal es tuyo! Atrae a las criaturas. msg.portal_claimed = ¡El portal es tuyo! Atrae a las criaturas.
msg.flag_removed = Bandera de ataque retirada. Tus criaturas regresan. msg.flag_removed = Bandera de ataque retirada. Tus criaturas regresan.
msg.flag_set = ¡Bandera de ataque colocada! Las criaturas listas para luchar se reúnen allí. msg.flag_set = ¡Bandera de ataque colocada! Las criaturas listas para luchar se reúnen allí.
msg.creature_leaves = ¡Un {0} descontento abandona tu reino! msg.creature_leaves = ¡Un {0} descontento abandona tu reino!
msg.no_gold = ¡No hay oro suficiente! msg.no_gold = ¡No hay oro suficiente!
msg.no_wood = ¡No hay madera suficiente! Tala setas gigantes (o árboles) con el pico.
msg.siege_built = Un {0} está listo: pon la bandera de ataque para enviarlo.
msg.sold = Salas vendidas: +{0} de oro. msg.sold = Salas vendidas: +{0} de oro.
msg.no_mana_bolt = No hay maná suficiente para el Rayo. msg.no_mana_bolt = No hay maná suficiente para el Rayo.
msg.no_mana_heal = No hay maná suficiente para Curar. msg.no_mana_heal = No hay maná suficiente para Curar.
@@ -73,6 +85,7 @@ msg.no_mana_cost = No hay maná suficiente (se necesitan {0}).
hint.portal_no_lair = El portal está abierto, pero sin guarida no vendrá ninguna criatura. hint.portal_no_lair = El portal está abierto, pero sin guarida no vendrá ninguna criatura.
hint.no_farm = Las criaturas quieren comer: construye un huerto de setas. hint.no_farm = Las criaturas quieren comer: construye un huerto de setas.
hint.lair_full = Todas las camas de la guarida están ocupadas. Amplía tu guarida. hint.lair_full = Todas las camas de la guarida están ocupadas. Amplía tu guarida.
hint.creature_max = Tu mazmorra alberga 100 criaturas: no vendrán más por el portal.
hint.gate_attack = ¡Tus criaturas atacan la Puerta de los Héroes! hint.gate_attack = ¡Tus criaturas atacan la Puerta de los Héroes!
hint.treasury_full = ¡Tus tesorerías están llenas! Construye más casillas de tesorería. hint.treasury_full = ¡Tus tesorerías están llenas! Construye más casillas de tesorería.
hint.flag_unreachable = Tus criaturas no pueden llegar a la bandera de ataque. hint.flag_unreachable = Tus criaturas no pueden llegar a la bandera de ataque.
@@ -124,6 +137,7 @@ ui.key = Tecla: {0}
# ---- HUD # ---- HUD
hud.gold = Oro hud.gold = Oro
hud.wood = Madera
hud.mana = Maná hud.mana = Maná
hud.core = Núcleo hud.core = Núcleo
hud.gate = Puerta hud.gate = Puerta
@@ -145,6 +159,7 @@ help.k_right = Clic derecho Soltar unidad (prisioneros: también en la cámar
help.k_slap = Mayús+Izq. Mano: abofetear unidades propias, golpear héroes en tu suelo help.k_slap = Mayús+Izq. Mano: abofetear unidades propias, golpear héroes en tu suelo
help.k_pan = Arrastrar dcho. Mover el mapa (también WASD/flechas, borde de pantalla) help.k_pan = Arrastrar dcho. Mover el mapa (también WASD/flechas, borde de pantalla)
help.k_rot = Arrastrar medio Rotar e inclinar la cámara (también Q/E, R/F) help.k_rot = Arrastrar medio Rotar e inclinar la cámara (también Q/E, R/F)
help.k_reset = Medio 2x clic Restablecer ángulo y giro (el lugar se queda)
help.k_zoom = Rueda del ratón Zoom help.k_zoom = Rueda del ratón Zoom
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Elegir herramienta help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Elegir herramienta
help.k_misc = P / Espacio Pausa M Sonido sí/no +/- Volumen help.k_misc = P / Espacio Pausa M Sonido sí/no +/- Volumen
@@ -237,6 +252,7 @@ set.lights = Fuentes de luz
set.ui_scale = Tamaño de interfaz set.ui_scale = Tamaño de interfaz
set.game_speed = Velocidad de juego set.game_speed = Velocidad de juego
set.hints = Consejos set.hints = Consejos
set.events = Sucesos aleatorios
set.keys_hint = Haz clic en una acción y pulsa una tecla. Las teclas ya usadas se intercambian. set.keys_hint = Haz clic en una acción y pulsa una tecla. Las teclas ya usadas se intercambian.
set.press_key = Pulsa una tecla para: {0} set.press_key = Pulsa una tecla para: {0}
set.esc_cancel = Esc cancela set.esc_cancel = Esc cancela
@@ -248,6 +264,8 @@ val.medium = Medio
val.high = Alto val.high = Alto
val.normal = Normal val.normal = Normal
val.large = Grande val.large = Grande
val.small = Pequeño
val.auto_plain = Automático
# ---- key binding names # ---- key binding names
key.cam_forward = Cámara adelante key.cam_forward = Cámara adelante
@@ -313,12 +331,30 @@ hint.tower_max = Esta torre ya está mejorada al máximo.
hint.tower_where = Las torres solo pueden construirse en casillas de suelo reclamadas y vacías. hint.tower_where = Las torres solo pueden construirse en casillas de suelo reclamadas y vacías.
hint.tower_mana = Tus torres mágicas se han quedado sin maná. hint.tower_mana = Tus torres mágicas se han quedado sin maná.
job.repair = Reparando una torre job.repair = Reparando una torre
job.mend_bridge = Repara un puente
job.raise_wall = Levanta un muro
job.march = Marcha hacia una espada
job.attack_tower = Atacando una torre job.attack_tower = Atacando una torre
unit.jailer = Carcelero unit.jailer = Carcelero
unit.priestess = Sacerdotisa de llamas unit.priestess = Sacerdotisa de llamas
msg.hero_captured = ¡{0} noqueado y hecho prisionero! Botín: {1} de oro. msg.hero_captured = ¡{0} noqueado y hecho prisionero! Botín: {1} de oro.
msg.defected = ¡{0} se ha pasado a tu bando! msg.defected = ¡{0} se ha pasado a tu bando!
msg.beast_tamed = ¡Un {0} salvaje se rinde y se une a tu mazmorra!
gear.1 = equipo de hierro
gear.2 = equipo de acero
gear.3 = equipo rúnico
msg.gear_given = La forja entrega {0} a: {1}.
msg.trader_arrives = Un mercader ha cruzado tu portal: {0} por {1} de oro. Haz clic en él con la mano para comprar; espera un minuto.
msg.trader_sold = Comprado: {0}. Un placer, dice el mercader.
msg.trader_left = El mercader se encoge de hombros y se marcha por el portal.
trader.info = Ofrece {0} por {1} de oro (clic con la mano)
trader.left = Se va en {0} s
trader.rune = los planos de una torre de forja rúnica
trader.wyrm = una sierpe de brasas
trader.chainbreaker = un rompecadenas
trader.wood = 30 de madera
trader.mana = una poción que llena tu maná
msg.prisoner_died = {0} ha muerto bajo tortura. msg.prisoner_died = {0} ha muerto bajo tortura.
job.carry = Llevando a un prisionero job.carry = Llevando a un prisionero
job.pray = Rezando job.pray = Rezando
@@ -329,6 +365,10 @@ info.tortured = siendo torturado
info.prison = Prisioneros: {0}/{1} info.prison = Prisioneros: {0}/{1}
msg.wave_warn_dir = Los exploradores informan: ¡la oleada de héroes {0} llega a la puerta en 30 segundos! Planean entrar por el {1}. msg.wave_warn_dir = Los exploradores informan: ¡la oleada de héroes {0} llega a la puerta en 30 segundos! Planean entrar por el {1}.
msg.event_quake = ¡Terremoto! Se han derrumbado túneles en el {0}.
msg.event_lava = ¡La lava se desborda en el {0}!
msg.event_flood = ¡Inundación! El río se desborda en el {0}.
msg.event_vein = ¡Los diablillos informan de una nueva veta de oro en el {0}!
msg.wave_warn_two = Los exploradores informan: ¡la oleada de héroes {0} llega a la puerta en 30 segundos! Se dividen y entran por el {1} y por el {2}. msg.wave_warn_two = Los exploradores informan: ¡la oleada de héroes {0} llega a la puerta en 30 segundos! Se dividen y entran por el {1} y por el {2}.
dir.n = norte dir.n = norte
dir.ne = nordeste dir.ne = nordeste
@@ -340,7 +380,8 @@ dir.w = oeste
dir.nw = noroeste dir.nw = noroeste
val.auto = Auto ({0}x) val.auto = Auto ({0}x)
set.render_scale = Resolución 3D set.render_scale = Aspecto pixelado
set.pixel_rows_val = {0} líneas
set.debug_fps = Mostrar FPS set.debug_fps = Mostrar FPS
set.debug_frame_time = Mostrar ms por fotograma set.debug_frame_time = Mostrar ms por fotograma
set.debug_ping = Mostrar ping set.debug_ping = Mostrar ping
@@ -398,8 +439,25 @@ help.start_touch = Toca para empezar
tool.bridge.label = Puente tool.bridge.label = Puente
tool.bridge.name = Construir puente tool.bridge.name = Construir puente
tool.bridge.desc = Construye un puente sobre agua o lava. Arrastra desde tu propio suelo a través del río; el puente crece casilla a casilla desde tu territorio. tool.bridge.desc = Construye un puente sobre agua o lava. Arrastra desde tu propio suelo a través del río; el puente crece casilla a casilla desde tu territorio.
cost.bridge = {0} de oro/casilla sobre agua, {1} sobre lava tool.drawbridge.label = Levadizo
tool.drawbridge.name = Construir puente levadizo
tool.drawbridge.desc = Un puente que puedes alzar: arrástralo sobre el río para construirlo y haz clic para cambiar: abajo, arriba, automático (se alza cuando se acercan enemigos). La madera sobre agua arde, la piedra sobre lava no.
tool.wall.label = Muro
tool.wall.name = Levantar muro
tool.wall.desc = Planea muros en tu propio suelo vacío (arrastra otra vez para cancelar): los diablillos los levantan como tierra reforzada. Cierra túneles y pon puertas donde importan.
tool.ram.label = Ariete
tool.ram.name = Construir ariete
tool.ram.desc = Un ariete sale de tu forja (oro y madera). Sigue tu bandera de ataque y destroza la puerta y las torres de los caballeros.
tool.catapult.label = Catapulta
tool.catapult.name = Construir catapulta
tool.catapult.desc = Una catapulta sale de tu forja (oro y madera). Dispara a las torres de los caballeros desde lejos y sigue tu bandera hasta su puerta.
cost.bridge = {0} oro + {1} madera/casilla sobre agua, {2} oro sobre lava
cost.drawbridge = {0} oro + {1} madera/casilla sobre agua, {2} oro sobre lava
cost.gold_wood = {0} oro + {1} madera
hint.bridge_where = Los puentes deben empezar en tu propio suelo: arrastra desde tu territorio a través del río. hint.bridge_where = Los puentes deben empezar en tu propio suelo: arrastra desde tu territorio a través del río.
hint.bridge_fire = Los puentes de madera arden: aleja de ellos las trampas de fuego, el aliento de fuego y los rayos; los puentes de piedra sobre lava son ignífugos.
hint.wall_where = Solo puedes levantar muros en tu propio suelo vacío (sin sala, trampa ni puerta).
hint.siege_where = Las máquinas de asedio salen en tu propio suelo libre, y necesitas una forja.
hint.bridge_river = Los puentes solo pueden construirse sobre agua o lava. hint.bridge_river = Los puentes solo pueden construirse sobre agua o lava.
msg.hero_burned = ¡Un {0} ha caído en la lava! +{1} de oro. msg.hero_burned = ¡Un {0} ha caído en la lava! +{1} de oro.
msg.creature_burned = Tu {0} ha caído en la lava. msg.creature_burned = Tu {0} ha caído en la lava.
@@ -409,6 +467,22 @@ tile.lava = Lava
tile.lava_info = Lo que cae dentro arde. Los puentes de piedra lo cruzan. tile.lava_info = Lo que cae dentro arde. Los puentes de piedra lo cruzan.
tile.bridge = Puente de madera tile.bridge = Puente de madera
tile.lavabridge = Puente de piedra tile.lavabridge = Puente de piedra
tile.mushroom = Seta gigante
tile.mushroom_grown = Adulto: listo para talar.
tile.mushroom_growing = Creciendo: {0}% - solo se puede talar cuando esté adulto.
tile.grove = Bosquecillo de setas
tile.grove_info = Nadie puede conquistar este musgo. Aquí vuelven a crecer setas gigantes.
tile.tree = Árbol
tile.plant_info = Márcalo con el pico: un diablillo lo tala por {0} de madera.
tile.drawbridge = Puente levadizo
tile.drawbridge_up = Alzado
tile.drawbridge_down = Bajado
tile.drawbridge_auto = Automático (abajo)
tile.drawbridge_auto_up = Automático (arriba)
tile.obsidian = Obsidiana
tile.obsidian_info = Lava enfriada: se funde en {0} s
tile.bridge_burning = ¡Arde!
tile.bridge_worn = Dañado {0}%
tile.shard = Fragmento de tu núcleo de alma tile.shard = Fragmento de tu núcleo de alma
tile.shard_info = Reclama esta cámara para recuperar el fragmento. tile.shard_info = Reclama esta cámara para recuperar el fragmento.
@@ -430,6 +504,11 @@ msg.enemy_tower_destroyed = ¡Ha caído una {0} de los caballeros!
msg.vault_taken = Cámara acorazada tomada: {0} en tesoros son tuyos. msg.vault_taken = Cámara acorazada tomada: {0} en tesoros son tuyos.
msg.shard_taken = ¡Un fragmento de tu núcleo de alma vuelve a estar en tus manos! msg.shard_taken = ¡Un fragmento de tu núcleo de alma vuelve a estar en tus manos!
msg.bridge_torn = ¡Los caballeros han derribado uno de tus puentes! msg.bridge_torn = ¡Los caballeros han derribado uno de tus puentes!
msg.bridge_fire = ¡Uno de tus puentes está en llamas!
msg.drawbridge_up = El puente levadizo está alzado.
msg.drawbridge_down = El puente levadizo está bajado.
msg.drawbridge_auto = El puente levadizo se alza solo cuando se acercan enemigos.
msg.drawbridge_rises = ¡Se acercan enemigos: un puente levadizo se alza!
job.tear_bridge = Derribando un puente job.tear_bridge = Derribando un puente
job.patrol = De patrulla job.patrol = De patrulla
job.guard = De guardia job.guard = De guardia
@@ -533,7 +612,7 @@ camp.m29.story = Islas de lava, amo, con fuentes de maná hirviendo en el centro
camp.m20.title = El Gran Maestre camp.m20.title = El Gran Maestre
camp.m20.story = El Gran Maestre Valerian Escudoclaro espera en su ciudadela al otro lado del gran río, con el último fragmento y la puerta de la orden. Lleva cien años rezando, lustrando y preparándose. | Destruye su puerta y recupera el fragmento. Entonces tu núcleo estará completo, y todos los señores que alguna vez lo quisieron vendrán a por él a la vez. Cada cosa a su tiempo. camp.m20.story = El Gran Maestre Valerian Escudoclaro espera en su ciudadela al otro lado del gran río, con el último fragmento y la puerta de la orden. Lleva cien años rezando, lustrando y preparándose. | Destruye su puerta y recupera el fragmento. Entonces tu núcleo estará completo, y todos los señores que alguna vez lo quisieron vendrán a por él a la vez. Cada cosa a su tiempo.
camp.m30.title = El trono de las profundidades camp.m30.title = El trono de las profundidades
camp.m30.story = Pasó lo que dije: tu núcleo está completo y ahora todo el mundo lo quiere. Mossgrim y Lady Nightwell marchan juntos, y lo que queda de la Orden abre su puerta por última vez. | Destruye ambos núcleos de señores y la puerta de la orden. Después de esto, las profundidades tendrán exactamente un amo. Adivina quién. Pista: yo no, yo soy el del té. camp.m30.story = Pasó lo que dije: tu núcleo está entero y ahora todos lo quieren. Mossgrim y Lady Nightwell han excavado hasta la superficie, y el Alto Mariscal espera en su fortaleza del bosque: luz del día, pájaros, todo el lote. Asqueroso. | Sal de la roca, destruye los dos núcleos de los maestros y la puerta de la orden dentro de la fortaleza. Después, el mundo tendrá exactamente un maestro. Adivina quién. Pista: yo no, yo soy el del té.
camp.epilogue = El núcleo verde se desmorona, el azul se apaga y la puerta de la orden enmudece. En algún lugar de arriba, la Guardia Luminosa redacta un informe larguísimo, y las cartas de Lady Nightwell han dejado de llegar. | Dedotuerto ha preparado té. Vuelves a estar completo, amo. El inframundo es tuyo. camp.epilogue = El núcleo verde se desmorona, el azul se apaga y la puerta de la orden enmudece. En algún lugar de arriba, la Guardia Luminosa redacta un informe larguísimo, y las cartas de Lady Nightwell han dejado de llegar. | Dedotuerto ha preparado té. Vuelves a estar completo, amo. El inframundo es tuyo.
lair.arrivals = {0} camas libres: las nuevas criaturas llegan al {1}% de velocidad lair.arrivals = {0} camas libres: las nuevas criaturas llegan al {1}% de velocidad
job.help = Ayudando job.help = Ayudando
@@ -657,6 +736,7 @@ net.error = {0}
net.rooms = Partidas abiertas net.rooms = Partidas abiertas
net.no_rooms = No hay partidas abiertas. net.no_rooms = No hay partidas abiertas.
net.create = Crear partida net.create = Crear partida
net.map_size = Mapa {0}x{1} · {2} jugadores
net.coop_mission = Campaña cooperativa: misión {0} ({1}) net.coop_mission = Campaña cooperativa: misión {0} ({1})
net.coop_create = Campaña coop. net.coop_create = Campaña coop.
net.join = Unirse net.join = Unirse
@@ -687,6 +767,9 @@ net.ai_off = No
# rival masters (free game) # rival masters (free game)
newgame.difficulty = Rivales newgame.difficulty = Rivales
newgame.land = Terreno
newgame.land0 = Subterráneo
newgame.land1 = Mitad en superficie
diff.0 = Fácil diff.0 = Fácil
diff.1 = Normal diff.1 = Normal
diff.2 = Difícil diff.2 = Difícil
@@ -699,9 +782,14 @@ msg.rival1_attack = ¡Mossgrim, el señor verde, envía {0} criaturas contra ti!
msg.rival2_attack = ¡Lady Nightwell, la señora azul, envía {0} criaturas contra ti! msg.rival2_attack = ¡Lady Nightwell, la señora azul, envía {0} criaturas contra ti!
msg.rival1_out = Mossgrim ha sido derrotado: ¡su núcleo de alma se ha hecho añicos! msg.rival1_out = Mossgrim ha sido derrotado: ¡su núcleo de alma se ha hecho añicos!
msg.rival2_out = Lady Nightwell ha sido derrotada: ¡su núcleo de alma se ha hecho añicos! msg.rival2_out = Lady Nightwell ha sido derrotada: ¡su núcleo de alma se ha hecho añicos!
msg.rival3_attack = ¡Saffrax, el amo amarillo, envía {0} criaturas contra ti!
msg.rival3_out = Saffrax ha sido derrotado: ¡su núcleo de alma se ha hecho añicos!
msg.rival_unit_slain = ¡Cae un {0} de un señor rival! +{1} de oro. msg.rival_unit_slain = ¡Cae un {0} de un señor rival! +{1} de oro.
msg.wave_on_rival1 = Oleada {0}: {1} héroes marchan contra el señor verde. msg.wave_on_rival1 = Oleada {0}: {1} héroes marchan contra el señor verde.
msg.wave_on_rival2 = Oleada {0}: {1} héroes marchan contra la señora azul. msg.wave_on_rival2 = Oleada {0}: {1} héroes marchan contra la señora azul.
msg.wave_warn_on_master = Los exploradores informan: la oleada {0} llega en 30 segundos y va a por el maestro {1}.
msg.wave_on_master = Oleada {0}: {1} héroes marchan contra el maestro {2}.
msg.partner_out = ¡El corazón de tu socio IA ha caído: sigue luchando solo!
hud.rival1 = Verde hud.rival1 = Verde
hud.rival2 = Azul hud.rival2 = Azul
msg.creature_freed = ¡Un {0} rompe sus cadenas y se une a ti! msg.creature_freed = ¡Un {0} rompe sus cadenas y se une a ti!
@@ -790,6 +878,8 @@ best.b.torture.text = Los héroes capturados acaban aquí. Con tiempo y un carce
best.b.temple.text = Las oraciones curan el núcleo de alma y mantienen alta la moral. La sacerdotisa de llamas viene por un templo. best.b.temple.text = Las oraciones curan el núcleo de alma y mantienen alta la moral. La sacerdotisa de llamas viene por un templo.
best.b.aerie.text = Perchas y nidos para criaturas voladoras. Murciélagos, gárgolas, búhos e incluso el dragón de brasas necesitan sitio aquí. best.b.aerie.text = Perchas y nidos para criaturas voladoras. Murciélagos, gárgolas, búhos e incluso el dragón de brasas necesitan sitio aquí.
best.b.bridge.text = Madera sobre agua, piedra sobre lava. Los caballeros derriban tus puentes: reclama los suyos. best.b.bridge.text = Madera sobre agua, piedra sobre lava. Los caballeros derriban tus puentes: reclama los suyos.
best.b.drawbridge.text = Álzalo y el río vuelve a ser un muro. En automático se alza cuando se acercan enemigos y baja para tus criaturas.
best.b.wall.text = Un túnel excavado no es para siempre: tápialo y deja a los héroes un solo camino, a través de tu puerta.
best.b.steel.text = Placas de hierro remachadas sobre un muro reforzado. Nadie lo atraviesa rápido, ni siquiera los enanos. best.b.steel.text = Placas de hierro remachadas sobre un muro reforzado. Nadie lo atraviesa rápido, ni siquiera los enanos.
best.b.arrow.text = La flecha atraviesa hasta dos enemigos más detrás del objetivo. Barata y fiable. best.b.arrow.text = La flecha atraviesa hasta dos enemigos más detrás del objetivo. Barata y fiable.
best.b.cannon.text = Daño en área y retroceso: empuja a los caballeros a la lava. best.b.cannon.text = Daño en área y retroceso: empuja a los caballeros a la lava.
@@ -801,7 +891,7 @@ room.forge = Forja
tool.forge.label = Forja tool.forge.label = Forja
tool.forge.desc = Las criaturas fabrican aquí trampas y puertas: trabajo, no oro. Las piezas terminadas esperan en cajas hasta que un diablillo las instala. Con una forja de 6+ casillas vienen engranajillos. tool.forge.desc = Las criaturas fabrican aquí trampas y puertas: trabajo, no oro. Las piezas terminadas esperan en cajas hasta que un diablillo las instala. Con una forja de 6+ casillas vienen engranajillos.
unit.cogling = Engranajillo unit.cogling = Engranajillo
cat.traps = Trampas cat.traps = Construir
cost.work = {0} de trabajo en la forja cost.work = {0} de trabajo en la forja
job.forge = Forjando job.forge = Forjando
job.install = Instalando una trampa job.install = Instalando una trampa
@@ -878,6 +968,7 @@ research.now = Investigando: {0} ({1}%)
research.next = después: {0} research.next = después: {0}
research.done = Aquí no queda nada por investigar. research.done = Aquí no queda nada por investigar.
msg.researched = Investigado: ¡{0}! Ya está en el menú de construcción. msg.researched = Investigado: ¡{0}! Ya está en el menú de construcción.
msg.room_upgraded = Investigación: {0} alcanza el nivel {1}: funciona un cuarto mejor, y se nota.
msg.tool_research = Aún no investigado: las criaturas que estudian en la biblioteca trabajan en ello. msg.tool_research = Aún no investigado: las criaturas que estudian en la biblioteca trabajan en ello.
msg.script = {0} msg.script = {0}
@@ -915,6 +1006,9 @@ tool.plague.desc = Una enfermedad que corroe a los enemigos alrededor del punto
tool.quake.label = Temblor tool.quake.label = Temblor
tool.quake.name = Terremoto tool.quake.name = Terremoto
tool.quake.desc = La tierra tiembla: los enemigos alrededor del punto sufren daño y quedan aturdidos dos segundos. Tus criaturas no pierden el equilibrio. tool.quake.desc = La tierra tiembla: los enemigos alrededor del punto sufren daño y quedan aturdidos dos segundos. Tus criaturas no pierden el equilibrio.
tool.freeze.label = Escarcha
tool.freeze.name = Escarcha
tool.freeze.desc = Frío glacial: la lava alrededor se enfría en obsidiana transitable durante 40 segundos; los enemigos quedan helados y ralentizados.
tile.unexplored = Oscuridad inexplorada tile.unexplored = Oscuridad inexplorada
# personality # personality
@@ -967,6 +1061,9 @@ tool.arena.desc = Las criaturas combaten por parejas: el doble de experiencia qu
tool.altar.label = Altar tool.altar.label = Altar
tool.altar.desc = Suelta una criatura sobre el altar para sacrificarla. Ciertas parejas de sacrificios traen una criatura rara; si no, la mazmorra queda bendecida: mejor ánimo y maná. tool.altar.desc = Suelta una criatura sobre el altar para sacrificarla. Ciertas parejas de sacrificios traen una criatura rara; si no, la mazmorra queda bendecida: mejor ánimo y maná.
unit.revenant = Aparecido unit.revenant = Aparecido
unit.cavebear = Oso cavernario
unit.beetle = Escarabajo gigante
unit.trader = Mercader
job.guard_post = De guardia job.guard_post = De guardia
job.spar = Combatiendo job.spar = Combatiendo
msg.revenant_rises = ¡Un aparecido se levanta del cementerio y te sirve! msg.revenant_rises = ¡Un aparecido se levanta del cementerio y te sirve!
@@ -975,6 +1072,10 @@ msg.altar_gift = El altar acepta la pareja: ¡aparece un {0}!
msg.altar_blessing = El {0} ha sido sacrificado. La mazmorra queda bendecida. msg.altar_blessing = El {0} ha sido sacrificado. La mazmorra queda bendecida.
best.revenant.lure = se levanta del cementerio best.revenant.lure = se levanta del cementerio
best.revenant.text = Un aparecido no come, no duerme y no se queja: el empleado perfecto, si soportas el olor. | Con algo de experiencia, absorbe la fuerza de aquellos a quienes golpea. Él y la sacerdotisa no se llevan bien, por razones obvias. best.revenant.text = Un aparecido no come, no duerme y no se queja: el empleado perfecto, si soportas el olor. | Con algo de experiencia, absorbe la fuerza de aquellos a quienes golpea. Él y la sacerdotisa no se llevan bien, por razones obvias.
best.cavebear.lure = vencerlo en su guarida teniendo una prisión
best.cavebear.text = Un oso que nunca vio el sol y no lo echa de menos. Peludo, fuerte y gruñón hasta el desayuno. | Véncelo en su guarida mientras haya sitio en una prisión: se rinde y se une a ti. Le gusta el huerto de setas, y las arañas, de tentempié.
best.beetle.lure = vencerlo en su guarida teniendo una prisión
best.beetle.text = Un escarabajo del tamaño de un barril, acorazado como un caballero y el doble de educado. | Véncelo en su guarida mientras haya sitio en una prisión y se unirá a ti. Duerme en la guarida y no soporta a los murciélagos.
best.b.prison.text = Hileras de celdas. Los héroes se pudren aquí en silencio hasta que alguien decide qué hacer con ellos. best.b.prison.text = Hileras de celdas. Los héroes se pudren aquí en silencio hasta que alguien decide qué hacer con ellos.
best.b.graveyard.text = Musgo, lápidas y tierra fresca. Aquí abajo nadie sigue muerto mucho tiempo. best.b.graveyard.text = Musgo, lápidas y tierra fresca. Aquí abajo nadie sigue muerto mucho tiempo.
best.b.guardpost.text = Un brasero, un estandarte y una criatura aburrida de vista aguda. best.b.guardpost.text = Un brasero, un estandarte y una criatura aburrida de vista aguda.
@@ -987,23 +1088,39 @@ ability.drain.desc = se cura un tercio del daño que inflige.
unit.giant = Gigante unit.giant = Gigante
unit.fairy = Hada espía unit.fairy = Hada espía
unit.battlemage = Mago de batalla unit.battlemage = Mago de batalla
unit.thief = Ladrón
unit.monk = Monje
unit.ranger = Explorador
unit.inquisitor = Inquisidor
unit.ram = Ariete
unit.catapult = Catapulta
msg.trap_wrecked = Los héroes han destrozado una trampa: {0}. msg.trap_wrecked = Los héroes han destrozado una trampa: {0}.
best.giant.text = Un gigante es lo que pasa cuando un caballero se come la verdura durante trescientos años. Lento, enorme, y trata las puertas como una sugerencia. | Destroza trampas con solo plantarse a su lado. Modales repugnantes. Golpéalo con todo, a ser posible desde lejos. best.giant.text = Un gigante es lo que pasa cuando un caballero se come la verdura durante trescientos años. Lento, enorme, y trata las puertas como una sugerencia. | Destroza trampas con solo plantarse a su lado. Modales repugnantes. Golpéalo con todo, a ser posible desde lejos.
best.fairy.text = La pequeña chivata de la Orden: un mosquito brillante con alas, enviado por delante para encontrar tus trampas y contárselo a todos. Cuando ha visto una trampa, los héroes la destrozan. | También rocía polvo curativo sobre los héroes. Aplástala primero. Dos veces, por si acaso. best.fairy.text = La pequeña chivata de la Orden: un mosquito brillante con alas, enviado por delante para encontrar tus trampas y contárselo a todos. Cuando ha visto una trampa, los héroes la destrozan. | También rocía polvo curativo sobre los héroes. Aplástala primero. Dos veces, por si acaso.
best.battlemage.text = Un mago con el hábito de la Orden que considera los muros un insulto personal. Los muros reforzados revientan bajo sus bolas de fuego, y nunca deja de hablar de ello. | Frágil de cerca. Lo que más le fastidia son los muros de acero. Bien. best.battlemage.text = Un mago con el hábito de la Orden que considera los muros un insulto personal. Los muros reforzados revientan bajo sus bolas de fuego, y nunca deja de hablar de ello. | Frágil de cerca. Lo que más le fastidia son los muros de acero. Bien.
best.thief.text = Dedos rápidos, pies aún más rápidos. No viene a luchar: se cuela en tu tesorería, llena su saco y corre de vuelta a la puerta. | Mátalo antes de que escape y el oro vuelve a ser tuyo. Con intereses.
best.monk.text = Años de ayuno y oración lo han vuelto sordo a tu magia: el rayo, la plaga y el frío le resbalan como la lluvia por un tejado. | Los puños también duelen. Recíbelo con acero, garras y dientes.
best.ranger.text = Un hombre de los bosques de la Orden que dispara desde lejos y deja cepos de hierro en tus pasillos. La criatura que pisa uno queda herida y atrapada un rato. | Los cepos siguen ahí hasta que alguien los pisa. Mejor un kóbold que tu ogro.
best.inquisitor.text = Frágil, pero sus sermones son peligrosos: cada veinte segundos convence a una de tus criaturas más débiles (un kóbold o una araña) de unirse a los héroes. | Las criaturas fuertes se ríen de él. Golpea tú primero.
best.ram.text = Un tronco con punta de hierro y un tejado contra las flechas. Tus criaturas le dan igual; tus puertas, muros y torres no. | Detenlo antes de que llegue a la puerta, o búscate otra puerta.
best.catapult.text = Un brazo de madera, un cubo de piedras y un caballero con demasiado tiempo libre. Alcanza más lejos que tus torres y apunta a ellas primero. | Lánzate sobre ella: de cerca no hace daño ni a una mosca.
# orders # orders
tool.order_attack.label = Atacar tool.order_attack.label = Atacar
tool.order_attack.name = ¡Al ataque! tool.order_attack.name = ¡Al ataque!
tool.order_attack.desc = Haz clic en un enemigo o una torre enemiga: todas las criaturas combatientes a menos de 18 casillas van a por él. Clic derecho con una herramienta de órdenes cancela las órdenes de guardia. tool.order_attack.desc = Haz clic en un enemigo o una torre enemiga: todas las criaturas listas para luchar en 18 casillas van a por él. Haz clic en suelo libre: un marcador de espada; las criaturas listas se reparten entre todas las espadas y luchan allí. Clic derecho en un marcador lo quita.
tool.order_guard.label = Guardia tool.order_guard.label = Guardia
tool.order_guard.name = Vigilar esta sala tool.order_guard.name = Vigilar esta sala
tool.order_guard.desc = Haz clic en una sala: hasta tres criaturas cercanas toman posiciones allí durante dos minutos y atacan todo lo que aparezca a la vista. Clic derecho cancela. tool.order_guard.desc = Haz clic en tu propio suelo: un marcador de escudo que tres criaturas mantienen y defienden. Hasta ocho escudos; clic derecho en un escudo lo quita.
hint.order_attack = Haz clic en un enemigo o una torre enemiga. hint.order_attack = Haz clic en un enemigo o una torre enemiga.
hint.order_guard = Haz clic en una de tus salas (o en tu propio suelo). hint.order_guard = Haz clic en una de tus salas (o en tu propio suelo).
hint.order_nobody = Ninguna criatura está libre para esa orden. hint.order_nobody = Ninguna criatura está libre para esa orden.
msg.order_attack = ¡{0} criaturas atacan! msg.order_attack = ¡{0} criaturas atacan!
msg.order_guard = {0} criaturas toman posiciones. msg.order_guard = {0} criaturas toman posiciones.
msg.mark_attack = Una espada marca el sitio: tus criaturas listas marchan allí.
msg.mark_guard = Un escudo marca el sitio: tres criaturas lo mantendrán.
msg.mark_removed = Marcador quitado: sus criaturas vuelven a estar libres.
hint.marks_full = Ocho marcadores de ese tipo es el máximo. Quita uno con clic derecho.
msg.orders_cancelled = Órdenes de guardia canceladas. msg.orders_cancelled = Órdenes de guardia canceladas.
# creature overview # creature overview
@@ -1013,6 +1130,8 @@ ov.summary = {0} criaturas - sueldos {1} de oro por paga - ánimo {2}%
ov.none = Aún no hay criaturas. Construye una guarida y un huerto de setas y reclama un portal. ov.none = Aún no hay criaturas. Construye una guarida y un huerto de setas y reclama un portal.
ov.hint = Nv, ánimo, sueldo, tarea, salud - clic: ir allí ov.hint = Nv, ánimo, sueldo, tarea, salud - clic: ir allí
set.voice = Voz de Dedotuerto set.voice = Voz de Dedotuerto
set.track = Pista musical
val.track_auto = Automática
# ---- campaign editor, block scripts, demo campaign # ---- campaign editor, block scripts, demo campaign
menu.back_editor = Volver al editor menu.back_editor = Volver al editor
@@ -1085,6 +1204,7 @@ ed.forts = Fortalezas
ed.rivals = Señores rivales ed.rivals = Señores rivales
ed.difficulty = Dificultad ed.difficulty = Dificultad
ed.portals = Portales ed.portals = Portales
ed.surface = Superficie (este)
ed.edit_map = Editar el mapa ed.edit_map = Editar el mapa
ed.make_random = Hacerlo aleatorio ed.make_random = Hacerlo aleatorio
ed.paint_map = Generar y pintar el mapa ed.paint_map = Generar y pintar el mapa
@@ -1164,6 +1284,10 @@ ed.terrain.silver = Veta de plata
ed.terrain.ruby = Veta de rubíes ed.terrain.ruby = Veta de rubíes
ed.terrain.diamond = Veta de diamantes ed.terrain.diamond = Veta de diamantes
ed.terrain.steel = Muro de acero ed.terrain.steel = Muro de acero
ed.terrain.mushroom = Seta gigante
ed.terrain.grove = Bosquecillo de setas
ed.terrain.grass = Hierba (superficie)
ed.terrain.tree = Árbol
ed.place.core = Núcleo de alma ed.place.core = Núcleo de alma
ed.place.portal = Portal ed.place.portal = Portal
ed.place.gate = Puerta de héroes ed.place.gate = Puerta de héroes
@@ -1194,6 +1318,7 @@ ed.level = Nivel
ed.zone_shape = Forma de la nueva zona ed.zone_shape = Forma de la nueva zona
ed.zone_new_name = Nombre de la nueva zona ed.zone_new_name = Nombre de la nueva zona
ed.zones = Zonas ed.zones = Zonas
ed.zone_erase = Quitar casillas
ed.shape.rect = Rectángulo ed.shape.rect = Rectángulo
ed.shape.circle = Círculo ed.shape.circle = Círculo
ed.shape.tiles = Casillas ed.shape.tiles = Casillas
@@ -1243,6 +1368,8 @@ blk.reveal = Revelar
blk.camera = Mostrar blk.camera = Mostrar
blk.gold = Oro + blk.gold = Oro +
blk.mana = Maná + blk.mana = Maná +
blk.weather = Clima en la superficie
blk.talentxp = Experiencia de talentos +
blk.if = Si blk.if = Si
blk.repeat = Repetir blk.repeat = Repetir
blk.trigger = Conmutar disparador blk.trigger = Conmutar disparador
@@ -1262,6 +1389,8 @@ blk.help.reveal = Levanta la niebla de guerra sobre una zona.
blk.help.camera = Mueve la cámara a una zona. blk.help.camera = Mueve la cámara a una zona.
blk.help.gold = Da (o quita) oro. blk.help.gold = Da (o quita) oro.
blk.help.mana = Da (o quita) maná. blk.help.mana = Da (o quita) maná.
blk.help.weather = Fija el clima en la superficie (despejado, lluvia, niebla o tormenta) durante tantos segundos; 0 lo mantiene hasta que otro bloque lo cambie.
blk.help.talentxp = Da al jugador experiencia para los talentos del maestro.
blk.help.if = Ejecuta los bloques de dentro solo si se cumple la condición; si no, los de «si no». blk.help.if = Ejecuta los bloques de dentro solo si se cumple la condición; si no, los de «si no».
blk.help.repeat = Ejecuta varias veces los bloques de dentro. blk.help.repeat = Ejecuta varias veces los bloques de dentro.
blk.help.trigger = Activa un disparador (puede volver a saltar) o lo desactiva. blk.help.trigger = Activa un disparador (puede volver a saltar) o lo desactiva.
@@ -1284,6 +1413,7 @@ blk.v.or = o
blk.v.not = no blk.v.not = no
blk.v.bool = Sí/no blk.v.bool = Sí/no
blk.v.eventkind = El causante es un blk.v.eventkind = El causante es un
blk.v.weatheris = El clima en la superficie es
blk.w_at = en blk.w_at = en
blk.w_by = en blk.w_by = en
blk.w_in = en blk.w_in = en
@@ -1328,14 +1458,19 @@ demo.m2.title = Otra tirada
demo.m2.story = Este mapa se genera de nuevo en cada partida de la campaña. Derrota a todos los héroes. demo.m2.story = Este mapa se genera de nuevo en cada partida de la campaña. Derrota a todos los héroes.
demo.m2.start = Un mapa aleatorio. Ni siquiera yo lo había visto antes. demo.m2.start = Un mapa aleatorio. Ni siquiera yo lo había visto antes.
demo.m2.wave = Han pasado dos minutos: llegan los héroes. demo.m2.wave = Han pasado dos minutos: llegan los héroes.
net.coop_editor = Editor campaign net.coop_editor = Campaña del editor
net.coop_builtin = Built-in campaign net.coop_builtin = Campaña integrada
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_league = Liga contra la Guardia Luminosa
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) net.coop_solo = Socio IA
net.coop_solo_started = Cooperativo sin servidor: tu socio IA construye su propia mazmorra, sigue tu bandera de ataque y te ayuda con oro.
net.coop_resume = Seguir M{0}
net.coop_editor_mission = Campaña del editor {0}: misión {1} ({2})
net.coop_editor_none = Ninguna misión de la campaña del editor sirve para cooperativo (mapa de al menos 72x72, un objetivo aparte de los maestros rivales)
blk.until = Repetir hasta blk.until = Repetir hasta
blk.dountil = Repetir y luego: hasta blk.dountil = Repetir y luego: hasta
blk.wait = Esperar blk.wait = Esperar
blk.w_seconds = segundos blk.w_seconds = segundos
blk.w_for = durante
blk.help.until = Ejecuta los bloques de dentro una y otra vez hasta que se cumpla la condición; se comprueba antes de cada vuelta, así que si ya se cumple no se ejecuta nada. blk.help.until = Ejecuta los bloques de dentro una y otra vez hasta que se cumpla la condición; se comprueba antes de cada vuelta, así que si ya se cumple no se ejecuta nada.
blk.help.dountil = Ejecuta los bloques de dentro, luego comprueba la condición y repite hasta que se cumpla; el contenido se ejecuta al menos una vez. blk.help.dountil = Ejecuta los bloques de dentro, luego comprueba la condición y repite hasta que se cumpla; el contenido se ejecuta al menos una vez.
blk.help.wait = Detiene este script unos segundos y luego sigue con el siguiente bloque (también dentro de bucles). blk.help.wait = Detiene este script unos segundos y luego sigue con el siguiente bloque (también dentro de bucles).
@@ -1356,130 +1491,280 @@ net.coop_wait_host = Esperando a que el anfitrión inicie la siguiente misión..
net.coop_run_over = Volver al vestíbulo net.coop_run_over = Volver al vestíbulo
# ---- co-op campaign "League against the Lightwatch" # ---- co-op campaign "League against the Lightwatch"
coop.name = League against the Lightwatch coop.name = Liga contra la Guardia Luminosa
coop.raid = Mossgrim's creatures crawl out of the dark! coop.raid = ¡Las criaturas de Mossgrim salen arrastrándose de la oscuridad!
coop.m01.title = Two Hearts, One Crookfinger coop.m01.title = Dos corazones, un Dedotuerto
coop.m01.story = Masters, plural. I could hardly believe it either. The Order of the Lightwatch has made a pact with Mossgrim, that mossy turncoat, to smoke out every dungeon under the hills.\n\nAlone, you would be smoked. Together you might just be the smoke. Dig in side by side and show the patrol nearby what a shared basement means. coop.m01.story = Maestros, en plural. Yo tampoco me lo creía. La Orden de la Guardia Luminosa ha sellado un pacto con Mossgrim, ese traidor mohoso, para ahumar todas las mazmorras bajo las colinas.\n\nSolos, acabaríais ahumados. Juntos, quizá seáis vosotros el humo. Excavad codo con codo y enseñadle a la patrulla cercana lo que significa un sótano compartido.
coop.m01.start = Two masters, one advisor. My wages, by the way, stay the same. coop.m01.start = Dos maestros, un consejero. Mi sueldo, por cierto, sigue siendo el mismo.
coop.m01.goal = Defeat every knight. coop.m01.goal = Derrotad a todos los caballeros.
coop.m01.t1 = Share the work: one digs, one builds. Or both dig. I am flexible. coop.m01.t1 = Repartíos el trabajo: uno excava, el otro construye. O excaváis los dos. Soy flexible.
coop.m01.t2 = The patrol is getting curious. Good. Curiosity is a lovely snack. coop.m01.t2 = La patrulla se está poniendo curiosa. Bien. La curiosidad es un tentempié delicioso.
coop.m02.title = The First Outpost coop.m02.title = El primer puesto avanzado
coop.m02.story = Across the river the Lightwatch has built an outpost with shiny towers. Shiny things must be broken - that is the oldest rule of the underworld.\n\nBridge the water, bring the towers down, and do it together before they call their friends. coop.m02.story = Al otro lado del río, la Guardia Luminosa ha levantado un puesto avanzado con torres relucientes. Lo reluciente hay que romperlo: es la regla más antigua del inframundo.\n\nTended un puente sobre el agua, derribad las torres y hacedlo juntos antes de que llamen a sus amigos.
coop.m02.start = Towers. Polished, pointy, insulting. Let us fix that. coop.m02.start = Torres. Pulidas, puntiagudas, ofensivas. Arreglemos eso.
coop.m02.goal = Raze the knight towers. coop.m02.goal = Arrasad las torres de los caballeros.
coop.m02.t1 = Bridges are like friendships, masters: build them, then march an army over them. coop.m02.t1 = Los puentes son como las amistades, maestros: se construyen y luego se hace marchar un ejército por encima.
coop.m02.t2 = One tower down is a start. All towers down is a party. coop.m02.t2 = Una torre caída es un comienzo. Todas las torres caídas son una fiesta.
coop.m03.title = Tracks in the Moss coop.m03.title = Huellas en el musgo
coop.m03.story = Something green and wet is lurking in the tunnels. Mossgrim has lent the knights his creatures, and they come through the hero gate in waves.\n\nHold out. Hold together. Hold your noses. coop.m03.story = Algo verde y húmedo acecha en los túneles. Mossgrim les ha prestado sus criaturas a los caballeros, y llegan por la Puerta de los Héroes en oleadas.\n\nAguantad. Permaneced unidos. Tapaos la nariz.
coop.m03.start = Mossgrim's ogres smell of wet carpet. You will know when they come. coop.m03.start = Los ogros de Mossgrim huelen a alfombra mojada. Sabréis cuándo llegan.
coop.m03.goal = Survive three waves. coop.m03.goal = Sobrevivid a tres oleadas.
coop.m03.t1 = The first ogres are here. Mossgrim never could keep his pets at home. coop.m03.t1 = Ya están aquí los primeros ogros. Mossgrim nunca supo tener a sus mascotas en casa.
coop.m03.t2 = Keep your hearts apart, masters - one ogre should never reach both of them. coop.m03.t2 = Mantened vuestros corazones separados, maestros: un solo ogro nunca debería alcanzar los dos.
coop.m04.title = The Stolen Springs coop.m04.title = Las fuentes robadas
coop.m04.story = Two mana springs bubble on islands in the lake, and the knights have claimed them as their holy water. Holy water, honestly.\n\nTake both springs. And if the knights' vault happens to fall into your hands first, Crookfinger knows a shortcut. coop.m04.story = Dos fuentes de maná burbujean en islas del lago, y los caballeros las han reclamado como su agua bendita. Agua bendita, francamente.\n\nTomad ambas fuentes. Y si la cámara acorazada de los caballeros cae antes en vuestras manos, Dedotuerto conoce un atajo.
coop.m04.start = Mana tastes better when it was someone else's. coop.m04.start = El maná sabe mejor cuando era de otro.
coop.m04.goal = Claim two mana springs. coop.m04.goal = Reclamad dos fuentes de maná.
coop.m04.branch = Rich enough for the shortcut coop.m04.branch = Lo bastante ricos para el atajo
coop.m04.t1 = Islands need bridges. Or wings. Bats are wings with teeth. coop.m04.t1 = Las islas necesitan puentes. O alas. Los murciélagos son alas con dientes.
coop.m04.t2 = That vault looks heavy. Heavy things should be carried home. coop.m04.t2 = Esa cámara acorazada parece pesada. Las cosas pesadas hay que llevarlas a casa.
coop.m05.title = Chains in the Rock coop.m05.title = Cadenas en la roca
coop.m05.story = The Lightwatch keeps creatures in chains in sealed caves - creatures that refused to bow. An ogre and a mage, both furious, both available.\n\nDig to them, claim their caves, and the chains will fall. coop.m05.story = La Guardia Luminosa mantiene encadenadas en cuevas selladas a criaturas que se negaron a inclinarse. Un ogro y un mago, ambos furiosos, ambos disponibles.\n\nExcavad hasta ellos, reclamad sus cuevas y las cadenas caerán.
coop.m05.start = Prisoners! Ours, soon. Free ones, I mean. Free to work for you. coop.m05.start = ¡Prisioneros! Pronto nuestros. Libres, quiero decir. Libres para trabajar para vosotros.
coop.m05.goal = Free two chained creatures. coop.m05.goal = Liberad a dos criaturas encadenadas.
coop.m05.t1 = Listen. Do you hear the ogre swearing? That is gratitude in advance. coop.m05.t1 = Escuchad. ¿Oís al ogro maldecir? Es gratitud por adelantado.
coop.m05.t2 = Spiders from the moss again. Mossgrim's nursery is busy. coop.m05.t2 = Otra vez arañas del musgo. La guardería de Mossgrim no para.
coop.m06.title = Bridge of the Watch coop.m06.title = El puente de la Guardia
coop.m06.story = Two rivers, one fortress, and the Lightwatch's finest archers on the walls. They call it the Bridge of the Watch. We will call it the bridge that was.\n\nBreak their towers. Leave the bridge - we might need it. coop.m06.story = Dos ríos, una fortaleza y los mejores arqueros de la Guardia Luminosa en las murallas. Lo llaman el Puente de la Guardia. Nosotros lo llamaremos el puente que fue.\n\nRomped sus torres. Dejad el puente: puede que lo necesitemos.
coop.m06.start = Archers on walls are like pigeons on roofs: loud and easy to remove. coop.m06.start = Los arqueros en las murallas son como las palomas en los tejados: ruidosos y fáciles de quitar.
coop.m06.goal = Raze the knight towers. coop.m06.goal = Arrasad las torres de los caballeros.
coop.m06.t1 = Two rivers mean two bridges. Or one very wet army. coop.m06.t1 = Dos ríos significan dos puentes. O un ejército muy mojado.
coop.m06.t2 = The knights are polishing their armour. Let us give them a reason. coop.m06.t2 = Los caballeros están puliendo sus armaduras. Démosles un motivo.
coop.m07.title = The Lava Smithy coop.m07.title = La forja de lava
coop.m07.story = Between two rivers of lava the knights forge their blessed blades. Their vault overflows with gold paid for by every dungeon they emptied.\n\nWe are taking it back. All of it. And then some. coop.m07.story = Entre dos ríos de lava, los caballeros forjan sus espadas benditas. Su cámara acorazada rebosa de oro pagado por cada mazmorra que vaciaron.\n\nVamos a recuperarlo. Todo. Y un poco más.
coop.m07.start = Warm feet, cold hearts, full pockets: my kind of evening. coop.m07.start = Pies calientes, corazones fríos, bolsillos llenos: mi tipo de velada.
coop.m07.goal = Loot gold from the knights. coop.m07.goal = Saquead el oro de los caballeros.
coop.m07.t1 = Do not stand too close to the lava. Your creatures, I mean. You are fine. coop.m07.t1 = No os acerquéis demasiado a la lava. Vuestras criaturas, quiero decir. Vosotros estáis bien.
coop.m07.t2 = More knights from the east. They really love their gold. coop.m07.t2 = Más caballeros desde el este. De verdad que adoran su oro.
coop.m08.title = The Field Hospital coop.m08.title = El hospital de campaña
coop.m08.story = The Lightwatch has set up a field hospital in the caves, and its priests patch up every knight you knock down.\n\nEnd the treatment. Permanently. coop.m08.story = La Guardia Luminosa ha montado un hospital de campaña en las cuevas, y sus sacerdotes remiendan a cada caballero que derribáis.\n\nPoned fin al tratamiento. Para siempre.
coop.m08.start = Priests. They heal. We do the opposite. It is a balanced relationship. coop.m08.start = Sacerdotes. Ellos curan. Nosotros hacemos lo contrario. Es una relación equilibrada.
coop.m08.goal = Defeat every knight. coop.m08.goal = Derrotad a todos los caballeros.
coop.m08.t1 = Every minute a new priest arrives. Kill faster, masters. coop.m08.t1 = Cada minuto llega un sacerdote nuevo. Matad más rápido, maestros.
coop.m08.t2 = A dungeon without hospital bills. That is what I call efficient. coop.m08.t2 = Una mazmorra sin facturas de hospital. Eso es lo que yo llamo eficiencia.
coop.m09.title = Mossgrim's Ambassador coop.m09.title = El embajador de Mossgrim
coop.m09.story = Mossgrim has sent an ambassador to the knights: a paladin in green-stained armour who carries his master's promises.\n\nPromises should never arrive. Find the ambassador and silence him. coop.m09.story = Mossgrim ha enviado un embajador a los caballeros: un paladín con la armadura manchada de verde que lleva las promesas de su amo.\n\nLas promesas nunca deberían llegar. Encontrad al embajador y silenciadlo.
coop.m09.start = An ambassador. A messenger with a fancy hat. Hats come off. coop.m09.start = Un embajador. Un mensajero con sombrero elegante. Los sombreros se caen.
coop.m09.goal = Defeat Mossgrim's ambassador. coop.m09.goal = Derrotad al embajador de Mossgrim.
coop.m09.boss = The ambassador has fallen. His message: undelivered. coop.m09.boss = El embajador ha caído. Su mensaje: sin entregar.
coop.m09.t1 = The ambassador waits in the east, surrounded by ogres who lost their sense of humour. coop.m09.t1 = El embajador espera en el este, rodeado de ogros que han perdido el sentido del humor.
coop.m09.t2 = Somewhere Mossgrim is sending another letter. Let us make it a short one. coop.m09.t2 = En algún lugar, Mossgrim está enviando otra carta. Que sea cortita.
coop.m10.title = The Twin Towers coop.m10.title = Las torres gemelas
coop.m10.story = Two fortresses guard the way north, each watching the other's back. Strike one and the other answers.\n\nStrike both. At once. That is what two masters are for. coop.m10.story = Dos fortalezas guardan el camino al norte, cada una cubriendo las espaldas de la otra. Golpead una y la otra responde.\n\nGolpead las dos. A la vez. Para eso hay dos maestros.
coop.m10.start = Two fortresses, two masters. Fair is fair. coop.m10.start = Dos fortalezas, dos maestros. Lo justo es justo.
coop.m10.goal = Raze the knight towers. coop.m10.goal = Arrasad las torres de los caballeros.
coop.m10.t1 = Split up, masters - one fortress each. I will cheer for both. coop.m10.t1 = Separaos, maestros: una fortaleza para cada uno. Yo animaré a los dos.
coop.m10.t2 = When both fall at once, the knights will not even know whom to blame. coop.m10.t2 = Si las dos caen a la vez, los caballeros ni sabrán a quién echarle la culpa.
coop.m11.title = The Siege coop.m11.title = El asedio
coop.m11.story = The Lightwatch has found your hearts and brings everything: five waves of knights and Mossgrim's gargoyles from the cliffs.\n\nNo cleverness this time. Just walls, traps, towers - and each other. coop.m11.story = La Guardia Luminosa ha encontrado vuestros corazones y lo trae todo: cinco oleadas de caballeros y las gárgolas de Mossgrim desde los acantilados.\n\nEsta vez nada de astucias. Solo muros, trampas, torres... y el uno al otro.
coop.m11.start = They are coming. All of them. Well, most of them. coop.m11.start = Ya vienen. Todos. Bueno, casi todos.
coop.m11.goal = Survive five waves. coop.m11.goal = Sobrevivid a cinco oleadas.
coop.m11.t1 = Doors and traps, masters. A siege is just guests who refuse to use the bell. coop.m11.t1 = Puertas y trampas, maestros. Un asedio no es más que invitados que se niegan a tocar el timbre.
coop.m11.t2 = Gargoyles from the south. Stone with wings - nobody asked for that. coop.m11.t2 = Gárgolas desde el sur. Piedra con alas: nadie pidió eso.
coop.m12.title = The Gate coop.m12.title = La puerta
coop.m12.story = The hero gate in this cavern is how the knights reach the deep dungeons. Close it and their reinforcements must walk the long way round.\n\nBreak the gate, and the Lightwatch will feel it all the way up to the High Marshal. coop.m12.story = La Puerta de los Héroes de esta caverna es el camino de los caballeros hacia las mazmorras profundas. Cerradla y sus refuerzos tendrán que dar el gran rodeo.\n\nRomped la puerta y la Guardia Luminosa lo notará hasta el Gran Mariscal.
coop.m12.start = A gate. Gates are just walls with ambitions. coop.m12.start = Una puerta. Las puertas no son más que muros con ambiciones.
coop.m12.goal = Destroy the hero gate. coop.m12.goal = Destruid la Puerta de los Héroes.
coop.m12.t1 = Waves keep coming out of it. Annoying, like a dripping tap. coop.m12.t1 = No paran de salir oleadas. Molesto, como un grifo que gotea.
coop.m12.t2 = Push, masters! The gate is cracking. So is my patience. coop.m12.t2 = ¡Empujad, maestros! La puerta se está agrietando. Y mi paciencia también.
coop.m13.title = Mossy Tunnels coop.m13.title = Túneles musgosos
coop.m13.story = We are in Mossgrim's backyard now. His tunnels are soft and damp, and two of his hoards lie in sealed caves.\n\nDig, take, leave. Mossgrim will notice. That is the point. coop.m13.story = Ahora estamos en el patio trasero de Mossgrim. Sus túneles son blandos y húmedos, y dos de sus tesoros yacen en cuevas selladas.\n\nExcavad, tomad, marchaos. Mossgrim se dará cuenta. De eso se trata.
coop.m13.start = It smells of mushrooms and treachery. Mossgrim must be near. coop.m13.start = Huele a setas y a traición. Mossgrim debe de andar cerca.
coop.m13.goal = Loot gold. coop.m13.goal = Saquead el oro.
coop.m13.t1 = Spiders, again. Mossgrim breeds them like other people grow tomatoes. coop.m13.t1 = Arañas, otra vez. Mossgrim las cría como otros cultivan tomates.
coop.m13.t2 = His hoards are bigger than I thought. Our pockets are bigger still. coop.m13.t2 = Sus tesoros son más grandes de lo que pensaba. Nuestros bolsillos, más aún.
coop.m14.title = Poison in the Water coop.m14.title = Veneno en el agua
coop.m14.story = Mossgrim has turned his dead into revenants and sends them from two sides. The knights call it holy work. Typical.\n\nDefeat every knight and every walking corpse. Then wash your hands. coop.m14.story = Mossgrim ha convertido a sus muertos en aparecidos y los envía desde dos lados. Los caballeros lo llaman obra santa. Típico.\n\nDerrotad a todos los caballeros y a todos los cadáveres andantes. Luego lavaos las manos.
coop.m14.start = Revenants. Our trick, stolen. I am offended. coop.m14.start = Aparecidos. Nuestro truco, robado. Me siento ofendido.
coop.m14.goal = Defeat every enemy. coop.m14.goal = Derrotad a todos los enemigos.
coop.m14.t1 = They come from the north and the south. Back to back, masters. coop.m14.t1 = Vienen del norte y del sur. Espalda con espalda, maestros.
coop.m14.t2 = A dead thing that walks is a dead thing that has not finished its job yet. coop.m14.t2 = Un muerto que camina es un muerto que todavía no ha terminado su trabajo.
coop.m15.title = The Chainbreaker coop.m15.title = El Rompecadenas
coop.m15.story = Deep in the rock the knights keep the Chainbreaker, a creature even they fear. Free it, and it will fight for whoever opened its cell.\n\nOr grab enough gold for the long way round - Crookfinger knows a smuggler. coop.m15.story = En lo profundo de la roca, los caballeros tienen al Rompecadenas, una criatura que hasta ellos temen. Liberadlo y luchará por quien abra su celda.\n\nO reunid oro suficiente para el gran rodeo: Dedotuerto conoce a un contrabandista.
coop.m15.start = The Chainbreaker. Big, angry, misunderstood. A bit like me. coop.m15.start = El Rompecadenas. Grande, furioso, incomprendido. Un poco como yo.
coop.m15.goal = Free the Chainbreaker and raze the towers. coop.m15.goal = Liberad al Rompecadenas y arrasad las torres.
coop.m15.branch = Bought the smuggler's map coop.m15.branch = Comprado el mapa del contrabandista
coop.m15.t1 = The cell is in the north-west. Mind its temper. coop.m15.t1 = La celda está al noroeste. Cuidado con su genio.
coop.m15.t2 = The smuggler is waiting. Smugglers hate waiting. And love gold. coop.m15.t2 = El contrabandista espera. Los contrabandistas odian esperar. Y adoran el oro.
coop.m16.title = The Burning Spring coop.m16.title = La fuente ardiente
coop.m16.story = In the middle of a lava lake lies an island with a mana spring that never runs dry. Mossgrim's mages guard it.\n\nTake the spring, defeat the guards. Then sit on the island and enjoy the warmth. coop.m16.story = En medio de un lago de lava hay una isla con una fuente de maná que nunca se seca. La custodian los magos de Mossgrim.\n\nTomad la fuente, derrotad a los guardias. Luego sentaos en la isla y disfrutad del calorcito.
coop.m16.start = A spring in lava. Whoever thought of that has style. Shame it is Mossgrim. coop.m16.start = Una fuente en la lava. Quien lo pensó tiene estilo. Lástima que sea Mossgrim.
coop.m16.goal = Claim the spring and defeat every enemy. coop.m16.goal = Reclamad la fuente y derrotad a todos los enemigos.
coop.m16.t1 = Mages from the east. Their spells are green and so are their teeth. coop.m16.t1 = Magos desde el este. Sus hechizos son verdes, y sus dientes también.
coop.m16.t2 = Once the spring is ours, mana for everyone. Share nicely. coop.m16.t2 = En cuanto la fuente sea nuestra, maná para todos. Compartid como es debido.
coop.m17.title = Mossgrim's Garden coop.m17.title = El jardín de Mossgrim
coop.m17.story = This is it: Mossgrim's garden, a cavern of mushrooms and creatures loyal to him. Ogres in the east, an ember wyrm in the north.\n\nTrample the garden. Every mushroom. coop.m17.story = Ha llegado el momento: el jardín de Mossgrim, una caverna de setas y criaturas leales a él. Ogros en el este, un dragón de brasas en el norte.\n\nPisotead el jardín. Cada seta.
coop.m17.start = Look at all those mushrooms. Pity about the owner. coop.m17.start = Mirad cuántas setas. Lástima del dueño.
coop.m17.goal = Defeat every enemy. coop.m17.goal = Derrotad a todos los enemigos.
coop.m17.t1 = An ember wyrm! Mossgrim must have saved up for years. coop.m17.t1 = ¡Un dragón de brasas! Mossgrim debe de haber ahorrado durante años.
coop.m17.t2 = The garden is wilting. Gardening is not your strength, masters. That is a compliment. coop.m17.t2 = El jardín se marchita. La jardinería no es vuestro fuerte, maestros. Es un cumplido.
coop.m18.title = The Last Fortress coop.m18.title = La última fortaleza
coop.m18.story = Three fortresses guard the stairs down to the High Marshal's hall. They are the last thing between you and the end of the Lightwatch's crusade.\n\nRaze them all. Then the stairs are yours. coop.m18.story = Tres fortalezas guardan la escalera que baja a la sala del Gran Mariscal. Son lo último que se interpone entre vosotros y el fin de la cruzada de la Guardia Luminosa.\n\nArrasadlas todas. Entonces la escalera será vuestra.
coop.m18.start = Three fortresses. They really did save the best for last. coop.m18.start = Tres fortalezas. De verdad que dejaron lo mejor para el final.
coop.m18.goal = Raze the knight towers. coop.m18.goal = Arrasad las torres de los caballeros.
coop.m18.t1 = Each master a fortress, and the third one together. I will count. coop.m18.t1 = Una fortaleza para cada maestro, y la tercera juntos. Yo llevaré la cuenta.
coop.m18.t2 = I can see the stairs already. Dusty. Nobody comes down here voluntarily. coop.m18.t2 = Ya veo la escalera. Polvorienta. Nadie baja aquí por gusto.
coop.m19.title = The High Marshal coop.m19.title = El Gran Mariscal
coop.m19.story = The High Marshal of the Lightwatch himself, in armour polished for a hundred years. His guards come from north and south.\n\nBring him down, and the Order will have nobody left to give the orders. coop.m19.story = El mismísimo Gran Mariscal de la Guardia Luminosa, con una armadura pulida durante cien años. Sus guardias llegan del norte y del sur.\n\nDerribadlo y a la Orden no le quedará nadie que dé las órdenes.
coop.m19.start = The High Marshal. I have dreamed of this. Mostly the part where he falls over. coop.m19.start = El Gran Mariscal. He soñado con esto. Sobre todo con la parte en que se cae.
coop.m19.goal = Defeat the High Marshal. coop.m19.goal = Derrotad al Gran Mariscal.
coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped cutlery drawer. coop.m19.boss = ¡El Gran Mariscal ha caído! Su armadura tintinea como un cajón de cubiertos volcado.
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first. coop.m19.t1 = Espera en el este. Que espere. Acabad primero con los guardias.
coop.m19.t2 = Knights and archers again. The Order is running out of ideas. coop.m19.t2 = Otra vez caballeros y arqueros. A la Orden se le acaban las ideas.
coop.m20.title = Mossgrim's Fall coop.m20.title = La caída de Mossgrim
coop.m20.story = With the High Marshal gone, Mossgrim stands alone. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nEnd the traitor. End his army. And then, masters - tea. coop.m20.story = Caído el Alto Mariscal, Mossgrim está solo: arriba, en el bosque, al sol, nada menos. Ha tomado la forma de una sierpe de brasas y ha reunido a toda criatura que aún le obedece.\n\nSalid de la roca y acabad con el traidor. Acabad con su ejército. Y luego, maestros: té.
coop.m20.start = Mossgrim, you old fungus. The league sends its regards. coop.m20.start = Mossgrim, viejo hongo. La liga te manda saludos.
coop.m20.goal = Defeat Mossgrim and all his creatures. coop.m20.goal = Derrotad a Mossgrim y a todas sus criaturas.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not. coop.m20.boss = Mossgrim ya no existe. El musgo volverá a crecer. Él no.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go. coop.m20.t1 = Ese dragón del este es Mossgrim. Sí, se ha descuidado bastante.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong. coop.m20.t2 = Cada criatura que derrotáis es una menos que contará mal esta historia.
# achievements and statistics
menu.achievements = Logros
ach.count = {0} de {1} logros desbloqueados · {2} minutos jugados
ach.hidden = Un secreto. Dedotuerto no suelta prenda.
msg.achievement = Logro desbloqueado: ¡{0}!
end.stats2 = Héroes {0} · Criaturas perdidas {1} · Casillas excavadas {2} · Oro {3} · Madera {4}
ach.heroes.name = Azote de héroes {0}
ach.heroes.desc = Derrota a {0} héroes.
ach.bosses.name = Rompecampeones {0}
ach.bosses.desc = Derriba a {0} campeones de la Orden.
ach.dug.name = Excavador profundo {0}
ach.dug.desc = Excava {0} casillas.
ach.gold.name = Acaparador {0}
ach.gold.desc = Gana {0} de oro.
ach.wood.name = Señor de la madera {0}
ach.wood.desc = Tala {0} de madera.
ach.walls.name = Albañil {0}
ach.walls.desc = Levanta {0} muros.
ach.rooms.name = Arquitecto de las sombras {0}
ach.rooms.desc = Construye {0} casillas de sala.
ach.spells.name = Hechicero oscuro {0}
ach.spells.desc = Lanza {0} hechizos.
ach.slaps.name = Mano firme {0}
ach.slaps.desc = Abofetea a tus criaturas {0} veces. Les encanta. Seguramente.
ach.traps.name = Forjatrampas {0}
ach.traps.desc = Encarga {0} trampas y puertas en la forja.
ach.towers.name = Señor de las torres {0}
ach.towers.desc = Construye {0} torres.
ach.drowned.name = Clases de natación {0}
ach.drowned.desc = Haz que {0} héroes caigan al agua o a la lava.
ach.burnt.name = Quemapuentes {0}
ach.burnt.desc = Quema {0} tablones de puente.
ach.siege.name = Maestro de asedio {0}
ach.siege.desc = Construye {0} máquinas de asedio.
ach.captured.name = Carcelero {0}
ach.captured.desc = Captura a {0} héroes.
ach.drawbridge.name = ¡Arriba con él! {0}
ach.drawbridge.desc = Alza puentes levadizos {0} veces.
ach.wins.name = Conquistador {0}
ach.wins.desc = Gana {0} partidas libres.
ach.missions.name = Buscafragmentos {0}
ach.missions.desc = Gana {0} misiones de campaña.
ach.coop.name = Miembro de la liga {0}
ach.coop.desc = Gana {0} misiones cooperativas.
ach.minutes.name = Ave nocturna {0}
ach.minutes.desc = Juega durante {0} minutos.
ach.flawless.name = Ni un rasguño
ach.flawless.desc = Gana sin perder una sola criatura.
ach.speedrun.name = Antes de que se enfríe el té
ach.speedrun.desc = Gana en menos de quince minutos.
ach.campaign.name = El Único Amo
ach.campaign.desc = Gana la última misión de la campaña.
ach.surface.name = Quemadura solar
ach.surface.desc = Gana una partida que llegue hasta la superficie.
ach.defeat.name = Té y compasión
ach.defeat.desc = Pierde una partida. Dedotuerto pone el agua a hervir.
ach.legend.name = Leyenda viviente
ach.legend.desc = Una criatura alcanza el nivel diez.
ach.wyrm.name = Aquí hay dragones
ach.wyrm.desc = Un dragón de brasas se une a tu mazmorra.
ach.fullhand.name = Manos llenas
ach.fullhand.desc = Sostén ocho unidades en la mano a la vez.
# tutorial
menu.tutorial = Tutorial
tut.name = Primeros pasos
tut.title = Dedotuerto te enseña el oficio
tut.story = Así que tú eres el nuevo amo. Pequeño, pero el núcleo aún late, y eso es lo que cuenta.\n\nTe enseñaré lo básico: excavar, salas, criaturas y qué hacer con los caballeros. Haz lo que te digo y nadie saldrá herido. Salvo los caballeros.
tut.start = Dedotuerto: Primera lección: excavar. Coge el pico (tecla 1), arrastra sobre la tierra al este del corazón y tus diablillos la excavarán.
tut.obj1 = Excava el túnel al este del corazón.
tut.dug = Dedotuerto: ¡Mira, una sala detrás! Tus diablillos reclaman el suelo por sí solos. Ahora abre Salas y construye una guarida en tu propio suelo: ahí duermen tus criaturas.
tut.obj2 = Construye una guarida (Salas).
tut.lair = Dedotuerto: Camas, hecho. Las criaturas también comen: construye un huerto de setas al lado.
tut.obj3 = Construye un huerto de setas (Salas).
tut.farm = Dedotuerto: Cama y comida: ahora el portal te envía criaturas. Paciencia, vienen solas. Espera a dos.
tut.obj4 = Espera a que dos criaturas lleguen por el portal.
tut.creatures = Dedotuerto: ¡Aquí están! Un poco de motivación nunca viene mal: coge la mano (tecla 2) y haz clic en una criatura manteniendo Mayús pulsada (en pantalla táctil: tócala dos veces) para darle un bofetón. Trabajará más rápido durante un rato.
tut.obj5 = Dale un bofetón a una de tus criaturas (mano, Mayús + clic).
tut.slap = Dedotuerto: ¡Ja! ¿Lo has oído? Algo viene del este. Prepárate: el rayo (Hechizos) golpea fuerte, y tus criaturas luchan solas.
tut.obj6 = Derrota a los dos caballeros.
tut.knights = Dedotuerto: ¡Dos caballeros! Envía a tus criaturas con la bandera de ataque (Órdenes) o fulmínalos con el rayo.
tut.win = Dedotuerto: ¡Bien hecho, amo! Eso es todo. Bueno, casi. La campaña cuenta el resto. Voy a poner el agua a hervir.
# master talents
cat.master = Maestro
talent.title = Talentos del maestro
talent.points = Puntos de talento libres: {0}
talent.next = {0}/{1} de experiencia hasta el siguiente punto
talent.all = Todos los talentos están aprendidos.
talent.builder = Constructor
talent.warlord = Caudillo
talent.warlock = Brujo
talent.merchant = Mercader
talent.builder.desc = Los diablillos cavan un {0}% más rápido y las salas cuestan un {1}% menos.
talent.warlord.desc = Las criaturas golpean un {0}% más fuerte y reciben un {1}% menos de daño.
talent.warlock.desc = El maná fluye un {0}% más rápido y los hechizos cuestan un {1}% menos.
talent.merchant.desc = Los sueldos son un {0}% más bajos y el tesoro entregado vale un {1}% más.
talent.none = Aún no aprendido.
talent.then = Siguiente nivel: {0}
msg.talent_point = ¡Un nuevo punto de talento! Gástalo en la ventana del maestro.
msg.talent_learned = {0}: nivel {1} aprendido.
# weather above ground
weather.fair = Despejado
weather.rain = Lluvia
weather.fog = Niebla
weather.storm = Tormenta
msg.weather_clear = El cielo se despeja en la superficie.
msg.weather_rain = Empieza a llover en la superficie: el fuego no prende.
msg.weather_fog = La niebla cubre la tierra: todos ven menos al aire libre.
msg.weather_storm = ¡Se avecina una tormenta en la superficie! Los rayos alcanzan unidades y árboles.
msg.lightning_unit = ¡Un rayo alcanza a {0}!
# creature names
names.imp = Mugrín, Mocoso, Cascarrín, Pringue, Garbancito, Roñito, Pelusa, Zurullo, Tizón, Chafardín, Escarbín, Migaja, Chispín, Lodito, Gusarapo, Rascón, Pocholo, Terrón, Pipo, Zarpín
names.kobold = Gruñín, Raspa, Mordisco, Chirlo, Trapo, Tarugo, Zarpazo, Rufo, Pellizco, Garfio, Cardo, Chuzo, Zorrino, Pícaro, Ratero, Mellado, Rasguño, Guijarro, Cuchi, Tiznado
names.spider = Patitas, Telarín, Hilacha, Cosquillas, Ocho, Sedita, Tejedora, Pelusín, Brujilla, Enredo, Ovillo, Agujita, Trampita, Colmilla, Lunares, Retales, Bordadora, Pinchito, Sombrita, Telita
names.mage = Bartolomé, Ignacio, Hermenegildo, Anacleto, Fulgencio, Eustaquio, Leopoldo, Baltasar, Melquíades, Teodoro, Segismundo, Florencio, Casimiro, Gumersindo, Nicanor, Aurelio, Rigoberto, Cipriano, Marcelino, Wenceslao
names.ogre = Pedrusco, Ceporro, Zoquete, Mamotreto, Tocho, Bruto, Mazacote, Bolo, Morlaco, Pancho, Trompo, Tronco, Cachalote, Pataca, Mendrugo, Bombo, Gordinflón, Porrazo, Garrulo, Borrico
names.jailer = Cerrojo, Candado, Grilletes, Ceñudo, Sombrío, Mazmorro, Rejas, Torvo, Hierro, Cadenas, Severo, Aldaba, Garrote, Cancerbero, Llavero, Lúgubre, Hosco, Calabozo, Tranca, Argolla
names.priestess = Brasa, Ascua, Llama, Pira, Centella, Ceniza, Bermeja, Hoguera, Fulgor, Lumbre, Morgana, Serafina, Escarlata, Chispa, Ignea, Rubí, Ámbar, Fénix, Candela, Pavesa
names.chainbreaker = Rompecadenas, Ulfgar, Almahierro, Brannok, Kragmor, Valdrek, Espino, Gorath, Indómito, Rurik, Torvaldo, Hagan, Durnok, Quebranto, Tizona, Wulfrido, Korrún, Arvak, Libertador, Halvardo
names.bat = Chillido, Aletas, Pío, Mordi, Eco, Batí, Ocaso, Zumbi, Chirri, Colmillito, Revolo, Murci, Polilla, Piquito, Picado, Colgandero, Terciopelo, Nocturno, Chirrín, Zigzag
names.gargoyle = Granito, Pizarra, Basalto, Adoquín, Mortero, Guijarro, Grava, Pedernal, Cascote, Escombro, Cuarzo, Pómez, Cornisa, Gárgola, Carapiedra, Sillar, Frontón, Cantero, Cincel, Peñasco
names.owl = Sabiondo, Ulular, Pluma, Buhíto, Minerva, Tobías, Lechuzo, Sapiencio, Plumón, Sabio, Cavilón, Anciano, Mochuelo, Noctua, Búho, Pergamino, Ocaso, Cárabo, Profesor, Erudito
names.wyrm = Vermitrax, Fauceniza, Chamusco, Calderón, Ignívoro, Ascuamor, Drakkar, Pirro, Obsidiano, Brasamor, Vulcanor, Abrasador, Malgrath, Tyrax, Infierno, Azufre, Kaldor, Fyrnak, Ragnor, Cindros
names.cogling = Tuerquita, Engranaje, Tornillo, Trinquete, Perno, Piñón, Chirimbolo, Cachivache, Llavecita, Muelle, Tintín, Chisme, Remache, Zumbido, Manivela, Arandela, Bisagra, Artilugio, Tictac, Resorte
names.revenant = Lamento, Pesar, Ceniciento, Quebranto, Hueco, Luto, Lívido, Pena, Espectro, Endecha, Túmulo, Penumbra, Cripta, Llanto, Vigilia, Mortaja, Sudario, Elegía, Ánima, Desolado
names.beast = Greñas, Melenudo, Bruno, Pelambre, Zampón, Musgoso, Gruñón, Osezno, Matorral, Crujido, Cascarón, Patudo, Cerdas, Moho, Retumbo, Zarza, Chasquido, Rezongón, Mole, Hocicón
names.human = Rodrigo, Gonzalo, Álvaro, Fernando, Tristán, Sancho, Bermudo, Lope, Pelayo, Hugo, Beltrán, Godofredo, Ordoño, Balduino, Ramiro, Gutierre, Nuño, Gilberto, Rufino, Diego
epithets.mid = Rompehuesos, Pisabarro, Muerderratas, Caramugre, Mascapiedras, Machacacráneos, Puñoapestoso, Nudillorroñoso, Revientapanzas, Pateaespinillas, Barbamoho, Garramugre, Lanzamocos, Masticaorejas, Parterrodillas, Comegusanos
epithets.high = Matarreyes, Manofunesta, Colmillopavor, Rompemundos, Azotehéroes, Puñotormenta, Terrornocturno, Segadalmas, Corazóndehierro, Derribacastillos, Matadragones, Andainfiernos, Señorsombra, Coronasangre, Mareafunesta, Quiebrastros
name.epithet = {0} {1}
msg.creature_fallen = {0} ha caído. Una piedra conmemorativa guarda su nombre.
tile.memorial = Piedra conmemorativa
tile.memorial_info = Aquí yace {0} ({1}, nivel {2}).
# hall of the fallen
hall.title = Salón de los caídos
hall.count = Criaturas caídas en el recuerdo: {0}
hall.empty = Aún no ha caído nadie. Que siga así.
hall.kills = Héroes abatidos: {0}
ov.fallen = Caídos ({0})
ov.living = Vivos
cause.slain = muerte a manos de: {0}
cause.lava = muerte en lava
cause.water = ahogamiento
cause.snare = muerte en un cepo
cause.lightning = muerte por rayo
cause.fell = caída en combate
+1770
View File
File diff suppressed because it is too large. Load diff
+1770
View File
File diff suppressed because it is too large. Load diff
+1770
View File
File diff suppressed because it is too large. Load diff
+422 -137
View File
@@ -53,17 +53,29 @@ msg.creature_arrived = Nuovo arrivo dal portale: {0}.
msg.payday_partial = Giorno di paga! {0} creature pagate ({1} oro), {2} a mani vuote e furiose! msg.payday_partial = Giorno di paga! {0} creature pagate ({1} oro), {2} a mani vuote e furiose!
msg.payday = Giorno di paga! {0} creature hanno ricevuto {1} oro. msg.payday = Giorno di paga! {0} creature hanno ricevuto {1} oro.
msg.wave_boss = Ondata {0}: un Paladino della Luce guida {1} eroi nel tuo regno! msg.wave_boss = Ondata {0}: un Paladino della Luce guida {1} eroi nel tuo regno!
boss.aldric = Sir Aldric l'Inflessibile
boss.seraphine = Dama Seraphine dell'Aurora
boss.halvard = Fratello Halvard il Pio
boss.rothgar = Capitano Rothgar Fianco di Ferro
msg.boss_comes = Un campione dell'Ordine guida l'ondata: {0}!
msg.boss_slain = {0} è caduto! Bottino: {1} oro e i piani di una torre della forgia runica.
msg.wave = Ondata {0}: {1} eroi sfondano la porta! msg.wave = Ondata {0}: {1} eroi sfondano la porta!
msg.gate_boss = La porta si sgretola! Appare il Guardiano della Porta con {0} difensori! msg.gate_boss = La porta si sgretola! Appare il Guardiano della Porta con {0} difensori!
msg.gate_defenders = La porta evoca {0} difensori! msg.gate_defenders = La porta evoca {0} difensori!
msg.level_up = {0} raggiunge il livello {1}. msg.level_up = {0} raggiunge il livello {1}.
msg.hero_slain = {0} sconfitto! Bottino: {1} oro. msg.hero_slain = {0} sconfitto! Bottino: {1} oro.
msg.thief_in_treasury = C'è un ladro nella tua tesoreria!
msg.thief_escaped = Un ladro è scappato con {0} oro!
msg.snare_caught = È finito in una tagliola: {0}!
msg.converted = L'inquisitore ha convertito una delle tue creature: {0}!
msg.creature_died = È caduto in battaglia: {0}. msg.creature_died = È caduto in battaglia: {0}.
msg.portal_claimed = Il portale è tuo! Attira le creature. msg.portal_claimed = Il portale è tuo! Attira le creature.
msg.flag_removed = Bandiera d'attacco rimossa. Le creature tornano indietro. msg.flag_removed = Bandiera d'attacco rimossa. Le creature tornano indietro.
msg.flag_set = Bandiera d'attacco piantata! Le creature pronte alla lotta si radunano lì. msg.flag_set = Bandiera d'attacco piantata! Le creature pronte alla lotta si radunano lì.
msg.creature_leaves = Una creatura scontenta lascia il tuo regno: {0}! msg.creature_leaves = Una creatura scontenta lascia il tuo regno: {0}!
msg.no_gold = Oro insufficiente! msg.no_gold = Oro insufficiente!
msg.no_wood = Legno insufficiente! Abbatti funghi giganti (o alberi) con il piccone.
msg.siege_built = Un {0} è pronto: metti la bandiera d'attacco per mandarlo.
msg.sold = Stanze vendute: +{0} oro. msg.sold = Stanze vendute: +{0} oro.
msg.no_mana_bolt = Mana insufficiente per il Fulmine. msg.no_mana_bolt = Mana insufficiente per il Fulmine.
msg.no_mana_heal = Mana insufficiente per la Cura. msg.no_mana_heal = Mana insufficiente per la Cura.
@@ -73,6 +85,7 @@ msg.no_mana_cost = Mana insufficiente (servono {0}).
hint.portal_no_lair = Il portale è aperto, ma senza una tana non verrà nessuna creatura. hint.portal_no_lair = Il portale è aperto, ma senza una tana non verrà nessuna creatura.
hint.no_farm = Le creature vogliono mangiare: costruisci una fungaia. hint.no_farm = Le creature vogliono mangiare: costruisci una fungaia.
hint.lair_full = Tutti i letti della tana sono occupati. Ingrandisci la tana. hint.lair_full = Tutti i letti della tana sono occupati. Ingrandisci la tana.
hint.creature_max = Il tuo dungeon ospita 100 creature: dal portale non ne arrivano altre.
hint.gate_attack = Le tue creature attaccano la Porta degli Eroi! hint.gate_attack = Le tue creature attaccano la Porta degli Eroi!
hint.treasury_full = Le tesorerie sono piene! Costruisci altre caselle di tesoreria. hint.treasury_full = Le tesorerie sono piene! Costruisci altre caselle di tesoreria.
hint.flag_unreachable = Le tue creature non riescono a raggiungere la bandiera d'attacco. hint.flag_unreachable = Le tue creature non riescono a raggiungere la bandiera d'attacco.
@@ -124,6 +137,7 @@ ui.key = Tasto: {0}
# ---- HUD # ---- HUD
hud.gold = Oro hud.gold = Oro
hud.wood = Legno
hud.mana = Mana hud.mana = Mana
hud.core = Nucleo hud.core = Nucleo
hud.gate = Porta hud.gate = Porta
@@ -145,6 +159,7 @@ help.k_right = Clic destro Posa un'unità (prigionieri: anche nella camera d
help.k_slap = Shift+Sinistro Mano: schiaffo alle tue unità, pugno agli eroi sul tuo suolo help.k_slap = Shift+Sinistro Mano: schiaffo alle tue unità, pugno agli eroi sul tuo suolo
help.k_pan = Destro+trascina Sposta la mappa (anche WASD/frecce, bordo schermo) help.k_pan = Destro+trascina Sposta la mappa (anche WASD/frecce, bordo schermo)
help.k_rot = Centro+trascina Ruota e inclina la camera (anche Q/E, R/F) help.k_rot = Centro+trascina Ruota e inclina la camera (anche Q/E, R/F)
help.k_reset = Centro 2x clic Ripristina angolo e rotazione (il posto resta)
help.k_zoom = Rotellina Zoom help.k_zoom = Rotellina Zoom
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Scegli strumento help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Scegli strumento
help.k_misc = P / Spazio Pausa M Audio sì/no +/- Volume help.k_misc = P / Spazio Pausa M Audio sì/no +/- Volume
@@ -237,6 +252,7 @@ set.lights = Fonti di luce
set.ui_scale = Dimensione interfaccia set.ui_scale = Dimensione interfaccia
set.game_speed = Velocità di gioco set.game_speed = Velocità di gioco
set.hints = Suggerimenti set.hints = Suggerimenti
set.events = Eventi casuali
set.keys_hint = Clicca un'azione e premi un tasto. I tasti già in uso vengono scambiati. set.keys_hint = Clicca un'azione e premi un tasto. I tasti già in uso vengono scambiati.
set.press_key = Premi un tasto per: {0} set.press_key = Premi un tasto per: {0}
set.esc_cancel = Esc annulla set.esc_cancel = Esc annulla
@@ -248,6 +264,8 @@ val.medium = Media
val.high = Alta val.high = Alta
val.normal = Normale val.normal = Normale
val.large = Grande val.large = Grande
val.small = Piccolo
val.auto_plain = Automatico
# ---- key binding names # ---- key binding names
key.cam_forward = Camera avanti key.cam_forward = Camera avanti
@@ -313,12 +331,30 @@ hint.tower_max = Questa torre è al massimo potenziamento.
hint.tower_where = Le torri si costruiscono solo su caselle di pavimento conquistate e vuote. hint.tower_where = Le torri si costruiscono solo su caselle di pavimento conquistate e vuote.
hint.tower_mana = Le tue torri magiche sono rimaste senza mana. hint.tower_mana = Le tue torri magiche sono rimaste senza mana.
job.repair = Ripara una torre job.repair = Ripara una torre
job.mend_bridge = Ripara un ponte
job.raise_wall = Alza un muro
job.march = Marcia verso una spada
job.attack_tower = Attacca una torre job.attack_tower = Attacca una torre
unit.jailer = Carceriere unit.jailer = Carceriere
unit.priestess = Sacerdotessa della Fiamma unit.priestess = Sacerdotessa della Fiamma
msg.hero_captured = {0} stordito e fatto prigioniero! Bottino: {1} oro. msg.hero_captured = {0} stordito e fatto prigioniero! Bottino: {1} oro.
msg.defected = {0} è passato dalla tua parte! msg.defected = {0} è passato dalla tua parte!
msg.beast_tamed = Un {0} selvatico si arrende e si unisce al tuo dungeon!
gear.1 = equipaggiamento di ferro
gear.2 = equipaggiamento d'acciaio
gear.3 = equipaggiamento runico
msg.gear_given = La forgia consegna {0} a: {1}.
msg.trader_arrives = Un mercante è arrivato dal tuo portale: {0} per {1} oro. Cliccalo con la mano per comprare: aspetta un minuto.
msg.trader_sold = Comprato: {0}. Un piacere, dice il mercante.
msg.trader_left = Il mercante alza le spalle e se ne va dal portale.
trader.info = Offre {0} per {1} oro (clic con la mano)
trader.left = Se ne va tra {0} s
trader.rune = i piani di una torre della forgia runica
trader.wyrm = un drago di brace
trader.chainbreaker = uno spezzacatene
trader.wood = 30 legno
trader.mana = una pozione che riempie il mana
msg.prisoner_died = {0} è morto sotto tortura. msg.prisoner_died = {0} è morto sotto tortura.
job.carry = Trasporta un prigioniero job.carry = Trasporta un prigioniero
job.pray = Prega job.pray = Prega
@@ -329,6 +365,10 @@ info.tortured = sotto tortura
info.prison = Prigionieri: {0}/{1} info.prison = Prigionieri: {0}/{1}
msg.wave_warn_dir = Gli esploratori riferiscono: l'ondata di eroi {0} raggiunge la porta tra 30 secondi! Vogliono irrompere da {1}. msg.wave_warn_dir = Gli esploratori riferiscono: l'ondata di eroi {0} raggiunge la porta tra 30 secondi! Vogliono irrompere da {1}.
msg.event_quake = Terremoto! Sono crollati dei tunnel a {0}.
msg.event_lava = La lava straripa a {0}!
msg.event_flood = Inondazione! Il fiume straripa a {0}.
msg.event_vein = I diavoletti segnalano una nuova vena d'oro a {0}!
msg.wave_warn_two = Gli esploratori riferiscono: l'ondata di eroi {0} raggiunge la porta tra 30 secondi! Si dividono e irrompono da {1} e da {2}. msg.wave_warn_two = Gli esploratori riferiscono: l'ondata di eroi {0} raggiunge la porta tra 30 secondi! Si dividono e irrompono da {1} e da {2}.
dir.n = nord dir.n = nord
dir.ne = nord-est dir.ne = nord-est
@@ -340,7 +380,8 @@ dir.w = ovest
dir.nw = nord-ovest dir.nw = nord-ovest
val.auto = Auto ({0}x) val.auto = Auto ({0}x)
set.render_scale = Risoluzione 3D set.render_scale = Aspetto pixel
set.pixel_rows_val = {0} righe
set.debug_fps = Mostra FPS set.debug_fps = Mostra FPS
set.debug_frame_time = Mostra tempo frame set.debug_frame_time = Mostra tempo frame
set.debug_ping = Mostra ping set.debug_ping = Mostra ping
@@ -398,8 +439,25 @@ help.start_touch = Tocca per iniziare
tool.bridge.label = Ponte tool.bridge.label = Ponte
tool.bridge.name = Costruisci ponte tool.bridge.name = Costruisci ponte
tool.bridge.desc = Costruisce un ponte su acqua o lava. Trascina dal tuo suolo attraverso il fiume; il ponte cresce casella per casella dal tuo territorio. tool.bridge.desc = Costruisce un ponte su acqua o lava. Trascina dal tuo suolo attraverso il fiume; il ponte cresce casella per casella dal tuo territorio.
cost.bridge = {0} oro/casella sull'acqua, {1} sulla lava tool.drawbridge.label = Levatoio
tool.drawbridge.name = Costruisci ponte levatoio
tool.drawbridge.desc = Un ponte che puoi sollevare: trascina sul fiume per costruirlo, clicca per cambiare: giù, su, automatico (si alza quando arrivano nemici). Il legno sull'acqua brucia, la pietra sulla lava no.
tool.wall.label = Muro
tool.wall.name = Alza muro
tool.wall.desc = Pianifica muri sul tuo pavimento vuoto (trascina di nuovo per annullare): i diavoletti li alzano come terra rinforzata. Chiudi i tunnel e metti le porte dove contano.
tool.ram.label = Ariete
tool.ram.name = Costruisci ariete
tool.ram.desc = Un ariete esce dalla tua forgia (oro e legno). Segue la tua bandiera d'attacco e sfonda porta e torri dei cavalieri.
tool.catapult.label = Catapulta
tool.catapult.name = Costruisci catapulta
tool.catapult.desc = Una catapulta esce dalla tua forgia (oro e legno). Colpisce da lontano le torri dei cavalieri e segue la tua bandiera fino alla loro porta.
cost.bridge = {0} oro + {1} legno/casella sull'acqua, {2} oro sulla lava
cost.drawbridge = {0} oro + {1} legno/casella sull'acqua, {2} oro sulla lava
cost.gold_wood = {0} oro + {1} legno
hint.bridge_where = I ponti devono partire dal tuo suolo: trascina dal tuo territorio attraverso il fiume. hint.bridge_where = I ponti devono partire dal tuo suolo: trascina dal tuo territorio attraverso il fiume.
hint.bridge_fire = I ponti di legno bruciano: tieni lontani trappole di fuoco, soffio infuocato e fulmini; i ponti di pietra sulla lava sono ignifughi.
hint.wall_where = I muri si alzano solo sul tuo pavimento vuoto (niente stanze, trappole o porte).
hint.siege_where = Le macchine d'assedio escono sul tuo terreno libero, e serve una forgia.
hint.bridge_river = I ponti si costruiscono solo su acqua o lava. hint.bridge_river = I ponti si costruiscono solo su acqua o lava.
msg.hero_burned = Un eroe è caduto nella lava: {0}! +{1} oro. msg.hero_burned = Un eroe è caduto nella lava: {0}! +{1} oro.
msg.creature_burned = È caduto nella lava: {0}. msg.creature_burned = È caduto nella lava: {0}.
@@ -409,6 +467,22 @@ tile.lava = Lava
tile.lava_info = Ciò che ci cade brucia. I ponti di pietra la attraversano. tile.lava_info = Ciò che ci cade brucia. I ponti di pietra la attraversano.
tile.bridge = Ponte di legno tile.bridge = Ponte di legno
tile.lavabridge = Ponte di pietra tile.lavabridge = Ponte di pietra
tile.mushroom = Fungo gigante
tile.mushroom_grown = Cresciuto: pronto per il taglio.
tile.mushroom_growing = Cresce: {0}% - si può abbattere solo quando è cresciuto.
tile.grove = Boschetto di funghi
tile.grove_info = Nessuno può conquistare questo muschio. Qui ricrescono funghi giganti.
tile.tree = Albero
tile.plant_info = Segnalo col piccone: un diavoletto lo abbatte per {0} legno.
tile.drawbridge = Ponte levatoio
tile.drawbridge_up = Sollevato
tile.drawbridge_down = Abbassato
tile.drawbridge_auto = Automatico (giù)
tile.drawbridge_auto_up = Automatico (su)
tile.obsidian = Ossidiana
tile.obsidian_info = Lava raffreddata: si scioglie tra {0} s
tile.bridge_burning = Brucia!
tile.bridge_worn = Danneggiato {0}%
tile.shard = Frammento del tuo nucleo tile.shard = Frammento del tuo nucleo
tile.shard_info = Conquista questa camera per riprenderti il frammento. tile.shard_info = Conquista questa camera per riprenderti il frammento.
@@ -430,6 +504,11 @@ msg.enemy_tower_destroyed = È caduta una {0} dei cavalieri!
msg.vault_taken = Caveau dei cavalieri preso: tesori per {0} sono tuoi. msg.vault_taken = Caveau dei cavalieri preso: tesori per {0} sono tuoi.
msg.shard_taken = Un frammento del tuo nucleo è di nuovo nelle tue mani! msg.shard_taken = Un frammento del tuo nucleo è di nuovo nelle tue mani!
msg.bridge_torn = I cavalieri hanno abbattuto uno dei tuoi ponti! msg.bridge_torn = I cavalieri hanno abbattuto uno dei tuoi ponti!
msg.bridge_fire = Uno dei tuoi ponti è in fiamme!
msg.drawbridge_up = Il ponte levatoio è sollevato.
msg.drawbridge_down = Il ponte levatoio è abbassato.
msg.drawbridge_auto = Il ponte levatoio si alza da solo quando arrivano nemici.
msg.drawbridge_rises = Arrivano nemici: un ponte levatoio si alza!
job.tear_bridge = Abbatte un ponte job.tear_bridge = Abbatte un ponte
job.patrol = Di pattuglia job.patrol = Di pattuglia
job.guard = Di guardia job.guard = Di guardia
@@ -533,7 +612,7 @@ camp.m29.story = Isole di lava, maestro, con fonti di mana che ribollono nel mez
camp.m20.title = Il Gran Maestro camp.m20.title = Il Gran Maestro
camp.m20.story = Il Gran Maestro Valeriano Scudolucente attende nella sua cittadella oltre il grande fiume, con l'ultimo frammento e la porta dell'ordine. Per cento anni ha pregato, lucidato e si è preparato. | Distruggi la sua porta e riprenditi il frammento. Allora il tuo nucleo sarà intero - e ogni maestro che l'abbia mai voluto verrà a prenderselo tutto in una volta. Una cosa alla volta. camp.m20.story = Il Gran Maestro Valeriano Scudolucente attende nella sua cittadella oltre il grande fiume, con l'ultimo frammento e la porta dell'ordine. Per cento anni ha pregato, lucidato e si è preparato. | Distruggi la sua porta e riprenditi il frammento. Allora il tuo nucleo sarà intero - e ogni maestro che l'abbia mai voluto verrà a prenderselo tutto in una volta. Una cosa alla volta.
camp.m30.title = Il trono degli abissi camp.m30.title = Il trono degli abissi
camp.m30.story = È andata come dicevo: il tuo nucleo è intero, e ora lo vogliono tutti. Mossgrim e Lady Nightwell marciano insieme, e ciò che resta dell'Ordine apre la sua porta un'ultima volta. | Distruggi entrambi i nuclei dei maestri e la porta dell'ordine. Dopo, gli abissi avranno esattamente un maestro. Indovina chi. Un indizio: non io, io sono quello del tè. camp.m30.story = È andata come dicevo: il tuo nucleo è intero, e ora lo vogliono tutti. Mossgrim e Lady Nightwell hanno scavato fino in superficie, e l'Alto Maresciallo aspetta nella sua fortezza nel bosco: luce del giorno, uccelli, tutto il pacchetto. Disgustoso. | Esci dalla roccia, distruggi entrambi i nuclei dei maestri e la porta dell'ordine dentro la fortezza. Dopo, il mondo avrà un solo maestro. Indovina chi. Suggerimento: non io, io sono quello del tè.
camp.epilogue = Il nucleo verde si sbriciola, quello blu si spegne e la porta dell'ordine tace. Da qualche parte lassù, la Guardia della Luce sta scrivendo un rapporto lunghissimo, e le lettere di Lady Nightwell hanno smesso di arrivare. | Ditastorte ha preparato il tè. Sei di nuovo intero, maestro. Gli inferi sono tuoi. camp.epilogue = Il nucleo verde si sbriciola, quello blu si spegne e la porta dell'ordine tace. Da qualche parte lassù, la Guardia della Luce sta scrivendo un rapporto lunghissimo, e le lettere di Lady Nightwell hanno smesso di arrivare. | Ditastorte ha preparato il tè. Sei di nuovo intero, maestro. Gli inferi sono tuoi.
lair.arrivals = {0} letti liberi: le nuove creature arrivano al {1}% della velocità lair.arrivals = {0} letti liberi: le nuove creature arrivano al {1}% della velocità
job.help = Aiuta job.help = Aiuta
@@ -657,6 +736,7 @@ net.error = {0}
net.rooms = Partite aperte net.rooms = Partite aperte
net.no_rooms = Nessuna partita aperta. net.no_rooms = Nessuna partita aperta.
net.create = Crea partita net.create = Crea partita
net.map_size = Mappa {0}x{1} · {2} giocatori
net.coop_mission = Campagna co-op: missione {0} ({1}) net.coop_mission = Campagna co-op: missione {0} ({1})
net.coop_create = Campagna co-op net.coop_create = Campagna co-op
net.join = Entra net.join = Entra
@@ -687,6 +767,9 @@ net.ai_off = No
# rival masters (free game) # rival masters (free game)
newgame.difficulty = Avversari newgame.difficulty = Avversari
newgame.land = Terreno
newgame.land0 = Sotterraneo
newgame.land1 = Metà in superficie
diff.0 = Facile diff.0 = Facile
diff.1 = Medio diff.1 = Medio
diff.2 = Difficile diff.2 = Difficile
@@ -699,9 +782,14 @@ msg.rival1_attack = Mossgrim, il maestro verde, ti manda contro {0} creature!
msg.rival2_attack = Lady Nightwell, la maestra blu, ti manda contro {0} creature! msg.rival2_attack = Lady Nightwell, la maestra blu, ti manda contro {0} creature!
msg.rival1_out = Mossgrim è sconfitto - il suo nucleo dell'anima è in frantumi! msg.rival1_out = Mossgrim è sconfitto - il suo nucleo dell'anima è in frantumi!
msg.rival2_out = Lady Nightwell è sconfitta - il suo nucleo dell'anima è in frantumi! msg.rival2_out = Lady Nightwell è sconfitta - il suo nucleo dell'anima è in frantumi!
msg.rival3_attack = Saffrax, il signore giallo, manda {0} creature contro di te!
msg.rival3_out = Saffrax è sconfitto: il suo nucleo dell'anima è andato in frantumi!
msg.rival_unit_slain = Cade una creatura di un maestro rivale: {0}! +{1} oro. msg.rival_unit_slain = Cade una creatura di un maestro rivale: {0}! +{1} oro.
msg.wave_on_rival1 = Ondata {0}: {1} eroi marciano sul maestro verde. msg.wave_on_rival1 = Ondata {0}: {1} eroi marciano sul maestro verde.
msg.wave_on_rival2 = Ondata {0}: {1} eroi marciano sulla maestra blu. msg.wave_on_rival2 = Ondata {0}: {1} eroi marciano sulla maestra blu.
msg.wave_warn_on_master = Gli esploratori riferiscono: l'ondata {0} arriva tra 30 secondi e punta sul maestro {1}.
msg.wave_on_master = Ondata {0}: {1} eroi marciano contro il maestro {2}.
msg.partner_out = Il cuore del tuo alleato IA è caduto: combatti da solo!
hud.rival1 = Verde hud.rival1 = Verde
hud.rival2 = Blu hud.rival2 = Blu
msg.creature_freed = Spezza le catene e si unisce a te: {0}! msg.creature_freed = Spezza le catene e si unisce a te: {0}!
@@ -790,6 +878,8 @@ best.b.torture.text = Gli eroi catturati finiscono qui. Con il tempo e un carcer
best.b.temple.text = Le preghiere curano il nucleo dell'anima e tengono alto il morale. La sacerdotessa della fiamma viene per un tempio. best.b.temple.text = Le preghiere curano il nucleo dell'anima e tengono alto il morale. La sacerdotessa della fiamma viene per un tempio.
best.b.aerie.text = Posatoi e nidi per creature volanti. Pipistrelli, gargoyle, gufi e perfino il drago di brace hanno bisogno di spazio qui. best.b.aerie.text = Posatoi e nidi per creature volanti. Pipistrelli, gargoyle, gufi e perfino il drago di brace hanno bisogno di spazio qui.
best.b.bridge.text = Legno sull'acqua, pietra sulla lava. I cavalieri abbattono i tuoi ponti - conquista i loro. best.b.bridge.text = Legno sull'acqua, pietra sulla lava. I cavalieri abbattono i tuoi ponti - conquista i loro.
best.b.drawbridge.text = Sollevato, il fiume torna a essere un muro. In automatico si alza quando arrivano nemici e si abbassa per le tue creature.
best.b.wall.text = Un tunnel scavato non è per sempre: murato di nuovo, agli eroi resta una sola via, attraverso la tua porta.
best.b.steel.text = Piastre di ferro rivettate su un muro rinforzato. Nessuno ci scava attraverso in fretta, nemmeno i nani. best.b.steel.text = Piastre di ferro rivettate su un muro rinforzato. Nessuno ci scava attraverso in fretta, nemmeno i nani.
best.b.arrow.text = La freccia trapassa fino a due nemici dietro il bersaglio. Economica e affidabile. best.b.arrow.text = La freccia trapassa fino a due nemici dietro il bersaglio. Economica e affidabile.
best.b.cannon.text = Danno ad area e contraccolpo - spingi i cavalieri nella lava. best.b.cannon.text = Danno ad area e contraccolpo - spingi i cavalieri nella lava.
@@ -801,7 +891,7 @@ room.forge = Fucina
tool.forge.label = Fucina tool.forge.label = Fucina
tool.forge.desc = Le creature fabbricano qui trappole e porte - lavoro, niente oro. I pezzi finiti aspettano in casse finché un imp non li installa. Gli Ingranotti vengono per una fucina di 6+ caselle. tool.forge.desc = Le creature fabbricano qui trappole e porte - lavoro, niente oro. I pezzi finiti aspettano in casse finché un imp non li installa. Gli Ingranotti vengono per una fucina di 6+ caselle.
unit.cogling = Ingranotto unit.cogling = Ingranotto
cat.traps = Trappole cat.traps = Costruire
cost.work = {0} di lavoro in fucina cost.work = {0} di lavoro in fucina
job.forge = Forgia job.forge = Forgia
job.install = Installa una trappola job.install = Installa una trappola
@@ -878,6 +968,7 @@ research.now = In ricerca: {0} ({1}%)
research.next = poi: {0} research.next = poi: {0}
research.done = Qui non resta niente da ricercare. research.done = Qui non resta niente da ricercare.
msg.researched = Ricerca completata: {0}! Ora è nel menu di costruzione. msg.researched = Ricerca completata: {0}! Ora è nel menu di costruzione.
msg.room_upgraded = Ricerca: {0} raggiunge il livello {1}: funziona un quarto meglio, e si vede.
msg.tool_research = Non ancora ricercato - ci lavorano le creature che studiano in biblioteca. msg.tool_research = Non ancora ricercato - ci lavorano le creature che studiano in biblioteca.
msg.script = {0} msg.script = {0}
@@ -915,6 +1006,9 @@ tool.plague.desc = Una malattia che divora i nemici intorno al punto e salta dal
tool.quake.label = Sisma tool.quake.label = Sisma
tool.quake.name = Terremoto tool.quake.name = Terremoto
tool.quake.desc = La terra trema: i nemici intorno al punto vengono feriti e storditi per due secondi. Le tue creature restano in piedi. tool.quake.desc = La terra trema: i nemici intorno al punto vengono feriti e storditi per due secondi. Le tue creature restano in piedi.
tool.freeze.label = Gelo
tool.freeze.name = Gelo
tool.freeze.desc = Freddo glaciale: la lava intorno si raffredda in ossidiana calpestabile per 40 secondi, i nemici vengono gelati e rallentati.
tile.unexplored = Oscurità inesplorata tile.unexplored = Oscurità inesplorata
# personality # personality
@@ -967,6 +1061,9 @@ tool.arena.desc = Le creature si sfidano a coppie: il doppio dell'esperienza del
tool.altar.label = Altare tool.altar.label = Altare
tool.altar.desc = Posa una creatura sull'altare per sacrificarla. Certe coppie di sacrifici portano una creatura rara, altrimenti il dungeon viene benedetto: umore migliore e mana. tool.altar.desc = Posa una creatura sull'altare per sacrificarla. Certe coppie di sacrifici portano una creatura rara, altrimenti il dungeon viene benedetto: umore migliore e mana.
unit.revenant = Redivivo unit.revenant = Redivivo
unit.cavebear = Orso delle caverne
unit.beetle = Scarabeo gigante
unit.trader = Mercante
job.guard_post = Di guardia job.guard_post = Di guardia
job.spar = Si allena in coppia job.spar = Si allena in coppia
msg.revenant_rises = Un redivivo risorge dal cimitero e ti serve! msg.revenant_rises = Un redivivo risorge dal cimitero e ti serve!
@@ -975,6 +1072,10 @@ msg.altar_gift = L'altare accetta la coppia: appare {0}!
msg.altar_blessing = Sacrificio compiuto: {0}. Il dungeon è benedetto. msg.altar_blessing = Sacrificio compiuto: {0}. Il dungeon è benedetto.
best.revenant.lure = risorge dal cimitero best.revenant.lure = risorge dal cimitero
best.revenant.text = Un redivivo non mangia, non dorme e non si lamenta - il dipendente perfetto, se sopporti l'odore. | Con un po' di esperienza beve la forza di chi colpisce. Lui e la sacerdotessa non vanno d'accordo, per ovvi motivi. best.revenant.text = Un redivivo non mangia, non dorme e non si lamenta - il dipendente perfetto, se sopporti l'odore. | Con un po' di esperienza beve la forza di chi colpisce. Lui e la sacerdotessa non vanno d'accordo, per ovvi motivi.
best.cavebear.lure = sconfiggerlo nella sua tana avendo una prigione
best.cavebear.text = Un orso che non ha mai visto il sole e non gli manca. Irsuto, forte e scontroso fino a colazione. | Sconfiggilo nella sua tana finché una prigione ha posto: si arrende e si unisce a te. Ama l'orto di funghi, e i ragni, come spuntino.
best.beetle.lure = sconfiggerlo nella sua tana avendo una prigione
best.beetle.text = Uno scarabeo grande come una botte, corazzato come un cavaliere e due volte più educato. | Sconfiggilo nella sua tana finché una prigione ha posto e si unirà a te. Dorme nella tana e non sopporta i pipistrelli.
best.b.prison.text = File di celle. Qui gli eroi marciscono in silenzio finché qualcuno non decide cosa farne. best.b.prison.text = File di celle. Qui gli eroi marciscono in silenzio finché qualcuno non decide cosa farne.
best.b.graveyard.text = Muschio, lapidi e terra fresca. Quaggiù nessuno resta morto a lungo. best.b.graveyard.text = Muschio, lapidi e terra fresca. Quaggiù nessuno resta morto a lungo.
best.b.guardpost.text = Un braciere, uno stendardo e una creatura annoiata dagli occhi acuti. best.b.guardpost.text = Un braciere, uno stendardo e una creatura annoiata dagli occhi acuti.
@@ -987,23 +1088,39 @@ ability.drain.desc = si cura di un terzo del danno che infligge.
unit.giant = Gigante unit.giant = Gigante
unit.fairy = Fata spia unit.fairy = Fata spia
unit.battlemage = Mago da battaglia unit.battlemage = Mago da battaglia
unit.thief = Ladro
unit.monk = Monaco
unit.ranger = Ranger
unit.inquisitor = Inquisitore
unit.ram = Ariete
unit.catapult = Catapulta
msg.trap_wrecked = Gli eroi hanno distrutto una trappola: {0}. msg.trap_wrecked = Gli eroi hanno distrutto una trappola: {0}.
best.giant.text = Un gigante è ciò che succede quando un cavaliere mangia la verdura per trecento anni. Lento, enorme, e considera le porte un semplice suggerimento. | Distrugge le trappole solo stando loro accanto. Maniere rivoltanti. Colpiscilo con tutto, preferibilmente da lontano. best.giant.text = Un gigante è ciò che succede quando un cavaliere mangia la verdura per trecento anni. Lento, enorme, e considera le porte un semplice suggerimento. | Distrugge le trappole solo stando loro accanto. Maniere rivoltanti. Colpiscilo con tutto, preferibilmente da lontano.
best.fairy.text = La piccola spia dell'Ordine: un moscerino luminoso con le ali, mandato avanti a trovare le tue trappole e a dirlo a tutti. Appena ha visto una trappola, gli eroi la distruggono. | Sparge anche polvere curativa sugli eroi. Schiacciala per prima. Due volte, per sicurezza. best.fairy.text = La piccola spia dell'Ordine: un moscerino luminoso con le ali, mandato avanti a trovare le tue trappole e a dirlo a tutti. Appena ha visto una trappola, gli eroi la distruggono. | Sparge anche polvere curativa sugli eroi. Schiacciala per prima. Due volte, per sicurezza.
best.battlemage.text = Un mago con la tonaca dell'Ordine che considera i muri un insulto personale. I muri rinforzati scoppiano sotto le sue palle di fuoco, e lui non smette mai di vantarsene. | Fragile da vicino. I muri d'acciaio lo irritano più di tutto - bene. best.battlemage.text = Un mago con la tonaca dell'Ordine che considera i muri un insulto personale. I muri rinforzati scoppiano sotto le sue palle di fuoco, e lui non smette mai di vantarsene. | Fragile da vicino. I muri d'acciaio lo irritano più di tutto - bene.
best.thief.text = Dita svelte, piedi ancora più svelti. Non viene per combattere: si infila nella tua tesoreria, riempie il sacco e corre di nuovo alla porta. | Uccidilo prima che scappi e l'oro torna tuo - con gli interessi.
best.monk.text = Anni di digiuno e preghiera lo hanno reso sordo alla tua magia: fulmine, peste e gelo gli scivolano addosso come pioggia su un tetto. | Anche i pugni fanno male. Accoglilo con acciaio, artigli e denti.
best.ranger.text = Un uomo dei boschi dell'Ordine che tira da lontano e lascia tagliole di ferro nei tuoi corridoi. La creatura che ci mette il piede resta ferita e bloccata per un po'. | Le tagliole restano finché qualcuno non ci finisce dentro. Meglio un coboldo che il tuo orco.
best.inquisitor.text = Fragile, ma le sue prediche sono pericolose: ogni venti secondi convince una delle tue creature più deboli - un coboldo o un ragno - a passare dalla parte degli eroi. | Le creature forti gli ridono in faccia. Colpisci per primo.
best.ram.text = Un tronco con punta di ferro e un tetto contro le frecce. Delle tue creature non gli importa: solo di porte, muri e torri. | Fermalo prima che arrivi alla porta, o procurati una porta nuova.
best.catapult.text = Un braccio di legno, un secchio di sassi e un cavaliere con troppo tempo libero. Tira più lontano delle tue torri e le punta per prime. | Caricala: da vicino non fa male a una mosca.
# orders # orders
tool.order_attack.label = Attacca tool.order_attack.label = Attacca
tool.order_attack.name = All'attacco! tool.order_attack.name = All'attacco!
tool.order_attack.desc = Clicca un nemico o una torre nemica: tutte le creature combattenti entro 18 caselle ci si lanciano. Il clic destro con uno strumento ordine annulla gli ordini di guardia. tool.order_attack.desc = Clicca un nemico o una torre nemica: tutte le creature pronte entro 18 caselle lo attaccano. Clicca terreno libero: un segnalino spada; le creature pronte si dividono tra tutte le spade e combattono lì. Clic destro su un segnalino lo rimuove.
tool.order_guard.label = Guardia tool.order_guard.label = Guardia
tool.order_guard.name = Sorveglia questa stanza tool.order_guard.name = Sorveglia questa stanza
tool.order_guard.desc = Clicca una stanza: fino a tre creature vicine prendono posizione lì per due minuti e attaccano tutto ciò che entra in vista. Il clic destro annulla. tool.order_guard.desc = Clicca il tuo terreno: un segnalino scudo che tre creature tengono e difendono. Fino a otto scudi; clic destro su uno scudo lo rimuove.
hint.order_attack = Clicca un nemico o una torre nemica. hint.order_attack = Clicca un nemico o una torre nemica.
hint.order_guard = Clicca una delle tue stanze (o il tuo pavimento). hint.order_guard = Clicca una delle tue stanze (o il tuo pavimento).
hint.order_nobody = Nessuna creatura è libera per quell'ordine. hint.order_nobody = Nessuna creatura è libera per quell'ordine.
msg.order_attack = {0} creature all'attacco! msg.order_attack = {0} creature all'attacco!
msg.order_guard = {0} creature prendono posizione. msg.order_guard = {0} creature prendono posizione.
msg.mark_attack = Una spada segna il punto: le tue creature pronte marciano lì.
msg.mark_guard = Uno scudo segna il punto: tre creature lo terranno.
msg.mark_removed = Segnalino rimosso: le sue creature sono di nuovo libere.
hint.marks_full = Otto segnalini di quel tipo sono il massimo. Rimuovine uno con il clic destro.
msg.orders_cancelled = Ordini di guardia annullati. msg.orders_cancelled = Ordini di guardia annullati.
# creature overview # creature overview
@@ -1013,6 +1130,8 @@ ov.summary = {0} creature - salari {1} oro per paga - umore {2}%
ov.none = Ancora nessuna creatura. Costruisci una tana e una fungaia e conquista un portale. ov.none = Ancora nessuna creatura. Costruisci una tana e una fungaia e conquista un portale.
ov.hint = Liv, umore, salario, lavoro, salute - clic: vai lì ov.hint = Liv, umore, salario, lavoro, salute - clic: vai lì
set.voice = Voce di Ditastorte set.voice = Voce di Ditastorte
set.track = Brano musicale
val.track_auto = Automatico
# ---- campaign editor, block scripts, demo campaign # ---- campaign editor, block scripts, demo campaign
menu.back_editor = Torna all'editor menu.back_editor = Torna all'editor
@@ -1085,6 +1204,7 @@ ed.forts = Fortezze
ed.rivals = Maestri rivali ed.rivals = Maestri rivali
ed.difficulty = Difficoltà ed.difficulty = Difficoltà
ed.portals = Portali ed.portals = Portali
ed.surface = Superficie (est)
ed.edit_map = Modifica mappa ed.edit_map = Modifica mappa
ed.make_random = Rendila casuale ed.make_random = Rendila casuale
ed.paint_map = Genera e dipingi la mappa ed.paint_map = Genera e dipingi la mappa
@@ -1164,6 +1284,10 @@ ed.terrain.silver = Vena d'argento
ed.terrain.ruby = Vena di rubini ed.terrain.ruby = Vena di rubini
ed.terrain.diamond = Vena di diamanti ed.terrain.diamond = Vena di diamanti
ed.terrain.steel = Muro d'acciaio ed.terrain.steel = Muro d'acciaio
ed.terrain.mushroom = Fungo gigante
ed.terrain.grove = Boschetto di funghi
ed.terrain.grass = Erba (superficie)
ed.terrain.tree = Albero
ed.place.core = Nucleo dell'Anima ed.place.core = Nucleo dell'Anima
ed.place.portal = Portale ed.place.portal = Portale
ed.place.gate = Porta degli Eroi ed.place.gate = Porta degli Eroi
@@ -1194,6 +1318,7 @@ ed.level = Livello
ed.zone_shape = Forma della nuova zona ed.zone_shape = Forma della nuova zona
ed.zone_new_name = Nome della nuova zona ed.zone_new_name = Nome della nuova zona
ed.zones = Zone ed.zones = Zone
ed.zone_erase = Togli caselle
ed.shape.rect = Rettangolo ed.shape.rect = Rettangolo
ed.shape.circle = Cerchio ed.shape.circle = Cerchio
ed.shape.tiles = Caselle ed.shape.tiles = Caselle
@@ -1243,6 +1368,8 @@ blk.reveal = Rivela
blk.camera = Mostra blk.camera = Mostra
blk.gold = Oro + blk.gold = Oro +
blk.mana = Mana + blk.mana = Mana +
blk.weather = Meteo in superficie
blk.talentxp = Esperienza talenti +
blk.if = Se blk.if = Se
blk.repeat = Ripeti blk.repeat = Ripeti
blk.trigger = Commuta trigger blk.trigger = Commuta trigger
@@ -1262,6 +1389,8 @@ blk.help.reveal = Solleva la nebbia di guerra su una zona.
blk.help.camera = Sposta la telecamera su una zona. blk.help.camera = Sposta la telecamera su una zona.
blk.help.gold = Dà (o toglie) oro. blk.help.gold = Dà (o toglie) oro.
blk.help.mana = Dà (o toglie) mana. blk.help.mana = Dà (o toglie) mana.
blk.help.weather = Imposta il meteo in superficie (sereno, pioggia, nebbia o tempesta) per tanti secondi; 0 lo mantiene finché un altro blocco non lo cambia.
blk.help.talentxp = Dà al giocatore esperienza per i talenti del maestro.
blk.help.if = Esegue i blocchi interni solo se la condizione è vera, altrimenti quelli sotto "altrimenti". blk.help.if = Esegue i blocchi interni solo se la condizione è vera, altrimenti quelli sotto "altrimenti".
blk.help.repeat = Esegue più volte i blocchi interni. blk.help.repeat = Esegue più volte i blocchi interni.
blk.help.trigger = Attiva un trigger (può scattare di nuovo) o lo disattiva. blk.help.trigger = Attiva un trigger (può scattare di nuovo) o lo disattiva.
@@ -1284,6 +1413,7 @@ blk.v.or = o
blk.v.not = non blk.v.not = non
blk.v.bool = Sì/no blk.v.bool = Sì/no
blk.v.eventkind = L'unità innesco è blk.v.eventkind = L'unità innesco è
blk.v.weatheris = Il meteo in superficie è
blk.w_at = presso blk.w_at = presso
blk.w_by = di blk.w_by = di
blk.w_in = in blk.w_in = in
@@ -1328,14 +1458,19 @@ demo.m2.title = Rimescolata
demo.m2.story = Questa mappa viene generata di nuovo a ogni partita della campagna. Sconfiggi tutti gli eroi. demo.m2.story = Questa mappa viene generata di nuovo a ogni partita della campagna. Sconfiggi tutti gli eroi.
demo.m2.start = Una mappa casuale. Nemmeno io l'avevo mai vista. demo.m2.start = Una mappa casuale. Nemmeno io l'avevo mai vista.
demo.m2.wave = Sono passati due minuti: arrivano gli eroi. demo.m2.wave = Sono passati due minuti: arrivano gli eroi.
net.coop_editor = Editor campaign net.coop_editor = Campagna dell'editor
net.coop_builtin = Built-in campaign net.coop_builtin = Campagna integrata
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_league = Lega contro la Guardia della Luce
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) net.coop_solo = Alleato IA
net.coop_solo_started = Cooperativa senza server: il tuo alleato IA costruisce il suo dungeon, segue la tua bandiera d'attacco e ti aiuta con l'oro.
net.coop_resume = Continua M{0}
net.coop_editor_mission = Campagna dell'editor {0}: missione {1} ({2})
net.coop_editor_none = Nessuna missione della campagna dell'editor è adatta alla cooperativa (mappa almeno 72x72, un obiettivo oltre ai maestri rivali)
blk.until = Ripeti finché blk.until = Ripeti finché
blk.dountil = Ripeti, poi controlla: finché blk.dountil = Ripeti, poi controlla: finché
blk.wait = Attendi blk.wait = Attendi
blk.w_seconds = secondi blk.w_seconds = secondi
blk.w_for = per
blk.help.until = Esegue i blocchi interni più volte finché la condizione è vera; viene controllata prima di ogni giro, quindi se è già vera il contenuto non viene eseguito. blk.help.until = Esegue i blocchi interni più volte finché la condizione è vera; viene controllata prima di ogni giro, quindi se è già vera il contenuto non viene eseguito.
blk.help.dountil = Esegue i blocchi interni, poi controlla la condizione e ripete finché è vera; il contenuto viene eseguito almeno una volta. blk.help.dountil = Esegue i blocchi interni, poi controlla la condizione e ripete finché è vera; il contenuto viene eseguito almeno una volta.
blk.help.wait = Mette in pausa questo script per alcuni secondi, poi prosegue con il blocco successivo (anche nei cicli). blk.help.wait = Mette in pausa questo script per alcuni secondi, poi prosegue con il blocco successivo (anche nei cicli).
@@ -1356,130 +1491,280 @@ net.coop_wait_host = In attesa che l'host avvii la prossima missione...
net.coop_run_over = Torna alla lobby net.coop_run_over = Torna alla lobby
# ---- co-op campaign "League against the Lightwatch" # ---- co-op campaign "League against the Lightwatch"
coop.name = League against the Lightwatch coop.name = Lega contro la Guardia della Luce
coop.raid = Mossgrim's creatures crawl out of the dark! coop.raid = Le creature di Mossgrim strisciano fuori dal buio!
coop.m01.title = Two Hearts, One Crookfinger coop.m01.title = Due cuori, un Ditastorte
coop.m01.story = Masters, plural. I could hardly believe it either. The Order of the Lightwatch has made a pact with Mossgrim, that mossy turncoat, to smoke out every dungeon under the hills.\n\nAlone, you would be smoked. Together you might just be the smoke. Dig in side by side and show the patrol nearby what a shared basement means. coop.m01.story = Maestri, al plurale. Non ci credevo nemmeno io. L'Ordine della Guardia della Luce ha stretto un patto con Mossgrim, quel voltagabbana muffoso, per affumicare ogni dungeon sotto le colline.\n\nDa soli, finireste affumicati. Insieme, forse il fumo sarete voi. Scavate fianco a fianco e mostrate alla pattuglia qui vicino cosa significa una cantina condivisa.
coop.m01.start = Two masters, one advisor. My wages, by the way, stay the same. coop.m01.start = Due maestri, un consigliere. Il mio stipendio, tra l'altro, resta lo stesso.
coop.m01.goal = Defeat every knight. coop.m01.goal = Sconfiggete tutti i cavalieri.
coop.m01.t1 = Share the work: one digs, one builds. Or both dig. I am flexible. coop.m01.t1 = Dividetevi il lavoro: uno scava, l'altro costruisce. Oppure scavate entrambi. Sono flessibile.
coop.m01.t2 = The patrol is getting curious. Good. Curiosity is a lovely snack. coop.m01.t2 = La pattuglia si sta incuriosendo. Bene. La curiosità è uno spuntino delizioso.
coop.m02.title = The First Outpost coop.m02.title = Il primo avamposto
coop.m02.story = Across the river the Lightwatch has built an outpost with shiny towers. Shiny things must be broken - that is the oldest rule of the underworld.\n\nBridge the water, bring the towers down, and do it together before they call their friends. coop.m02.story = Oltre il fiume la Guardia della Luce ha costruito un avamposto con torri scintillanti. Le cose scintillanti vanno rotte: è la regola più antica degli inferi.\n\nGettate un ponte sull'acqua, abbattete le torri e fatelo insieme, prima che chiamino i loro amici.
coop.m02.start = Towers. Polished, pointy, insulting. Let us fix that. coop.m02.start = Torri. Lucidate, appuntite, offensive. Rimediamo.
coop.m02.goal = Raze the knight towers. coop.m02.goal = Radete al suolo le torri dei cavalieri.
coop.m02.t1 = Bridges are like friendships, masters: build them, then march an army over them. coop.m02.t1 = I ponti sono come le amicizie, maestri: si costruiscono, e poi ci si fa marciare sopra un esercito.
coop.m02.t2 = One tower down is a start. All towers down is a party. coop.m02.t2 = Una torre abbattuta è un inizio. Tutte le torri abbattute sono una festa.
coop.m03.title = Tracks in the Moss coop.m03.title = Tracce nel muschio
coop.m03.story = Something green and wet is lurking in the tunnels. Mossgrim has lent the knights his creatures, and they come through the hero gate in waves.\n\nHold out. Hold together. Hold your noses. coop.m03.story = Qualcosa di verde e umido si aggira nei tunnel. Mossgrim ha prestato le sue creature ai cavalieri, e arrivano dalla Porta degli Eroi a ondate.\n\nResistete. Restate uniti. Tappatevi il naso.
coop.m03.start = Mossgrim's ogres smell of wet carpet. You will know when they come. coop.m03.start = Gli orchi di Mossgrim puzzano di tappeto bagnato. Ve ne accorgerete quando arrivano.
coop.m03.goal = Survive three waves. coop.m03.goal = Sopravvivete a tre ondate.
coop.m03.t1 = The first ogres are here. Mossgrim never could keep his pets at home. coop.m03.t1 = Ecco i primi orchi. Mossgrim non è mai riuscito a tenere a casa i suoi animaletti.
coop.m03.t2 = Keep your hearts apart, masters - one ogre should never reach both of them. coop.m03.t2 = Tenete i vostri cuori lontani, maestri: un solo orco non deve mai raggiungerli entrambi.
coop.m04.title = The Stolen Springs coop.m04.title = Le fonti rubate
coop.m04.story = Two mana springs bubble on islands in the lake, and the knights have claimed them as their holy water. Holy water, honestly.\n\nTake both springs. And if the knights' vault happens to fall into your hands first, Crookfinger knows a shortcut. coop.m04.story = Due fonti di mana gorgogliano su isole nel lago, e i cavalieri le hanno dichiarate la loro acqua santa. Acqua santa, ma per favore.\n\nPrendete entrambe le fonti. E se il caveau dei cavalieri vi capita in mano prima, Ditastorte conosce una scorciatoia.
coop.m04.start = Mana tastes better when it was someone else's. coop.m04.start = Il mana ha un sapore migliore quando era di qualcun altro.
coop.m04.goal = Claim two mana springs. coop.m04.goal = Conquistate due fonti di mana.
coop.m04.branch = Rich enough for the shortcut coop.m04.branch = Abbastanza ricchi per la scorciatoia
coop.m04.t1 = Islands need bridges. Or wings. Bats are wings with teeth. coop.m04.t1 = Le isole vogliono ponti. O ali. I pipistrelli sono ali con i denti.
coop.m04.t2 = That vault looks heavy. Heavy things should be carried home. coop.m04.t2 = Quel caveau sembra pesante. Le cose pesanti vanno portate a casa.
coop.m05.title = Chains in the Rock coop.m05.title = Catene nella roccia
coop.m05.story = The Lightwatch keeps creatures in chains in sealed caves - creatures that refused to bow. An ogre and a mage, both furious, both available.\n\nDig to them, claim their caves, and the chains will fall. coop.m05.story = La Guardia della Luce tiene in catene, in grotte sigillate, creature che si sono rifiutate di inchinarsi. Un orco e un mago, entrambi furiosi, entrambi disponibili.\n\nScavate fino a loro, conquistate le loro grotte e le catene cadranno.
coop.m05.start = Prisoners! Ours, soon. Free ones, I mean. Free to work for you. coop.m05.start = Prigionieri! Presto nostri. Liberi, intendo. Liberi di lavorare per voi.
coop.m05.goal = Free two chained creatures. coop.m05.goal = Liberate due creature incatenate.
coop.m05.t1 = Listen. Do you hear the ogre swearing? That is gratitude in advance. coop.m05.t1 = Ascoltate. Sentite l'orco imprecare? È gratitudine anticipata.
coop.m05.t2 = Spiders from the moss again. Mossgrim's nursery is busy. coop.m05.t2 = Di nuovo ragni dal muschio. L'asilo di Mossgrim è affollato.
coop.m06.title = Bridge of the Watch coop.m06.title = Il ponte della Guardia
coop.m06.story = Two rivers, one fortress, and the Lightwatch's finest archers on the walls. They call it the Bridge of the Watch. We will call it the bridge that was.\n\nBreak their towers. Leave the bridge - we might need it. coop.m06.story = Due fiumi, una fortezza e i migliori arcieri della Guardia della Luce sulle mura. Lo chiamano il Ponte della Guardia. Noi lo chiameremo il ponte che fu.\n\nSpezzate le loro torri. Lasciate il ponte: potrebbe servirci.
coop.m06.start = Archers on walls are like pigeons on roofs: loud and easy to remove. coop.m06.start = Gli arcieri sulle mura sono come i piccioni sui tetti: rumorosi e facili da togliere.
coop.m06.goal = Raze the knight towers. coop.m06.goal = Radete al suolo le torri dei cavalieri.
coop.m06.t1 = Two rivers mean two bridges. Or one very wet army. coop.m06.t1 = Due fiumi significano due ponti. Oppure un esercito molto bagnato.
coop.m06.t2 = The knights are polishing their armour. Let us give them a reason. coop.m06.t2 = I cavalieri stanno lucidando le armature. Diamogli un motivo.
coop.m07.title = The Lava Smithy coop.m07.title = La fucina di lava
coop.m07.story = Between two rivers of lava the knights forge their blessed blades. Their vault overflows with gold paid for by every dungeon they emptied.\n\nWe are taking it back. All of it. And then some. coop.m07.story = Tra due fiumi di lava i cavalieri forgiano le loro lame benedette. Il loro caveau trabocca d'oro pagato da ogni dungeon che hanno svuotato.\n\nCe lo riprendiamo. Tutto. E anche qualcosa in più.
coop.m07.start = Warm feet, cold hearts, full pockets: my kind of evening. coop.m07.start = Piedi caldi, cuori freddi, tasche piene: la mia serata ideale.
coop.m07.goal = Loot gold from the knights. coop.m07.goal = Saccheggiate l'oro dei cavalieri.
coop.m07.t1 = Do not stand too close to the lava. Your creatures, I mean. You are fine. coop.m07.t1 = Non state troppo vicini alla lava. Le vostre creature, intendo. Voi siete a posto.
coop.m07.t2 = More knights from the east. They really love their gold. coop.m07.t2 = Altri cavalieri da est. Adorano proprio il loro oro.
coop.m08.title = The Field Hospital coop.m08.title = L'ospedale da campo
coop.m08.story = The Lightwatch has set up a field hospital in the caves, and its priests patch up every knight you knock down.\n\nEnd the treatment. Permanently. coop.m08.story = La Guardia della Luce ha allestito un ospedale da campo nelle grotte, e i suoi sacerdoti rattoppano ogni cavaliere che abbattete.\n\nInterrompete la cura. Per sempre.
coop.m08.start = Priests. They heal. We do the opposite. It is a balanced relationship. coop.m08.start = Sacerdoti. Loro guariscono. Noi facciamo il contrario. È un rapporto equilibrato.
coop.m08.goal = Defeat every knight. coop.m08.goal = Sconfiggete tutti i cavalieri.
coop.m08.t1 = Every minute a new priest arrives. Kill faster, masters. coop.m08.t1 = Ogni minuto arriva un nuovo sacerdote. Uccidete più in fretta, maestri.
coop.m08.t2 = A dungeon without hospital bills. That is what I call efficient. coop.m08.t2 = Un dungeon senza spese ospedaliere. Questa sì che è efficienza.
coop.m09.title = Mossgrim's Ambassador coop.m09.title = L'ambasciatore di Mossgrim
coop.m09.story = Mossgrim has sent an ambassador to the knights: a paladin in green-stained armour who carries his master's promises.\n\nPromises should never arrive. Find the ambassador and silence him. coop.m09.story = Mossgrim ha mandato un ambasciatore ai cavalieri: un paladino dall'armatura macchiata di verde che porta le promesse del suo padrone.\n\nLe promesse non dovrebbero mai arrivare. Trovate l'ambasciatore e fatelo tacere.
coop.m09.start = An ambassador. A messenger with a fancy hat. Hats come off. coop.m09.start = Un ambasciatore. Un messaggero con un cappello elegante. I cappelli cadono.
coop.m09.goal = Defeat Mossgrim's ambassador. coop.m09.goal = Sconfiggete l'ambasciatore di Mossgrim.
coop.m09.boss = The ambassador has fallen. His message: undelivered. coop.m09.boss = L'ambasciatore è caduto. Il suo messaggio: mai consegnato.
coop.m09.t1 = The ambassador waits in the east, surrounded by ogres who lost their sense of humour. coop.m09.t1 = L'ambasciatore aspetta a est, circondato da orchi che hanno perso il senso dell'umorismo.
coop.m09.t2 = Somewhere Mossgrim is sending another letter. Let us make it a short one. coop.m09.t2 = Da qualche parte Mossgrim sta spedendo un'altra lettera. Facciamola breve.
coop.m10.title = The Twin Towers coop.m10.title = Le torri gemelle
coop.m10.story = Two fortresses guard the way north, each watching the other's back. Strike one and the other answers.\n\nStrike both. At once. That is what two masters are for. coop.m10.story = Due fortezze sorvegliano la via verso nord, ognuna guarda le spalle all'altra. Colpitene una e l'altra risponde.\n\nColpitele entrambe. Insieme. È a questo che servono due maestri.
coop.m10.start = Two fortresses, two masters. Fair is fair. coop.m10.start = Due fortezze, due maestri. Giusto è giusto.
coop.m10.goal = Raze the knight towers. coop.m10.goal = Radete al suolo le torri dei cavalieri.
coop.m10.t1 = Split up, masters - one fortress each. I will cheer for both. coop.m10.t1 = Dividetevi, maestri: una fortezza a testa. Io farò il tifo per entrambi.
coop.m10.t2 = When both fall at once, the knights will not even know whom to blame. coop.m10.t2 = Se cadono tutte e due insieme, i cavalieri non sapranno nemmeno a chi dare la colpa.
coop.m11.title = The Siege coop.m11.title = L'assedio
coop.m11.story = The Lightwatch has found your hearts and brings everything: five waves of knights and Mossgrim's gargoyles from the cliffs.\n\nNo cleverness this time. Just walls, traps, towers - and each other. coop.m11.story = La Guardia della Luce ha trovato i vostri cuori e porta tutto: cinque ondate di cavalieri e i gargoyle di Mossgrim dalle scogliere.\n\nNiente astuzie stavolta. Solo mura, trappole, torri - e voi due, l'uno accanto all'altro.
coop.m11.start = They are coming. All of them. Well, most of them. coop.m11.start = Arrivano. Tutti. Be', quasi tutti.
coop.m11.goal = Survive five waves. coop.m11.goal = Sopravvivete a cinque ondate.
coop.m11.t1 = Doors and traps, masters. A siege is just guests who refuse to use the bell. coop.m11.t1 = Porte e trappole, maestri. Un assedio è solo una visita di ospiti che si rifiutano di suonare il campanello.
coop.m11.t2 = Gargoyles from the south. Stone with wings - nobody asked for that. coop.m11.t2 = Gargoyle da sud. Pietra con le ali: nessuno l'aveva chiesto.
coop.m12.title = The Gate coop.m12.title = La porta
coop.m12.story = The hero gate in this cavern is how the knights reach the deep dungeons. Close it and their reinforcements must walk the long way round.\n\nBreak the gate, and the Lightwatch will feel it all the way up to the High Marshal. coop.m12.story = La Porta degli Eroi di questa caverna è la via dei cavalieri verso i dungeon profondi. Chiudetela e i loro rinforzi dovranno fare il giro lungo.\n\nSpezzate la porta e la Guardia della Luce lo sentirà fino al Gran Maresciallo.
coop.m12.start = A gate. Gates are just walls with ambitions. coop.m12.start = Una porta. Le porte sono solo muri con ambizioni.
coop.m12.goal = Destroy the hero gate. coop.m12.goal = Distruggete la Porta degli Eroi.
coop.m12.t1 = Waves keep coming out of it. Annoying, like a dripping tap. coop.m12.t1 = Ne escono ondate su ondate. Fastidioso, come un rubinetto che gocciola.
coop.m12.t2 = Push, masters! The gate is cracking. So is my patience. coop.m12.t2 = Spingete, maestri! La porta si sta incrinando. E anche la mia pazienza.
coop.m13.title = Mossy Tunnels coop.m13.title = Tunnel muschiosi
coop.m13.story = We are in Mossgrim's backyard now. His tunnels are soft and damp, and two of his hoards lie in sealed caves.\n\nDig, take, leave. Mossgrim will notice. That is the point. coop.m13.story = Ora siamo nel cortile di Mossgrim. I suoi tunnel sono molli e umidi, e due dei suoi tesori giacciono in grotte sigillate.\n\nScavate, prendete, andatevene. Mossgrim se ne accorgerà. È proprio questo il punto.
coop.m13.start = It smells of mushrooms and treachery. Mossgrim must be near. coop.m13.start = Puzza di funghi e di tradimento. Mossgrim dev'essere vicino.
coop.m13.goal = Loot gold. coop.m13.goal = Saccheggiate l'oro.
coop.m13.t1 = Spiders, again. Mossgrim breeds them like other people grow tomatoes. coop.m13.t1 = Ragni, di nuovo. Mossgrim li alleva come altri coltivano pomodori.
coop.m13.t2 = His hoards are bigger than I thought. Our pockets are bigger still. coop.m13.t2 = I suoi tesori sono più grandi di quanto pensassi. Le nostre tasche, ancora di più.
coop.m14.title = Poison in the Water coop.m14.title = Veleno nell'acqua
coop.m14.story = Mossgrim has turned his dead into revenants and sends them from two sides. The knights call it holy work. Typical.\n\nDefeat every knight and every walking corpse. Then wash your hands. coop.m14.story = Mossgrim ha trasformato i suoi morti in redivivi e li manda da due lati. I cavalieri la chiamano opera santa. Tipico.\n\nSconfiggete ogni cavaliere e ogni cadavere ambulante. Poi lavatevi le mani.
coop.m14.start = Revenants. Our trick, stolen. I am offended. coop.m14.start = Redivivi. Il nostro trucco, rubato. Sono offeso.
coop.m14.goal = Defeat every enemy. coop.m14.goal = Sconfiggete tutti i nemici.
coop.m14.t1 = They come from the north and the south. Back to back, masters. coop.m14.t1 = Arrivano da nord e da sud. Schiena contro schiena, maestri.
coop.m14.t2 = A dead thing that walks is a dead thing that has not finished its job yet. coop.m14.t2 = Un morto che cammina è un morto che non ha ancora finito il suo lavoro.
coop.m15.title = The Chainbreaker coop.m15.title = Lo Spezzacatene
coop.m15.story = Deep in the rock the knights keep the Chainbreaker, a creature even they fear. Free it, and it will fight for whoever opened its cell.\n\nOr grab enough gold for the long way round - Crookfinger knows a smuggler. coop.m15.story = Nel profondo della roccia i cavalieri tengono lo Spezzacatene, una creatura che temono persino loro. Liberatelo e combatterà per chi ha aperto la sua cella.\n\nOppure raccogliete abbastanza oro per il giro lungo: Ditastorte conosce un contrabbandiere.
coop.m15.start = The Chainbreaker. Big, angry, misunderstood. A bit like me. coop.m15.start = Lo Spezzacatene. Grosso, arrabbiato, incompreso. Un po' come me.
coop.m15.goal = Free the Chainbreaker and raze the towers. coop.m15.goal = Liberate lo Spezzacatene e radete al suolo le torri.
coop.m15.branch = Bought the smuggler's map coop.m15.branch = Comprata la mappa del contrabbandiere
coop.m15.t1 = The cell is in the north-west. Mind its temper. coop.m15.t1 = La cella è a nord-ovest. Attenti al suo caratterino.
coop.m15.t2 = The smuggler is waiting. Smugglers hate waiting. And love gold. coop.m15.t2 = Il contrabbandiere aspetta. I contrabbandieri odiano aspettare. E adorano l'oro.
coop.m16.title = The Burning Spring coop.m16.title = La fonte ardente
coop.m16.story = In the middle of a lava lake lies an island with a mana spring that never runs dry. Mossgrim's mages guard it.\n\nTake the spring, defeat the guards. Then sit on the island and enjoy the warmth. coop.m16.story = In mezzo a un lago di lava c'è un'isola con una fonte di mana che non si esaurisce mai. La sorvegliano i maghi di Mossgrim.\n\nPrendete la fonte, sconfiggete le guardie. Poi sedetevi sull'isola e godetevi il calduccio.
coop.m16.start = A spring in lava. Whoever thought of that has style. Shame it is Mossgrim. coop.m16.start = Una fonte nella lava. Chi ci ha pensato ha stile. Peccato che sia Mossgrim.
coop.m16.goal = Claim the spring and defeat every enemy. coop.m16.goal = Conquistate la fonte e sconfiggete tutti i nemici.
coop.m16.t1 = Mages from the east. Their spells are green and so are their teeth. coop.m16.t1 = Maghi da est. I loro incantesimi sono verdi, e anche i loro denti.
coop.m16.t2 = Once the spring is ours, mana for everyone. Share nicely. coop.m16.t2 = Appena la fonte sarà nostra, mana per tutti. Condividete con garbo.
coop.m17.title = Mossgrim's Garden coop.m17.title = Il giardino di Mossgrim
coop.m17.story = This is it: Mossgrim's garden, a cavern of mushrooms and creatures loyal to him. Ogres in the east, an ember wyrm in the north.\n\nTrample the garden. Every mushroom. coop.m17.story = Ci siamo: il giardino di Mossgrim, una caverna di funghi e di creature a lui fedeli. Orchi a est, un drago di brace a nord.\n\nCalpestate il giardino. Ogni singolo fungo.
coop.m17.start = Look at all those mushrooms. Pity about the owner. coop.m17.start = Guardate quanti funghi. Peccato per il proprietario.
coop.m17.goal = Defeat every enemy. coop.m17.goal = Sconfiggete tutti i nemici.
coop.m17.t1 = An ember wyrm! Mossgrim must have saved up for years. coop.m17.t1 = Un drago di brace! Mossgrim deve aver risparmiato per anni.
coop.m17.t2 = The garden is wilting. Gardening is not your strength, masters. That is a compliment. coop.m17.t2 = Il giardino appassisce. Il giardinaggio non è il vostro forte, maestri. È un complimento.
coop.m18.title = The Last Fortress coop.m18.title = L'ultima fortezza
coop.m18.story = Three fortresses guard the stairs down to the High Marshal's hall. They are the last thing between you and the end of the Lightwatch's crusade.\n\nRaze them all. Then the stairs are yours. coop.m18.story = Tre fortezze sorvegliano la scala che scende alla sala del Gran Maresciallo. Sono l'ultima cosa tra voi e la fine della crociata della Guardia della Luce.\n\nRadetele al suolo tutte. Poi la scala sarà vostra.
coop.m18.start = Three fortresses. They really did save the best for last. coop.m18.start = Tre fortezze. Hanno davvero tenuto il meglio per la fine.
coop.m18.goal = Raze the knight towers. coop.m18.goal = Radete al suolo le torri dei cavalieri.
coop.m18.t1 = Each master a fortress, and the third one together. I will count. coop.m18.t1 = Una fortezza per ogni maestro, e la terza insieme. Io tengo il conto.
coop.m18.t2 = I can see the stairs already. Dusty. Nobody comes down here voluntarily. coop.m18.t2 = Vedo già la scala. Polverosa. Nessuno scende quaggiù di sua volontà.
coop.m19.title = The High Marshal coop.m19.title = Il Gran Maresciallo
coop.m19.story = The High Marshal of the Lightwatch himself, in armour polished for a hundred years. His guards come from north and south.\n\nBring him down, and the Order will have nobody left to give the orders. coop.m19.story = Il Gran Maresciallo della Guardia della Luce in persona, in un'armatura lucidata per cento anni. Le sue guardie arrivano da nord e da sud.\n\nAbbattetelo e all'Ordine non resterà nessuno che dia gli ordini.
coop.m19.start = The High Marshal. I have dreamed of this. Mostly the part where he falls over. coop.m19.start = Il Gran Maresciallo. L'ho sognato tante volte. Soprattutto la parte in cui cade.
coop.m19.goal = Defeat the High Marshal. coop.m19.goal = Sconfiggete il Gran Maresciallo.
coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped cutlery drawer. coop.m19.boss = Il Gran Maresciallo è caduto! La sua armatura sferraglia come un cassetto delle posate rovesciato.
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first. coop.m19.t1 = Aspetta a est. Lasciatelo aspettare. Prima eliminate le guardie.
coop.m19.t2 = Knights and archers again. The Order is running out of ideas. coop.m19.t2 = Di nuovo cavalieri e arciere. L'Ordine sta finendo le idee.
coop.m20.title = Mossgrim's Fall coop.m20.title = La caduta di Mossgrim
coop.m20.story = With the High Marshal gone, Mossgrim stands alone. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nEnd the traitor. End his army. And then, masters - tea. coop.m20.story = Caduto l'Alto Maresciallo, Mossgrim è solo: su nel bosco, al sole, figurarsi. Ha preso la forma di un drago di brace e radunato ogni creatura che ancora lo ascolta.\n\nUscite dalla roccia e finite il traditore. Finite il suo esercito. E poi, maestri: tè.
coop.m20.start = Mossgrim, you old fungus. The league sends its regards. coop.m20.start = Mossgrim, vecchio fungo. La lega ti manda i suoi saluti.
coop.m20.goal = Defeat Mossgrim and all his creatures. coop.m20.goal = Sconfiggete Mossgrim e tutte le sue creature.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not. coop.m20.boss = Mossgrim non c'è più. Il muschio ricrescerà. Lui no.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go. coop.m20.t1 = Quel drago a est è Mossgrim. Sì, si è lasciato andare.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong. coop.m20.t2 = Ogni creatura che sconfiggete è una in meno che racconterà male questa storia.
# achievements and statistics
menu.achievements = Obiettivi
ach.count = {0} di {1} obiettivi sbloccati · {2} minuti giocati
ach.hidden = Un segreto. Ditastorte non parla.
msg.achievement = Obiettivo sbloccato: {0}!
end.stats2 = Eroi {0} · Creature perse {1} · Caselle scavate {2} · Oro {3} · Legno {4}
ach.heroes.name = Flagello degli eroi {0}
ach.heroes.desc = Sconfiggi {0} eroi.
ach.bosses.name = Spezzacampioni {0}
ach.bosses.desc = Abbatti {0} campioni dell'Ordine.
ach.dug.name = Scavatore profondo {0}
ach.dug.desc = Scava {0} caselle.
ach.gold.name = Accumulatore {0}
ach.gold.desc = Guadagna {0} oro.
ach.wood.name = Signore del legno {0}
ach.wood.desc = Abbatti {0} legno.
ach.walls.name = Muratore {0}
ach.walls.desc = Alza {0} muri.
ach.rooms.name = Architetto delle ombre {0}
ach.rooms.desc = Costruisci {0} caselle di stanza.
ach.spells.name = Incantatore oscuro {0}
ach.spells.desc = Lancia {0} magie.
ach.slaps.name = Mano ferma {0}
ach.slaps.desc = Dai {0} schiaffi alle tue creature. Lo adorano. Probabilmente.
ach.traps.name = Fabbro di trappole {0}
ach.traps.desc = Ordina {0} trappole e porte alla fucina.
ach.towers.name = Signore delle torri {0}
ach.towers.desc = Costruisci {0} torri.
ach.drowned.name = Lezioni di nuoto {0}
ach.drowned.desc = Fai cadere {0} eroi in acqua o nella lava.
ach.burnt.name = Bruciaponti {0}
ach.burnt.desc = Brucia {0} assi di ponte.
ach.siege.name = Maestro d'assedio {0}
ach.siege.desc = Costruisci {0} macchine d'assedio.
ach.captured.name = Carceriere {0}
ach.captured.desc = Cattura {0} eroi.
ach.drawbridge.name = Su che si va! {0}
ach.drawbridge.desc = Alza i ponti levatoi {0} volte.
ach.wins.name = Conquistatore {0}
ach.wins.desc = Vinci {0} partite libere.
ach.missions.name = Cercaframmenti {0}
ach.missions.desc = Vinci {0} missioni della campagna.
ach.coop.name = Membro della lega {0}
ach.coop.desc = Vinci {0} missioni co-op.
ach.minutes.name = Nottambulo {0}
ach.minutes.desc = Gioca per {0} minuti.
ach.flawless.name = Neanche un graffio
ach.flawless.desc = Vinci senza perdere nemmeno una creatura.
ach.speedrun.name = Prima che il tè si raffreddi
ach.speedrun.desc = Vinci entro quindici minuti.
ach.campaign.name = L'Unico Maestro
ach.campaign.desc = Vinci l'ultima missione della campagna.
ach.surface.name = Scottatura
ach.surface.desc = Vinci una partita che arriva fino in superficie.
ach.defeat.name = Tè e compassione
ach.defeat.desc = Perdi una partita. Ditastorte mette su il bollitore.
ach.legend.name = Leggenda vivente
ach.legend.desc = Una creatura raggiunge il livello dieci.
ach.wyrm.name = Hic sunt dracones
ach.wyrm.desc = Un drago di brace si unisce al tuo dungeon.
ach.fullhand.name = Mani piene
ach.fullhand.desc = Tieni in mano otto unità contemporaneamente.
# tutorial
menu.tutorial = Tutorial
tut.name = Primi passi
tut.title = Ditastorte ti insegna il mestiere
tut.story = Dunque sei tu il nuovo padrone. Piccolo, ma il nucleo batte ancora, ed è questo che conta.\n\nTi mostrerò le basi: scavare, stanze, creature e cosa fare con i cavalieri. Fai quello che dico e nessuno si farà male. Tranne i cavalieri.
tut.start = Ditastorte: Prima lezione: scavare. Prendi il piccone (tasto 1), trascina sulla terra a est del cuore e i tuoi imp la scaveranno.
tut.obj1 = Scava il tunnel a est del cuore.
tut.dug = Ditastorte: Guarda, c'è una sala dietro! I tuoi imp reclamano il suolo da soli. Ora apri Stanze e costruisci una tana sul tuo pavimento: lì dormono le tue creature.
tut.obj2 = Costruisci una tana (Stanze).
tut.lair = Ditastorte: Letti, fatto. Anche le creature mangiano: costruisci una fungaia lì accanto.
tut.obj3 = Costruisci una fungaia (Stanze).
tut.farm = Ditastorte: Vitto e alloggio: ora il portale ti manda creature. Pazienza, arrivano da sole. Aspettane due.
tut.obj4 = Aspetta che due creature arrivino dal portale.
tut.creatures = Ditastorte: Eccole! Un po' di incoraggiamento non guasta mai: prendi la mano (tasto 2) e clicca su una creatura tenendo premuto Shift (su touch screen: toccala due volte) per darle uno schiaffo. Per un po' lavorerà più in fretta.
tut.obj5 = Dai uno schiaffo a una delle tue creature (mano, Shift + clic).
tut.slap = Ditastorte: Ah! Hai sentito? Qualcosa arriva da est. Preparati: il fulmine (Magie) colpisce duro, e le tue creature combattono da sole.
tut.obj6 = Sconfiggi i due cavalieri.
tut.knights = Ditastorte: Due cavalieri! Manda le tue creature con la bandiera d'attacco (Ordini) o colpiscili con il fulmine.
tut.win = Ditastorte: Ben fatto, padrone! Tutto qui. Be', quasi. Il resto lo racconta la campagna. Metto su il bollitore.
# master talents
cat.master = Maestro
talent.title = Talenti del maestro
talent.points = Punti talento liberi: {0}
talent.next = {0}/{1} esperienza al prossimo punto
talent.all = Tutti i talenti sono appresi.
talent.builder = Costruttore
talent.warlord = Condottiero
talent.warlock = Stregone
talent.merchant = Mercante
talent.builder.desc = Gli Imp scavano il {0}% più in fretta, le stanze costano il {1}% in meno.
talent.warlord.desc = Le creature colpiscono il {0}% più forte e subiscono il {1}% di danni in meno.
talent.warlock.desc = Il mana scorre il {0}% più in fretta, le magie costano il {1}% in meno.
talent.merchant.desc = I salari sono il {0}% più bassi, il tesoro consegnato vale il {1}% in più.
talent.none = Non ancora appreso.
talent.then = Livello successivo: {0}
msg.talent_point = Un nuovo punto talento! Spendilo nella finestra del maestro.
msg.talent_learned = {0}: livello {1} appreso.
# weather above ground
weather.fair = Sereno
weather.rain = Pioggia
weather.fog = Nebbia
weather.storm = Tempesta
msg.weather_clear = In superficie il cielo si rasserena.
msg.weather_rain = In superficie inizia a piovere: il fuoco non attecchisce.
msg.weather_fog = La nebbia avvolge la terra: all'aperto tutti vedono meno.
msg.weather_storm = In superficie si avvicina una tempesta! I fulmini colpiscono unità e alberi.
msg.lightning_unit = Un fulmine colpisce {0}!
# creature names
names.imp = Briciola, Moccolo, Scavino, Fanghetto, Pidocchio, Sudicino, Grattino, Zozzo, Pulcino, Ciottolo, Rospetto, Scricciolo, Mollica, Fuliggine, Tappo, Bricco, Zampetto, Bigolo, Frugolo, Pallino
names.kobold = Ringhio, Graffio, Morso, Straccio, Rusco, Zanna, Pizzico, Rognoso, Uncino, Cardo, Spillo, Furbetto, Tarlo, Sgraffa, Ladruncolo, Sdentato, Sassolino, Ruggine, Grinza, Malandrino
names.spider = Zampette, Ragnetto, Filuccia, Solletico, Ottopiedi, Setina, Tessitrice, Batuffolo, Streghina, Groviglio, Gomitolo, Aghetto, Trappolina, Zannina, Pois, Merletto, Ricamina, Pungino, Ombretta, Ragnatela
names.mage = Bartolomeo, Ignazio, Ermenegildo, Anacleto, Fulgenzio, Eustachio, Leopoldo, Baldassarre, Prospero, Teodoro, Sigismondo, Fiorenzo, Casimiro, Gualtiero, Pancrazio, Aurelio, Ruggero, Cipriano, Serafino, Venceslao
names.ogre = Macigno, Testone, Zuccone, Bestione, Mattone, Bruto, Mangione, Ciccione, Ceppo, Tontolone, Tronco, Pancione, Babbeo, Pataccone, Gnocco, Bombo, Ciocco, Sberla, Bamboccione, Somarone
names.jailer = Chiavistello, Lucchetto, Ceppi, Cupo, Torvo, Segreta, Sbarra, Bieco, Ferro, Catena, Severo, Paletto, Manetta, Cerbero, Chiavaio, Lugubre, Arcigno, Gattabuia, Spranga, Anello
names.priestess = Brace, Favilla, Fiamma, Pira, Scintilla, Cenere, Vermiglia, Falò, Fulgida, Vampa, Morgana, Serafina, Scarlatta, Fiammetta, Ignea, Rubina, Ambra, Fenice, Candela, Lucilla
names.chainbreaker = Spezzacatene, Ulfgar, Animaferro, Brannoc, Kragmor, Valdrek, Spino, Gorath, Indomito, Rurik, Torvaldo, Agone, Durnok, Frantumo, Brando, Gualfredo, Korrun, Arvak, Liberatore, Alvardo
names.bat = Squittio, Alucce, Squitto, Morsetto, Eco, Pipistrello, Crepuscolo, Zinzino, Cri, Dentino, Svolazzo, Notturno, Falenino, Svolazzino, Picchiata, Pendolino, Vellutino, Ombrino, Strillo, Zigzag
names.gargoyle = Granito, Ardesia, Basalto, Selciato, Calcina, Ciottolone, Ghiaia, Selce, Mattone, Maceria, Quarzo, Pomice, Cornicione, Grifone, Musone, Travertino, Frontone, Scalpellino, Scalpello, Macigno
names.owl = Saputello, Gufetto, Piuma, Barbagianni, Minerva, Tobia, Civetta, Sapienzio, Piumino, Savio, Meditabondo, Anziano, Assiolo, Noctua, Allocco, Pergamena, Imbrunire, Gufone, Professore, Erudito
names.wyrm = Vermitrax, Fauci, Bruciaterra, Calderone, Ignivoro, Fumarolo, Drakkar, Pirro, Ossidiana, Boccafuoco, Vulcanor, Ustione, Malgrath, Tyrax, Inferno, Zolfo, Kaldor, Fyrnak, Ragnor, Tizzone
names.cogling = Bulloncino, Ingranaggio, Vitina, Cricchetto, Perno, Pignone, Aggeggio, Arnese, Chiavetta, Molla, Tintinnio, Coso, Rivetto, Ronzio, Manovella, Rondella, Cardine, Marchingegno, Tictac, Molletta
names.revenant = Lamento, Pena, Cinereo, Strazio, Vuoto, Lutto, Livido, Dolore, Spettro, Nenia, Tumulo, Penombra, Cripta, Pianto, Veglia, Sudario, Esangue, Elegia, Anima, Desolato
names.beast = Arruffo, Criniera, Bruno, Pelone, Sgranocchio, Muschio, Brontolo, Orsetto, Cespuglio, Scrocchio, Guscio, Zampone, Setola, Muffa, Rombo, Rovo, Cricrì, Borbotto, Bestione, Annusino
names.human = Rinaldo, Orlando, Corrado, Goffredo, Tristano, Ugo, Galvano, Lancillotto, Astolfo, Tancredi, Bertoldo, Ruggiero, Oliviero, Baldovino, Rambaldo, Guglielmo, Ettore, Gilberto, Rodolfo, Brunetto
epithets.mid = Spaccaossa, Pestafango, Mordiratti, Faccialercia, Masticasassi, Picchiateschi, Pugnopuzzo, Nocchedruggine, Sventrapance, Calciastinchi, Barbamuffa, Artigliosozzo, Sparamocci, Mangiaorecchie, Spaccaginocchia, Mangiavermi
epithets.high = Regicida, Manofunesta, Zannaterrore, Spaccamondi, Flagelloeroi, Pugnotempesta, Terrornotturno, Mietianime, Cuordiferro, Sfondacastelli, Ammazzadraghi, Passainferi, Signordombra, Coronasangue, Mareafunesta, Spezzastelle
name.epithet = {0} {1}
msg.creature_fallen = {0} ci ha lasciati. Una lapide commemorativa ne custodisce il nome.
tile.memorial = Lapide commemorativa
tile.memorial_info = Qui giace {0} ({1}, livello {2}).
# hall of the fallen
hall.title = Sala dei caduti
hall.count = Creature cadute ricordate: {0}
hall.empty = Nessuno è ancora caduto. Che duri a lungo.
hall.kills = Eroi uccisi: {0}
ov.fallen = Caduti ({0})
ov.living = Vivi
cause.slain = morte per mano di: {0}
cause.lava = morte nella lava
cause.water = annegamento
cause.snare = morte in una tagliola
cause.lightning = colpo di fulmine
cause.fell = caduta in battaglia
+1770
View File
File diff suppressed because it is too large. Load diff
+1769
View File
File diff suppressed because it is too large. Load diff
+1770
View File
File diff suppressed because it is too large. Load diff
+1770
View File
File diff suppressed because it is too large. Load diff
+422 -137
View File
@@ -53,17 +53,29 @@ msg.creature_arrived = Через портал пришёл новичок: {0}.
msg.payday_partial = День выплат! {0} существ получили жалованье ({1} золота), {2} остались ни с чем и злятся! msg.payday_partial = День выплат! {0} существ получили жалованье ({1} золота), {2} остались ни с чем и злятся!
msg.payday = День выплат! {0} существ получили {1} золота. msg.payday = День выплат! {0} существ получили {1} золота.
msg.wave_boss = Волна {0}: Паладин Света ведёт в твои владения {1} героев! msg.wave_boss = Волна {0}: Паладин Света ведёт в твои владения {1} героев!
boss.aldric = Сэр Алдрик Непреклонный
boss.seraphine = Дама Серафина Рассветная
boss.halvard = Брат Халвард Благочестивый
boss.rothgar = Капитан Ротгар Железный Бок
msg.boss_comes = Волну ведёт чемпион ордена: {0}!
msg.boss_slain = {0} пал! Добыча: {1} золота и чертежи башни рунной кузницы.
msg.wave = Волна {0}: {1} героев прорываются через врата! msg.wave = Волна {0}: {1} героев прорываются через врата!
msg.gate_boss = Врата рушатся! Появляется Страж врат и {0} защитников! msg.gate_boss = Врата рушатся! Появляется Страж врат и {0} защитников!
msg.gate_defenders = Врата призывают {0} защитников! msg.gate_defenders = Врата призывают {0} защитников!
msg.level_up = {0}: достигнут уровень {1}. msg.level_up = {0}: достигнут уровень {1}.
msg.hero_slain = Повержен: {0}! Добыча: {1} золота. msg.hero_slain = Повержен: {0}! Добыча: {1} золота.
msg.thief_in_treasury = Вор в твоей сокровищнице!
msg.thief_escaped = Вор сбежал с {0} золота!
msg.snare_caught = Попал в капкан: {0}!
msg.converted = Инквизитор обратил одно из твоих существ: {0}!
msg.creature_died = Пал твой боец: {0}. msg.creature_died = Пал твой боец: {0}.
msg.portal_claimed = Портал твой! Он привлекает существ. msg.portal_claimed = Портал твой! Он привлекает существ.
msg.flag_removed = Флаг атаки убран. Существа возвращаются. msg.flag_removed = Флаг атаки убран. Существа возвращаются.
msg.flag_set = Флаг атаки установлен! Готовые к бою существа собираются там. msg.flag_set = Флаг атаки установлен! Готовые к бою существа собираются там.
msg.creature_leaves = Недовольное существо ({0}) покидает твои владения! msg.creature_leaves = Недовольное существо ({0}) покидает твои владения!
msg.no_gold = Не хватает золота! msg.no_gold = Не хватает золота!
msg.no_wood = Мало дерева! Рубите гигантские грибы (или деревья) киркой.
msg.siege_built = {0} готов — поставьте флаг атаки, чтобы отправить его.
msg.sold = Комнаты проданы: +{0} золота. msg.sold = Комнаты проданы: +{0} золота.
msg.no_mana_bolt = Не хватает маны для молнии. msg.no_mana_bolt = Не хватает маны для молнии.
msg.no_mana_heal = Не хватает маны для лечения. msg.no_mana_heal = Не хватает маны для лечения.
@@ -73,6 +85,7 @@ msg.no_mana_cost = Не хватает маны (нужно {0}).
hint.portal_no_lair = Портал открыт, но без логова существа не придут. hint.portal_no_lair = Портал открыт, но без логова существа не придут.
hint.no_farm = Существа хотят есть: построй грибной сад. hint.no_farm = Существа хотят есть: построй грибной сад.
hint.lair_full = Все места в логове заняты. Расширь логово. hint.lair_full = Все места в логове заняты. Расширь логово.
hint.creature_max = В твоём подземелье 100 существ - больше через портал не придёт.
hint.gate_attack = Твои существа атакуют Врата героев! hint.gate_attack = Твои существа атакуют Врата героев!
hint.treasury_full = Сокровищницы полны! Построй больше клеток сокровищницы. hint.treasury_full = Сокровищницы полны! Построй больше клеток сокровищницы.
hint.flag_unreachable = Существа не могут добраться до флага атаки. hint.flag_unreachable = Существа не могут добраться до флага атаки.
@@ -124,6 +137,7 @@ ui.key = Клавиша: {0}
# ---- HUD # ---- HUD
hud.gold = Золото hud.gold = Золото
hud.wood = Дерево
hud.mana = Мана hud.mana = Мана
hud.core = Ядро hud.core = Ядро
hud.gate = Врата hud.gate = Врата
@@ -145,6 +159,7 @@ help.k_right = Правый клик Опустить существо (пл
help.k_slap = Shift+Левый Рука: шлепок своим, удар героям на своей земле help.k_slap = Shift+Левый Рука: шлепок своим, удар героям на своей земле
help.k_pan = Тянуть правой Двигать карту (также WASD/стрелки, край экрана) help.k_pan = Тянуть правой Двигать карту (также WASD/стрелки, край экрана)
help.k_rot = Тянуть средней Вращать и наклонять камеру (также Q/E, R/F) help.k_rot = Тянуть средней Вращать и наклонять камеру (также Q/E, R/F)
help.k_reset = Средняя 2x щелчок Сбросить угол и поворот (место остаётся)
help.k_zoom = Колесо мыши Масштаб help.k_zoom = Колесо мыши Масштаб
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Выбор инструмента help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Выбор инструмента
help.k_misc = P / Пробел Пауза M Звук вкл/выкл +/- Громкость help.k_misc = P / Пробел Пауза M Звук вкл/выкл +/- Громкость
@@ -237,6 +252,7 @@ set.lights = Источники света
set.ui_scale = Размер интерфейса set.ui_scale = Размер интерфейса
set.game_speed = Скорость игры set.game_speed = Скорость игры
set.hints = Подсказки set.hints = Подсказки
set.events = Случайные события
set.keys_hint = Выбери действие и нажми клавишу. Занятые клавиши меняются местами. set.keys_hint = Выбери действие и нажми клавишу. Занятые клавиши меняются местами.
set.press_key = Нажми клавишу для: {0} set.press_key = Нажми клавишу для: {0}
set.esc_cancel = Esc - отмена set.esc_cancel = Esc - отмена
@@ -248,6 +264,8 @@ val.medium = Средне
val.high = Высоко val.high = Высоко
val.normal = Обычный val.normal = Обычный
val.large = Большой val.large = Большой
val.small = Маленький
val.auto_plain = Авто
# ---- key binding names # ---- key binding names
key.cam_forward = Камера вперёд key.cam_forward = Камера вперёд
@@ -313,12 +331,30 @@ hint.tower_max = Эта башня улучшена до предела.
hint.tower_where = Башни строятся только на захваченном пустом полу. hint.tower_where = Башни строятся только на захваченном пустом полу.
hint.tower_mana = У магических башен кончилась мана. hint.tower_mana = У магических башен кончилась мана.
job.repair = Чинит башню job.repair = Чинит башню
job.mend_bridge = Чинит мост
job.raise_wall = Возводит стену
job.march = Идёт к мечу
job.attack_tower = Атакует башню job.attack_tower = Атакует башню
unit.jailer = Тюремщик unit.jailer = Тюремщик
unit.priestess = Жрица пламени unit.priestess = Жрица пламени
msg.hero_captured = {0} оглушён и взят в плен! Добыча: {1} золота. msg.hero_captured = {0} оглушён и взят в плен! Добыча: {1} золота.
msg.defected = {0} переходит на твою сторону! msg.defected = {0} переходит на твою сторону!
msg.beast_tamed = Дикий {0} сдаётся и присоединяется к твоему подземелью!
gear.1 = железное снаряжение
gear.2 = стальное снаряжение
gear.3 = руническое снаряжение
msg.gear_given = Кузница вручает {0}: {1}.
msg.trader_arrives = Через твой портал пришёл торговец: {0} за {1} золота. Щёлкни по нему рукой, чтобы купить, — он ждёт минуту.
msg.trader_sold = Куплено: {0}. Приятно иметь дело, говорит торговец.
msg.trader_left = Торговец пожимает плечами и уходит через портал.
trader.info = Предлагает {0} за {1} золота (щёлкни рукой)
trader.left = Уйдёт через {0} с
trader.rune = чертежи башни рунной кузницы
trader.wyrm = огненного змея
trader.chainbreaker = разрушителя цепей
trader.wood = 30 дерева
trader.mana = зелье, восполняющее ману
msg.prisoner_died = {0} не пережил пыток. msg.prisoner_died = {0} не пережил пыток.
job.carry = Несёт пленника job.carry = Несёт пленника
job.pray = Молится job.pray = Молится
@@ -329,6 +365,10 @@ info.tortured = его пытают
info.prison = Пленники: {0}/{1} info.prison = Пленники: {0}/{1}
msg.wave_warn_dir = Разведчики докладывают: волна героев {0} будет у врат через 30 секунд! Они хотят прорваться с направления: {1}. msg.wave_warn_dir = Разведчики докладывают: волна героев {0} будет у врат через 30 секунд! Они хотят прорваться с направления: {1}.
msg.event_quake = Землетрясение! Туннели на {0} обрушились.
msg.event_lava = Лава выходит из берегов на {0}!
msg.event_flood = Наводнение! Река выходит из берегов на {0}.
msg.event_vein = Бесы докладывают о новой золотой жиле на {0}!
msg.wave_warn_two = Разведчики докладывают: волна героев {0} будет у врат через 30 секунд! Они разделились и прорвутся с направлений: {1} и {2}. msg.wave_warn_two = Разведчики докладывают: волна героев {0} будет у врат через 30 секунд! Они разделились и прорвутся с направлений: {1} и {2}.
dir.n = север dir.n = север
dir.ne = северо-восток dir.ne = северо-восток
@@ -340,7 +380,8 @@ dir.w = запад
dir.nw = северо-запад dir.nw = северо-запад
val.auto = Авто ({0}x) val.auto = Авто ({0}x)
set.render_scale = Разрешение 3D set.render_scale = Пиксельный вид
set.pixel_rows_val = {0} строк
set.debug_fps = Показывать FPS set.debug_fps = Показывать FPS
set.debug_frame_time = Время кадра set.debug_frame_time = Время кадра
set.debug_ping = Показывать пинг set.debug_ping = Показывать пинг
@@ -398,8 +439,25 @@ help.start_touch = Коснись, чтобы начать
tool.bridge.label = Мост tool.bridge.label = Мост
tool.bridge.name = Построить мост tool.bridge.name = Построить мост
tool.bridge.desc = Строит мост через воду или лаву. Тяни от своей земли через реку; мост растёт клетка за клеткой от твоей территории. tool.bridge.desc = Строит мост через воду или лаву. Тяни от своей земли через реку; мост растёт клетка за клеткой от твоей территории.
cost.bridge = {0} золота/клетка над водой, {1} над лавой tool.drawbridge.label = Подъёмный
tool.drawbridge.name = Построить подъёмный мост
tool.drawbridge.desc = Мост, который можно поднять: протяните через реку, чтобы построить, щёлкните, чтобы переключить: опущен, поднят, автоматически (поднимается при приближении врагов). Дерево над водой горит, камень над лавой — нет.
tool.wall.label = Стена
tool.wall.name = Возвести стену
tool.wall.desc = Планируйте стены на своём пустом полу (повторное протягивание отменяет): бесы возводят их из укреплённой земли. Закрывайте туннели и ставьте двери там, где нужно.
tool.ram.label = Таран
tool.ram.name = Построить таран
tool.ram.desc = Таран выкатывается из кузницы (золото и дерево). Он идёт к флагу атаки и разбивает ворота и башни рыцарей.
tool.catapult.label = Катапульта
tool.catapult.name = Построить катапульту
tool.catapult.desc = Катапульта выкатывается из кузницы (золото и дерево). Она обстреливает башни рыцарей издалека и идёт к флагу атаки к их воротам.
cost.bridge = {0} золота + {1} дерева/клетка над водой, {2} золота над лавой
cost.drawbridge = {0} золота + {1} дерева/клетка над водой, {2} золота над лавой
cost.gold_wood = {0} золота + {1} дерева
hint.bridge_where = Мост должен начинаться у твоей земли: тяни от своей территории через реку. hint.bridge_where = Мост должен начинаться у твоей земли: тяни от своей территории через реку.
hint.bridge_fire = Деревянные мосты горят: держите огненные ловушки, огненное дыхание и молнии подальше; каменные мосты над лавой не горят.
hint.wall_where = Стены можно возводить только на своём пустом полу (без комнат, ловушек и дверей).
hint.siege_where = Осадные машины ставятся на ваш свободный пол — и нужна кузница.
hint.bridge_river = Мосты строятся только над водой или лавой. hint.bridge_river = Мосты строятся только над водой или лавой.
msg.hero_burned = Герой упал в лаву: {0}! +{1} золота. msg.hero_burned = Герой упал в лаву: {0}! +{1} золота.
msg.creature_burned = Твой боец упал в лаву: {0}. msg.creature_burned = Твой боец упал в лаву: {0}.
@@ -409,6 +467,22 @@ tile.lava = Лава
tile.lava_info = Всё, что туда падает, сгорает. Её пересекают каменные мосты. tile.lava_info = Всё, что туда падает, сгорает. Её пересекают каменные мосты.
tile.bridge = Деревянный мост tile.bridge = Деревянный мост
tile.lavabridge = Каменный мост tile.lavabridge = Каменный мост
tile.mushroom = Гигантский гриб
tile.mushroom_grown = Вырос: можно рубить.
tile.mushroom_growing = Растёт: {0}% - срубить можно только взрослый гриб.
tile.grove = Грибная роща
tile.grove_info = Этот мох нельзя захватить. Здесь снова вырастают гигантские грибы.
tile.tree = Дерево
tile.plant_info = Отметьте киркой: бес срубит его за {0} дерева.
tile.drawbridge = Подъёмный мост
tile.drawbridge_up = Поднят
tile.drawbridge_down = Опущен
tile.drawbridge_auto = Автоматически (опущен)
tile.drawbridge_auto_up = Автоматически (поднят)
tile.obsidian = Обсидиан
tile.obsidian_info = Застывшая лава — растает через {0} с
tile.bridge_burning = Горит!
tile.bridge_worn = Повреждён на {0}%
tile.shard = Осколок твоего ядра души tile.shard = Осколок твоего ядра души
tile.shard_info = Захвати этот зал, чтобы вернуть осколок. tile.shard_info = Захвати этот зал, чтобы вернуть осколок.
@@ -430,6 +504,11 @@ msg.enemy_tower_destroyed = Рыцарская постройка пала: {0}!
msg.vault_taken = Рыцарское хранилище взято: сокровища на {0} твои. msg.vault_taken = Рыцарское хранилище взято: сокровища на {0} твои.
msg.shard_taken = Осколок твоего ядра души снова в твоих руках! msg.shard_taken = Осколок твоего ядра души снова в твоих руках!
msg.bridge_torn = Рыцари разрушили один из твоих мостов! msg.bridge_torn = Рыцари разрушили один из твоих мостов!
msg.bridge_fire = Один из ваших мостов горит!
msg.drawbridge_up = Подъёмный мост поднят.
msg.drawbridge_down = Подъёмный мост опущен.
msg.drawbridge_auto = Подъёмный мост сам поднимается при приближении врагов.
msg.drawbridge_rises = Враги близко: подъёмный мост поднимается!
job.tear_bridge = Ломает мост job.tear_bridge = Ломает мост
job.patrol = В дозоре job.patrol = В дозоре
job.guard = На страже job.guard = На страже
@@ -533,7 +612,7 @@ camp.m29.story = Лавовые острова, хозяин, посреди н
camp.m20.title = Великий магистр camp.m20.title = Великий магистр
camp.m20.story = Великий магистр Валериан Светлощит ждёт в своей цитадели за великой рекой, с последним осколком и вратами ордена. Сто лет он молился, полировал и готовился. | Разрушь его врата и верни осколок. Тогда твоё ядро станет целым - и все хозяева, что когда-либо его хотели, явятся за ним разом. Всё по порядку. camp.m20.story = Великий магистр Валериан Светлощит ждёт в своей цитадели за великой рекой, с последним осколком и вратами ордена. Сто лет он молился, полировал и готовился. | Разрушь его врата и верни осколок. Тогда твоё ядро станет целым - и все хозяева, что когда-либо его хотели, явятся за ним разом. Всё по порядку.
camp.m30.title = Трон глубин camp.m30.title = Трон глубин
camp.m30.story = Вышло, как я и говорил: твоё ядро целое, и теперь его хотят все. Моссгрим и леди Найтвелл идут вместе, а остатки Ордена в последний раз открывают свои врата. | Уничтожь оба ядра хозяев и врата ордена. После этого у глубин будет ровно один хозяин. Угадай кто. Подсказка: не я, я тут по части чая. camp.m30.story = Вышло, как я говорил: ваше ядро цело, и теперь его хотят все. Моссгрим и леди Найтвелл прокопались на поверхность, а верховный маршал ждёт в своей крепости в лесу — дневной свет, птички, полный набор. Мерзость. | Выберитесь из скалы, уничтожьте оба ядра мастеров и врата ордена в крепости. После этого в мире будет ровно один мастер. Угадайте кто. Подсказка: не я, я чайный.
camp.epilogue = Зелёное ядро рассыпается, синее гаснет, и врата ордена умолкают. Где-то наверху Светлый Дозор пишет очень длинный отчёт, а письма леди Найтвелл больше не приходят. | Кривопал заварил чай. Ты снова целый, хозяин. Подземелье твоё. camp.epilogue = Зелёное ядро рассыпается, синее гаснет, и врата ордена умолкают. Где-то наверху Светлый Дозор пишет очень длинный отчёт, а письма леди Найтвелл больше не приходят. | Кривопал заварил чай. Ты снова целый, хозяин. Подземелье твоё.
lair.arrivals = {0} свободных мест: новые существа приходят со скоростью {1}% lair.arrivals = {0} свободных мест: новые существа приходят со скоростью {1}%
job.help = Помогает job.help = Помогает
@@ -657,6 +736,7 @@ net.error = {0}
net.rooms = Открытые матчи net.rooms = Открытые матчи
net.no_rooms = Нет открытых матчей. net.no_rooms = Нет открытых матчей.
net.create = Создать матч net.create = Создать матч
net.map_size = Карта {0}x{1} · {2} игроков
net.coop_mission = Кооп-кампания: миссия {0} ({1}) net.coop_mission = Кооп-кампания: миссия {0} ({1})
net.coop_create = Кооп-кампания net.coop_create = Кооп-кампания
net.join = Войти net.join = Войти
@@ -687,6 +767,9 @@ net.ai_off = Выкл
# rival masters (free game) # rival masters (free game)
newgame.difficulty = Противники newgame.difficulty = Противники
newgame.land = Местность
newgame.land0 = Под землёй
newgame.land1 = Наполовину наверху
diff.0 = Легко diff.0 = Легко
diff.1 = Средне diff.1 = Средне
diff.2 = Сложно diff.2 = Сложно
@@ -699,9 +782,14 @@ msg.rival1_attack = Моссгрим, зелёный хозяин, шлёт на
msg.rival2_attack = Леди Найтвелл, синяя хозяйка, шлёт на тебя {0} существ! msg.rival2_attack = Леди Найтвелл, синяя хозяйка, шлёт на тебя {0} существ!
msg.rival1_out = Моссгрим повержен - его ядро души разбито! msg.rival1_out = Моссгрим повержен - его ядро души разбито!
msg.rival2_out = Леди Найтвелл повержена - её ядро души разбито! msg.rival2_out = Леди Найтвелл повержена - её ядро души разбито!
msg.rival3_attack = Саффракс, жёлтый хозяин, шлёт на тебя {0} существ!
msg.rival3_out = Саффракс повержен - его ядро души разбито!
msg.rival_unit_slain = Пал боец соперника: {0}! +{1} золота. msg.rival_unit_slain = Пал боец соперника: {0}! +{1} золота.
msg.wave_on_rival1 = Волна {0}: {1} героев идут на зелёного хозяина. msg.wave_on_rival1 = Волна {0}: {1} героев идут на зелёного хозяина.
msg.wave_on_rival2 = Волна {0}: {1} героев идут на синюю хозяйку. msg.wave_on_rival2 = Волна {0}: {1} героев идут на синюю хозяйку.
msg.wave_warn_on_master = Разведчики докладывают: волна {0} будет через 30 секунд и идёт на мастера {1}.
msg.wave_on_master = Волна {0}: {1} героев идут на мастера {2}.
msg.partner_out = Сердце вашего ИИ-союзника пало – сражайтесь в одиночку!
hud.rival1 = Зелёный hud.rival1 = Зелёный
hud.rival2 = Синяя hud.rival2 = Синяя
msg.creature_freed = Существо рвёт цепи и присоединяется к тебе: {0}! msg.creature_freed = Существо рвёт цепи и присоединяется к тебе: {0}!
@@ -790,6 +878,8 @@ best.b.torture.text = Сюда попадают пленные герои. Со
best.b.temple.text = Молитвы лечат ядро души и поднимают дух. Жрица пламени приходит на храм. best.b.temple.text = Молитвы лечат ядро души и поднимают дух. Жрица пламени приходит на храм.
best.b.aerie.text = Насесты и гнёзда для летунов. Нетопырям, горгульям, совам и даже огненному змию нужно здесь место. best.b.aerie.text = Насесты и гнёзда для летунов. Нетопырям, горгульям, совам и даже огненному змию нужно здесь место.
best.b.bridge.text = Дерево над водой, камень над лавой. Рыцари ломают твои мосты - захватывай их мосты. best.b.bridge.text = Дерево над водой, камень над лавой. Рыцари ломают твои мосты - захватывай их мосты.
best.b.drawbridge.text = Поднимите его — и река снова стена. В автоматическом режиме он поднимается при приближении врагов и опускается для ваших существ.
best.b.wall.text = Выкопанный туннель не навсегда: замуруйте его и оставьте героям один путь — через вашу дверь.
best.b.steel.text = Клёпаные железные листы на укреплённой стене. Быстро никто не прокопает, даже гномы. best.b.steel.text = Клёпаные железные листы на укреплённой стене. Быстро никто не прокопает, даже гномы.
best.b.arrow.text = Стрела пробивает ещё до двух врагов за целью. Дёшево и надёжно. best.b.arrow.text = Стрела пробивает ещё до двух врагов за целью. Дёшево и надёжно.
best.b.cannon.text = Урон по площади и отбрасывание - столкни рыцарей в лаву. best.b.cannon.text = Урон по площади и отбрасывание - столкни рыцарей в лаву.
@@ -801,7 +891,7 @@ room.forge = Кузница
tool.forge.label = Кузница tool.forge.label = Кузница
tool.forge.desc = Здесь существа делают ловушки и двери - за работу, не за золото. Готовые изделия ждут в ящиках, пока бесёнок их не установит. На кузницу от 6 клеток приходят шестерёнки. tool.forge.desc = Здесь существа делают ловушки и двери - за работу, не за золото. Готовые изделия ждут в ящиках, пока бесёнок их не установит. На кузницу от 6 клеток приходят шестерёнки.
unit.cogling = Шестерёнок unit.cogling = Шестерёнок
cat.traps = Ловушки cat.traps = Постройки
cost.work = {0} работы в кузнице cost.work = {0} работы в кузнице
job.forge = Куёт job.forge = Куёт
job.install = Ставит ловушку job.install = Ставит ловушку
@@ -878,6 +968,7 @@ research.now = Исследуется: {0} ({1}%)
research.next = затем: {0} research.next = затем: {0}
research.done = Здесь больше нечего исследовать. research.done = Здесь больше нечего исследовать.
msg.researched = Исследовано: {0}! Теперь это в меню строительства. msg.researched = Исследовано: {0}! Теперь это в меню строительства.
msg.room_upgraded = Исследование: {0} достигает уровня {1} — работает на четверть лучше, и это видно.
msg.tool_research = Ещё не исследовано - над этим работают существа, что учатся в библиотеке. msg.tool_research = Ещё не исследовано - над этим работают существа, что учатся в библиотеке.
msg.script = {0} msg.script = {0}
@@ -915,6 +1006,9 @@ tool.plague.desc = Хворь, которая гложет врагов вокр
tool.quake.label = Толчок tool.quake.label = Толчок
tool.quake.name = Землетрясение tool.quake.name = Землетрясение
tool.quake.desc = Земля дрожит: враги вокруг точки ранены и оглушены на две секунды. Твои существа стоят на ногах. tool.quake.desc = Земля дрожит: враги вокруг точки ранены и оглушены на две секунды. Твои существа стоят на ногах.
tool.freeze.label = Мороз
tool.freeze.name = Мороз
tool.freeze.desc = Ледяной холод: лава вокруг на 40 секунд застывает в проходимый обсидиан, враги замерзают и замедляются.
tile.unexplored = Неизведанная тьма tile.unexplored = Неизведанная тьма
# personality # personality
@@ -967,6 +1061,9 @@ tool.arena.desc = Существа дерутся парами: вдвое бо
tool.altar.label = Алтарь tool.altar.label = Алтарь
tool.altar.desc = Опусти существо на алтарь, чтобы принести его в жертву. Некоторые пары жертв приносят редкое существо, иначе подземелье получает благословение: лучше настроение и мана. tool.altar.desc = Опусти существо на алтарь, чтобы принести его в жертву. Некоторые пары жертв приносят редкое существо, иначе подземелье получает благословение: лучше настроение и мана.
unit.revenant = Восставший unit.revenant = Восставший
unit.cavebear = Пещерный медведь
unit.beetle = Гигантский жук
unit.trader = Торговец
job.guard_post = На страже job.guard_post = На страже
job.spar = Спаррингует job.spar = Спаррингует
msg.revenant_rises = С кладбища встаёт восставший и служит тебе! msg.revenant_rises = С кладбища встаёт восставший и служит тебе!
@@ -975,6 +1072,10 @@ msg.altar_gift = Алтарь принимает пару: появляется
msg.altar_blessing = Принесён в жертву: {0}. Подземелье благословлено. msg.altar_blessing = Принесён в жертву: {0}. Подземелье благословлено.
best.revenant.lure = встаёт с кладбища best.revenant.lure = встаёт с кладбища
best.revenant.text = Восставший не ест, не спит и не жалуется - идеальный работник, если вытерпишь запах. | Набравшись опыта, пьёт силу тех, кого бьёт. Со жрицей они не ладят, по понятным причинам. best.revenant.text = Восставший не ест, не спит и не жалуется - идеальный работник, если вытерпишь запах. | Набравшись опыта, пьёт силу тех, кого бьёт. Со жрицей они не ладят, по понятным причинам.
best.cavebear.lure = победить в логове, имея тюрьму
best.cavebear.text = Медведь, который никогда не видел солнца и не скучает. Лохматый, сильный и ворчливый до завтрака. | Победи его в логове, пока в тюрьме есть место, — он сдастся и присоединится к тебе. Любит грибной сад и пауков — на закуску.
best.beetle.lure = победить в логове, имея тюрьму
best.beetle.text = Жук размером с бочку, бронированный, как рыцарь, и вдвое вежливее. | Победи его в логове, пока в тюрьме есть место, — и он присоединится к тебе. Спит в логове и терпеть не может летучих мышей.
best.b.prison.text = Ряды камер. Герои тихо гниют здесь, пока кто-нибудь не решит, что с ними делать. best.b.prison.text = Ряды камер. Герои тихо гниют здесь, пока кто-нибудь не решит, что с ними делать.
best.b.graveyard.text = Мох, камни и свежая земля. Здесь, внизу, никто не остаётся мёртвым надолго. best.b.graveyard.text = Мох, камни и свежая земля. Здесь, внизу, никто не остаётся мёртвым надолго.
best.b.guardpost.text = Жаровня, знамя и скучающее существо с острым взглядом. best.b.guardpost.text = Жаровня, знамя и скучающее существо с острым взглядом.
@@ -987,23 +1088,39 @@ ability.drain.desc = лечит себя на треть нанесённого
unit.giant = Великан unit.giant = Великан
unit.fairy = Фея-шпион unit.fairy = Фея-шпион
unit.battlemage = Боевой маг unit.battlemage = Боевой маг
unit.thief = Вор
unit.monk = Монах
unit.ranger = Следопыт
unit.inquisitor = Инквизитор
unit.ram = Таран
unit.catapult = Катапульта
msg.trap_wrecked = Герои сломали ловушку: {0}. msg.trap_wrecked = Герои сломали ловушку: {0}.
best.giant.text = Великан - это то, что выходит, когда рыцарь триста лет ест овощи. Медленный, огромный, а двери для него - лишь пожелание. | Ломает ловушки, просто стоя рядом. Отвратительные манеры. Бей всем, что есть, лучше издалека. best.giant.text = Великан - это то, что выходит, когда рыцарь триста лет ест овощи. Медленный, огромный, а двери для него - лишь пожелание. | Ломает ловушки, просто стоя рядом. Отвратительные манеры. Бей всем, что есть, лучше издалека.
best.fairy.text = Маленькая доносчица Ордена: светящаяся мошка с крылышками, которую шлют вперёд найти твои ловушки и всем разболтать. Стоит ей увидеть ловушку, как герои её ломают. | Ещё и посыпает героев целебной пыльцой. Прихлопни её первой. Дважды, для верности. best.fairy.text = Маленькая доносчица Ордена: светящаяся мошка с крылышками, которую шлют вперёд найти твои ловушки и всем разболтать. Стоит ей увидеть ловушку, как герои её ломают. | Ещё и посыпает героев целебной пыльцой. Прихлопни её первой. Дважды, для верности.
best.battlemage.text = Маг в мантии Ордена, для которого стены - личное оскорбление. Укреплённые стены лопаются от его огненных шаров, и он без конца об этом болтает. | Вблизи хрупок. Больше всего его бесят стальные стены - и хорошо. best.battlemage.text = Маг в мантии Ордена, для которого стены - личное оскорбление. Укреплённые стены лопаются от его огненных шаров, и он без конца об этом болтает. | Вблизи хрупок. Больше всего его бесят стальные стены - и хорошо.
best.thief.text = Быстрые пальцы, ещё более быстрые ноги. Он пришёл не драться: прокрадывается в твою сокровищницу, набивает мешок и бежит обратно к вратам. | Убей его, пока он не ушёл, и золото снова твоё - с процентами.
best.monk.text = Годы постов и молитв сделали его глухим к твоей магии: молния, мор и мороз стекают с него, как дождь с крыши. | Кулаки тоже бьют больно. Встречай его сталью, когтями и зубами.
best.ranger.text = Лесничий Ордена, который стреляет издалека и расставляет железные капканы в твоих коридорах. Существо, наступившее в капкан, ранено и надолго застревает. | Капканы лежат, пока в них кто-нибудь не наступит. Лучше кобольд, чем твой огр.
best.inquisitor.text = Дряхлый, но его проповеди опасны: каждые двадцать секунд он уговаривает одно из твоих самых слабых существ - кобольда или паука - перейти к героям. | Сильные существа над ним только смеются. Бей первым.
best.ram.text = Бревно с железным наконечником и крышей от стрел. Ваши существа ему безразличны — только двери, стены и башни. | Остановите его до двери или заведите новую дверь.
best.catapult.text = Деревянный рычаг, ведро камней и рыцарь с избытком свободного времени. Бьёт дальше ваших башен и целится в них первыми. | Атакуйте вплотную: вблизи она и мухи не обидит.
# orders # orders
tool.order_attack.label = Атака tool.order_attack.label = Атака
tool.order_attack.name = В атаку! tool.order_attack.name = В атаку!
tool.order_attack.desc = Кликни врага или вражескую башню: все боевые существа в радиусе 18 клеток идут на неё. Правый клик инструментом приказа отменяет приказы охраны. tool.order_attack.desc = Щёлкни по врагу или вражеской башне: все готовые к бою существа в радиусе 18 клеток атакуют. Щёлкни по свободной земле: метка-меч — готовые существа делятся между всеми мечами и сражаются там. Правый щелчок по метке убирает её.
tool.order_guard.label = Охрана tool.order_guard.label = Охрана
tool.order_guard.name = Охранять комнату tool.order_guard.name = Охранять комнату
tool.order_guard.desc = Кликни комнату: до трёх существ поблизости занимают там позиции на две минуты и атакуют всё, что увидят. Правый клик - отмена. tool.order_guard.desc = Щёлкни по своей земле: метка-щит, которую держат и защищают три существа. До восьми щитов; правый щелчок по щиту убирает его.
hint.order_attack = Кликни врага или вражескую башню. hint.order_attack = Кликни врага или вражескую башню.
hint.order_guard = Кликни одну из своих комнат (или свой пол). hint.order_guard = Кликни одну из своих комнат (или свой пол).
hint.order_nobody = Нет свободных существ для этого приказа. hint.order_nobody = Нет свободных существ для этого приказа.
msg.order_attack = Существ в атаке: {0}! msg.order_attack = Существ в атаке: {0}!
msg.order_guard = Существ на позициях: {0}. msg.order_guard = Существ на позициях: {0}.
msg.mark_attack = Меч отмечает место: твои готовые существа идут туда.
msg.mark_guard = Щит отмечает место: три существа будут его держать.
msg.mark_removed = Метка убрана — её существа снова свободны.
hint.marks_full = Восемь меток этого вида — максимум. Убери одну правым щелчком.
msg.orders_cancelled = Приказы охраны отменены. msg.orders_cancelled = Приказы охраны отменены.
# creature overview # creature overview
@@ -1013,6 +1130,8 @@ ov.summary = Существ: {0} - жалованье {1} золота за вы
ov.none = Существ пока нет. Построй логово и грибной сад и захвати портал. ov.none = Существ пока нет. Построй логово и грибной сад и захвати портал.
ov.hint = Ур., настроение, жалованье, дело, здоровье - клик: перейти ov.hint = Ур., настроение, жалованье, дело, здоровье - клик: перейти
set.voice = Голос Кривопала set.voice = Голос Кривопала
set.track = Мелодия
val.track_auto = Автоматически
# ---- campaign editor, block scripts, demo campaign # ---- campaign editor, block scripts, demo campaign
menu.back_editor = Назад в редактор menu.back_editor = Назад в редактор
@@ -1085,6 +1204,7 @@ ed.forts = Крепости
ed.rivals = Соперники ed.rivals = Соперники
ed.difficulty = Сложность ed.difficulty = Сложность
ed.portals = Порталы ed.portals = Порталы
ed.surface = Поверхность (восток)
ed.edit_map = Изменить карту ed.edit_map = Изменить карту
ed.make_random = Сделать случайной ed.make_random = Сделать случайной
ed.paint_map = Создать и нарисовать карту ed.paint_map = Создать и нарисовать карту
@@ -1164,6 +1284,10 @@ ed.terrain.silver = Серебряная жила
ed.terrain.ruby = Рубиновая жила ed.terrain.ruby = Рубиновая жила
ed.terrain.diamond = Алмазная жила ed.terrain.diamond = Алмазная жила
ed.terrain.steel = Стальная стена ed.terrain.steel = Стальная стена
ed.terrain.mushroom = Гигантский гриб
ed.terrain.grove = Грибная роща
ed.terrain.grass = Трава (поверхность)
ed.terrain.tree = Дерево
ed.place.core = Ядро души ed.place.core = Ядро души
ed.place.portal = Портал ed.place.portal = Портал
ed.place.gate = Врата героев ed.place.gate = Врата героев
@@ -1194,6 +1318,7 @@ ed.level = Уровень
ed.zone_shape = Форма новой зоны ed.zone_shape = Форма новой зоны
ed.zone_new_name = Имя новой зоны ed.zone_new_name = Имя новой зоны
ed.zones = Зоны ed.zones = Зоны
ed.zone_erase = Убрать клетки
ed.shape.rect = Прямоугольник ed.shape.rect = Прямоугольник
ed.shape.circle = Круг ed.shape.circle = Круг
ed.shape.tiles = Клетки ed.shape.tiles = Клетки
@@ -1243,6 +1368,8 @@ blk.reveal = Открыть
blk.camera = Показать blk.camera = Показать
blk.gold = Золото + blk.gold = Золото +
blk.mana = Мана + blk.mana = Мана +
blk.weather = Погода на поверхности
blk.talentxp = Опыт талантов +
blk.if = Если blk.if = Если
blk.repeat = Повторить blk.repeat = Повторить
blk.trigger = Переключить триггер blk.trigger = Переключить триггер
@@ -1262,6 +1389,8 @@ blk.help.reveal = Снимает туман войны с зоны.
blk.help.camera = Переносит камеру к зоне. blk.help.camera = Переносит камеру к зоне.
blk.help.gold = Даёт (или забирает) золото. blk.help.gold = Даёт (или забирает) золото.
blk.help.mana = Даёт (или забирает) ману. blk.help.mana = Даёт (или забирает) ману.
blk.help.weather = Задаёт погоду на поверхности (ясно, дождь, туман или гроза) на столько секунд; 0 оставляет её, пока другой блок её не изменит.
blk.help.talentxp = Даёт игроку опыт для талантов мастера.
blk.help.if = Выполняет вложенные блоки, только если условие верно, иначе - блоки под «иначе». blk.help.if = Выполняет вложенные блоки, только если условие верно, иначе - блоки под «иначе».
blk.help.repeat = Выполняет вложенные блоки несколько раз. blk.help.repeat = Выполняет вложенные блоки несколько раз.
blk.help.trigger = Включает триггер (он может сработать снова) или выключает его. blk.help.trigger = Включает триггер (он может сработать снова) или выключает его.
@@ -1284,6 +1413,7 @@ blk.v.or = или
blk.v.not = не blk.v.not = не
blk.v.bool = Да/нет blk.v.bool = Да/нет
blk.v.eventkind = Боец триггера: blk.v.eventkind = Боец триггера:
blk.v.weatheris = Погода на поверхности:
blk.w_at = где: blk.w_at = где:
blk.w_by = на blk.w_by = на
blk.w_in = в blk.w_in = в
@@ -1328,14 +1458,19 @@ demo.m2.title = Новый бросок
demo.m2.story = Эта карта создаётся заново при каждом прохождении кампании. Победи всех героев. demo.m2.story = Эта карта создаётся заново при каждом прохождении кампании. Победи всех героев.
demo.m2.start = Случайная карта. Даже я её раньше не видел. demo.m2.start = Случайная карта. Даже я её раньше не видел.
demo.m2.wave = Две минуты прошли: идут герои. demo.m2.wave = Две минуты прошли: идут герои.
net.coop_editor = Editor campaign net.coop_editor = Кампания из редактора
net.coop_builtin = Built-in campaign net.coop_builtin = Встроенная кампания
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_league = Союз против Светлого Дозора
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) net.coop_solo = ИИ-союзник
net.coop_solo_started = Кооператив без сервера: ИИ-союзник строит своё подземелье, идёт к вашему флагу атаки и помогает золотом.
net.coop_resume = Дальше: M{0}
net.coop_editor_mission = Кампания из редактора {0}: миссия {1} ({2})
net.coop_editor_none = Ни одна миссия кампании из редактора не подходит для кооператива (карта не меньше 72x72, цель помимо мастеров-соперников)
blk.until = Повторять, пока не blk.until = Повторять, пока не
blk.dountil = Повторять, затем проверить: пока не blk.dountil = Повторять, затем проверить: пока не
blk.wait = Ждать blk.wait = Ждать
blk.w_seconds = сек. blk.w_seconds = сек.
blk.w_for = на
blk.help.until = Снова и снова выполняет блоки внутри, пока условие не станет верным; проверка перед каждым проходом, поэтому если оно уже верно, блоки не выполняются. blk.help.until = Снова и снова выполняет блоки внутри, пока условие не станет верным; проверка перед каждым проходом, поэтому если оно уже верно, блоки не выполняются.
blk.help.dountil = Выполняет блоки внутри, затем проверяет условие и повторяет, пока оно не станет верным; блоки выполняются хотя бы один раз. blk.help.dountil = Выполняет блоки внутри, затем проверяет условие и повторяет, пока оно не станет верным; блоки выполняются хотя бы один раз.
blk.help.wait = Приостанавливает этот скрипт на несколько секунд, затем выполняется следующий блок (и внутри циклов). blk.help.wait = Приостанавливает этот скрипт на несколько секунд, затем выполняется следующий блок (и внутри циклов).
@@ -1356,130 +1491,280 @@ net.coop_wait_host = Ждём, пока хозяин запустит следу
net.coop_run_over = Назад в лобби net.coop_run_over = Назад в лобби
# ---- co-op campaign "League against the Lightwatch" # ---- co-op campaign "League against the Lightwatch"
coop.name = League against the Lightwatch coop.name = Лига против Светлого Дозора
coop.raid = Mossgrim's creatures crawl out of the dark! coop.raid = Существа Моссгрима выползают из тьмы!
coop.m01.title = Two Hearts, One Crookfinger coop.m01.title = Два сердца, один Кривопал
coop.m01.story = Masters, plural. I could hardly believe it either. The Order of the Lightwatch has made a pact with Mossgrim, that mossy turncoat, to smoke out every dungeon under the hills.\n\nAlone, you would be smoked. Together you might just be the smoke. Dig in side by side and show the patrol nearby what a shared basement means. coop.m01.story = Повелители, во множественном числе. Я и сам не поверил. Орден Светлого Дозора заключил союз с Моссгримом, этим заплесневелым предателем, чтобы выкурить все подземелья под холмами.\n\nПоодиночке вас бы выкурили. Вместе вы, пожалуй, сами станете дымом. Копайте бок о бок и покажите патрулю по соседству, что такое общий подвал.
coop.m01.start = Two masters, one advisor. My wages, by the way, stay the same. coop.m01.start = Два повелителя, один советник. Жалованье у меня, кстати, прежнее.
coop.m01.goal = Defeat every knight. coop.m01.goal = Победите всех рыцарей.
coop.m01.t1 = Share the work: one digs, one builds. Or both dig. I am flexible. coop.m01.t1 = Разделите работу: один копает, другой строит. Или копайте оба. Я человек гибкий. Ну, бес.
coop.m01.t2 = The patrol is getting curious. Good. Curiosity is a lovely snack. coop.m01.t2 = Патруль становится любопытным. Хорошо. Любопытство - отличная закуска.
coop.m02.title = The First Outpost coop.m02.title = Первый форпост
coop.m02.story = Across the river the Lightwatch has built an outpost with shiny towers. Shiny things must be broken - that is the oldest rule of the underworld.\n\nBridge the water, bring the towers down, and do it together before they call their friends. coop.m02.story = За рекой Светлый Дозор построил форпост с блестящими башнями. Блестящее положено ломать - это древнейшее правило подземного мира.\n\nПерекиньте мост через воду, снесите башни и сделайте это вместе, пока они не позвали друзей.
coop.m02.start = Towers. Polished, pointy, insulting. Let us fix that. coop.m02.start = Башни. Начищенные, острые, оскорбительные. Исправим это.
coop.m02.goal = Raze the knight towers. coop.m02.goal = Снесите рыцарские башни.
coop.m02.t1 = Bridges are like friendships, masters: build them, then march an army over them. coop.m02.t1 = Мосты как дружба, повелители: сначала их строят, потом по ним маршируют армией.
coop.m02.t2 = One tower down is a start. All towers down is a party. coop.m02.t2 = Одна снесённая башня - это начало. Все снесённые башни - это праздник.
coop.m03.title = Tracks in the Moss coop.m03.title = Следы во мху
coop.m03.story = Something green and wet is lurking in the tunnels. Mossgrim has lent the knights his creatures, and they come through the hero gate in waves.\n\nHold out. Hold together. Hold your noses. coop.m03.story = В туннелях затаилось что-то зелёное и мокрое. Моссгрим одолжил рыцарям своих существ, и они идут волнами через Врата героев.\n\nДержитесь. Держитесь вместе. Зажмите носы.
coop.m03.start = Mossgrim's ogres smell of wet carpet. You will know when they come. coop.m03.start = Огры Моссгрима пахнут мокрым ковром. Вы сразу поймёте, когда они придут.
coop.m03.goal = Survive three waves. coop.m03.goal = Переживите три волны.
coop.m03.t1 = The first ogres are here. Mossgrim never could keep his pets at home. coop.m03.t1 = Первые огры уже здесь. Моссгрим никогда не умел держать своих питомцев дома.
coop.m03.t2 = Keep your hearts apart, masters - one ogre should never reach both of them. coop.m03.t2 = Держите сердца подальше друг от друга, повелители: один огр не должен добраться до обоих.
coop.m04.title = The Stolen Springs coop.m04.title = Украденные источники
coop.m04.story = Two mana springs bubble on islands in the lake, and the knights have claimed them as their holy water. Holy water, honestly.\n\nTake both springs. And if the knights' vault happens to fall into your hands first, Crookfinger knows a shortcut. coop.m04.story = На островах в озере бурлят два источника маны, и рыцари объявили их своей святой водой. Святой водой, подумать только.\n\nЗахватите оба источника. А если рыцарское хранилище раньше попадёт вам в руки, Кривопал знает короткий путь.
coop.m04.start = Mana tastes better when it was someone else's. coop.m04.start = Мана вкуснее, когда она была чужой.
coop.m04.goal = Claim two mana springs. coop.m04.goal = Захватите два источника маны.
coop.m04.branch = Rich enough for the shortcut coop.m04.branch = Достаточно богаты для короткого пути
coop.m04.t1 = Islands need bridges. Or wings. Bats are wings with teeth. coop.m04.t1 = Островам нужны мосты. Или крылья. Нетопыри - это крылья с зубами.
coop.m04.t2 = That vault looks heavy. Heavy things should be carried home. coop.m04.t2 = Хранилище выглядит тяжёлым. Тяжёлое надо уносить домой.
coop.m05.title = Chains in the Rock coop.m05.title = Цепи в скале
coop.m05.story = The Lightwatch keeps creatures in chains in sealed caves - creatures that refused to bow. An ogre and a mage, both furious, both available.\n\nDig to them, claim their caves, and the chains will fall. coop.m05.story = Светлый Дозор держит в цепях, в запечатанных пещерах, существ, которые отказались склониться. Огр и маг, оба в ярости, оба свободны для найма.\n\nПрокопайте к ним, захватите их пещеры - и цепи падут.
coop.m05.start = Prisoners! Ours, soon. Free ones, I mean. Free to work for you. coop.m05.start = Пленники! Скоро наши. То есть свободные. Свободные работать на вас.
coop.m05.goal = Free two chained creatures. coop.m05.goal = Освободите двух скованных существ.
coop.m05.t1 = Listen. Do you hear the ogre swearing? That is gratitude in advance. coop.m05.t1 = Слышите? Огр ругается. Это благодарность авансом.
coop.m05.t2 = Spiders from the moss again. Mossgrim's nursery is busy. coop.m05.t2 = Снова пауки из мха. Питомник Моссгрима не простаивает.
coop.m06.title = Bridge of the Watch coop.m06.title = Мост Дозора
coop.m06.story = Two rivers, one fortress, and the Lightwatch's finest archers on the walls. They call it the Bridge of the Watch. We will call it the bridge that was.\n\nBreak their towers. Leave the bridge - we might need it. coop.m06.story = Две реки, одна крепость и лучшие лучники Светлого Дозора на стенах. Они зовут это Мостом Дозора. Мы назовём его мостом, который был.\n\nСломайте их башни. Мост оставьте - он нам ещё пригодится.
coop.m06.start = Archers on walls are like pigeons on roofs: loud and easy to remove. coop.m06.start = Лучники на стенах как голуби на крышах: шумные и легко убираются.
coop.m06.goal = Raze the knight towers. coop.m06.goal = Снесите рыцарские башни.
coop.m06.t1 = Two rivers mean two bridges. Or one very wet army. coop.m06.t1 = Две реки - это два моста. Или одна очень мокрая армия.
coop.m06.t2 = The knights are polishing their armour. Let us give them a reason. coop.m06.t2 = Рыцари начищают доспехи. Дадим им повод.
coop.m07.title = The Lava Smithy coop.m07.title = Лавовая кузня
coop.m07.story = Between two rivers of lava the knights forge their blessed blades. Their vault overflows with gold paid for by every dungeon they emptied.\n\nWe are taking it back. All of it. And then some. coop.m07.story = Между двумя реками лавы рыцари куют свои благословенные клинки. Их хранилище ломится от золота, оплаченного каждым подземельем, которое они опустошили.\n\nМы забираем его назад. Всё. И ещё немного сверху.
coop.m07.start = Warm feet, cold hearts, full pockets: my kind of evening. coop.m07.start = Тёплые ноги, холодные сердца, полные карманы: мой любимый вечер.
coop.m07.goal = Loot gold from the knights. coop.m07.goal = Награбьте золото рыцарей.
coop.m07.t1 = Do not stand too close to the lava. Your creatures, I mean. You are fine. coop.m07.t1 = Не подходите слишком близко к лаве. Ваши существа, я имею в виду. Вам-то ничего.
coop.m07.t2 = More knights from the east. They really love their gold. coop.m07.t2 = Ещё рыцари с востока. Как же они любят своё золото.
coop.m08.title = The Field Hospital coop.m08.title = Полевой лазарет
coop.m08.story = The Lightwatch has set up a field hospital in the caves, and its priests patch up every knight you knock down.\n\nEnd the treatment. Permanently. coop.m08.story = Светлый Дозор устроил в пещерах полевой лазарет, и его жрецы латают каждого рыцаря, которого вы сбиваете с ног.\n\nПрекратите лечение. Навсегда.
coop.m08.start = Priests. They heal. We do the opposite. It is a balanced relationship. coop.m08.start = Жрецы. Они лечат. Мы делаем обратное. Отношения вполне уравновешенные.
coop.m08.goal = Defeat every knight. coop.m08.goal = Победите всех рыцарей.
coop.m08.t1 = Every minute a new priest arrives. Kill faster, masters. coop.m08.t1 = Каждую минуту прибывает новый жрец. Убивайте быстрее, повелители.
coop.m08.t2 = A dungeon without hospital bills. That is what I call efficient. coop.m08.t2 = Подземелье без больничных счетов. Вот это я называю эффективностью.
coop.m09.title = Mossgrim's Ambassador coop.m09.title = Посол Моссгрима
coop.m09.story = Mossgrim has sent an ambassador to the knights: a paladin in green-stained armour who carries his master's promises.\n\nPromises should never arrive. Find the ambassador and silence him. coop.m09.story = Моссгрим отправил к рыцарям посла: паладина в доспехах с зелёными пятнами, который несёт обещания своего хозяина.\n\nОбещания не должны доходить. Найдите посла и заставьте его замолчать.
coop.m09.start = An ambassador. A messenger with a fancy hat. Hats come off. coop.m09.start = Посол. Гонец в нарядной шляпе. Шляпы слетают.
coop.m09.goal = Defeat Mossgrim's ambassador. coop.m09.goal = Победите посла Моссгрима.
coop.m09.boss = The ambassador has fallen. His message: undelivered. coop.m09.boss = Посол пал. Его послание: не доставлено.
coop.m09.t1 = The ambassador waits in the east, surrounded by ogres who lost their sense of humour. coop.m09.t1 = Посол ждёт на востоке в окружении огров, потерявших чувство юмора.
coop.m09.t2 = Somewhere Mossgrim is sending another letter. Let us make it a short one. coop.m09.t2 = Где-то Моссгрим отправляет очередное письмо. Сделаем его коротким.
coop.m10.title = The Twin Towers coop.m10.title = Башни-близнецы
coop.m10.story = Two fortresses guard the way north, each watching the other's back. Strike one and the other answers.\n\nStrike both. At once. That is what two masters are for. coop.m10.story = Две крепости стерегут путь на север, и каждая прикрывает спину другой. Ударите по одной - ответит другая.\n\nУдарьте по обеим. Одновременно. Для этого и нужны два повелителя.
coop.m10.start = Two fortresses, two masters. Fair is fair. coop.m10.start = Две крепости, два повелителя. Всё по-честному.
coop.m10.goal = Raze the knight towers. coop.m10.goal = Снесите рыцарские башни.
coop.m10.t1 = Split up, masters - one fortress each. I will cheer for both. coop.m10.t1 = Разделитесь, повелители: каждому по крепости. Я буду болеть за обоих.
coop.m10.t2 = When both fall at once, the knights will not even know whom to blame. coop.m10.t2 = Если обе падут разом, рыцари даже не поймут, кого винить.
coop.m11.title = The Siege coop.m11.title = Осада
coop.m11.story = The Lightwatch has found your hearts and brings everything: five waves of knights and Mossgrim's gargoyles from the cliffs.\n\nNo cleverness this time. Just walls, traps, towers - and each other. coop.m11.story = Светлый Дозор нашёл ваши сердца и бросает всё: пять волн рыцарей и горгулий Моссгрима со скал.\n\nНикаких хитростей на этот раз. Только стены, ловушки, башни - и вы друг у друга.
coop.m11.start = They are coming. All of them. Well, most of them. coop.m11.start = Они идут. Все. Ну, почти все.
coop.m11.goal = Survive five waves. coop.m11.goal = Переживите пять волн.
coop.m11.t1 = Doors and traps, masters. A siege is just guests who refuse to use the bell. coop.m11.t1 = Двери и ловушки, повелители. Осада - это просто гости, которые отказываются звонить в колокольчик.
coop.m11.t2 = Gargoyles from the south. Stone with wings - nobody asked for that. coop.m11.t2 = Горгульи с юга. Камень с крыльями - никто о таком не просил.
coop.m12.title = The Gate coop.m12.title = Врата
coop.m12.story = The hero gate in this cavern is how the knights reach the deep dungeons. Close it and their reinforcements must walk the long way round.\n\nBreak the gate, and the Lightwatch will feel it all the way up to the High Marshal. coop.m12.story = Через Врата героев в этой пещере рыцари попадают в глубокие подземелья. Закройте их, и подкреплению придётся идти в обход.\n\nСломайте врата, и Светлый Дозор почувствует это до самого Верховного маршала.
coop.m12.start = A gate. Gates are just walls with ambitions. coop.m12.start = Врата. Врата - это просто стены с амбициями.
coop.m12.goal = Destroy the hero gate. coop.m12.goal = Разрушьте Врата героев.
coop.m12.t1 = Waves keep coming out of it. Annoying, like a dripping tap. coop.m12.t1 = Из них всё лезут и лезут волны. Раздражает, как капающий кран.
coop.m12.t2 = Push, masters! The gate is cracking. So is my patience. coop.m12.t2 = Давите, повелители! Врата трещат. Как и моё терпение.
coop.m13.title = Mossy Tunnels coop.m13.title = Замшелые туннели
coop.m13.story = We are in Mossgrim's backyard now. His tunnels are soft and damp, and two of his hoards lie in sealed caves.\n\nDig, take, leave. Mossgrim will notice. That is the point. coop.m13.story = Теперь мы на заднем дворе Моссгрима. Его туннели мягкие и сырые, а два его клада лежат в запечатанных пещерах.\n\nКопайте, берите, уходите. Моссгрим заметит. В этом и смысл.
coop.m13.start = It smells of mushrooms and treachery. Mossgrim must be near. coop.m13.start = Пахнет грибами и предательством. Моссгрим где-то рядом.
coop.m13.goal = Loot gold. coop.m13.goal = Награбьте золото.
coop.m13.t1 = Spiders, again. Mossgrim breeds them like other people grow tomatoes. coop.m13.t1 = Опять пауки. Моссгрим разводит их, как другие выращивают помидоры.
coop.m13.t2 = His hoards are bigger than I thought. Our pockets are bigger still. coop.m13.t2 = Его клады больше, чем я думал. А наши карманы ещё больше.
coop.m14.title = Poison in the Water coop.m14.title = Яд в воде
coop.m14.story = Mossgrim has turned his dead into revenants and sends them from two sides. The knights call it holy work. Typical.\n\nDefeat every knight and every walking corpse. Then wash your hands. coop.m14.story = Моссгрим превратил своих мертвецов в восставших и шлёт их с двух сторон. Рыцари называют это святым делом. Как обычно.\n\nПобедите всех рыцарей и всех ходячих мертвецов. Потом вымойте руки.
coop.m14.start = Revenants. Our trick, stolen. I am offended. coop.m14.start = Восставшие. Наш трюк, украденный. Я оскорблён.
coop.m14.goal = Defeat every enemy. coop.m14.goal = Победите всех врагов.
coop.m14.t1 = They come from the north and the south. Back to back, masters. coop.m14.t1 = Они идут с севера и с юга. Спина к спине, повелители.
coop.m14.t2 = A dead thing that walks is a dead thing that has not finished its job yet. coop.m14.t2 = Ходячий мертвец - это мертвец, который ещё не закончил свою работу.
coop.m15.title = The Chainbreaker coop.m15.title = Цеполом
coop.m15.story = Deep in the rock the knights keep the Chainbreaker, a creature even they fear. Free it, and it will fight for whoever opened its cell.\n\nOr grab enough gold for the long way round - Crookfinger knows a smuggler. coop.m15.story = Глубоко в скале рыцари держат Цеполома - существо, которого боятся даже они. Освободите его, и он будет сражаться за того, кто открыл его камеру.\n\nИли наберите достаточно золота для обходного пути - Кривопал знает одного контрабандиста.
coop.m15.start = The Chainbreaker. Big, angry, misunderstood. A bit like me. coop.m15.start = Цеполом. Большой, злой, непонятый. Немного похож на меня.
coop.m15.goal = Free the Chainbreaker and raze the towers. coop.m15.goal = Освободите Цеполома и снесите башни.
coop.m15.branch = Bought the smuggler's map coop.m15.branch = Карта контрабандиста куплена
coop.m15.t1 = The cell is in the north-west. Mind its temper. coop.m15.t1 = Камера на северо-западе. Осторожнее с его нравом.
coop.m15.t2 = The smuggler is waiting. Smugglers hate waiting. And love gold. coop.m15.t2 = Контрабандист ждёт. Контрабандисты ненавидят ждать. И обожают золото.
coop.m16.title = The Burning Spring coop.m16.title = Пылающий источник
coop.m16.story = In the middle of a lava lake lies an island with a mana spring that never runs dry. Mossgrim's mages guard it.\n\nTake the spring, defeat the guards. Then sit on the island and enjoy the warmth. coop.m16.story = Посреди лавового озера лежит остров с источником маны, который никогда не иссякает. Его стерегут маги Моссгрима.\n\nЗахватите источник, победите стражу. А потом сядьте на острове и наслаждайтесь теплом.
coop.m16.start = A spring in lava. Whoever thought of that has style. Shame it is Mossgrim. coop.m16.start = Источник в лаве. У того, кто это придумал, есть стиль. Жаль, что это Моссгрим.
coop.m16.goal = Claim the spring and defeat every enemy. coop.m16.goal = Захватите источник и победите всех врагов.
coop.m16.t1 = Mages from the east. Their spells are green and so are their teeth. coop.m16.t1 = Маги с востока. Заклинания у них зелёные, и зубы тоже.
coop.m16.t2 = Once the spring is ours, mana for everyone. Share nicely. coop.m16.t2 = Как только источник станет нашим - мана для всех. Делитесь по-хорошему.
coop.m17.title = Mossgrim's Garden coop.m17.title = Сад Моссгрима
coop.m17.story = This is it: Mossgrim's garden, a cavern of mushrooms and creatures loyal to him. Ogres in the east, an ember wyrm in the north.\n\nTrample the garden. Every mushroom. coop.m17.story = Вот оно: сад Моссгрима, пещера грибов и преданных ему существ. Огры на востоке, огненный змий на севере.\n\nВытопчите сад. Каждый гриб.
coop.m17.start = Look at all those mushrooms. Pity about the owner. coop.m17.start = Сколько грибов. Жаль только хозяина.
coop.m17.goal = Defeat every enemy. coop.m17.goal = Победите всех врагов.
coop.m17.t1 = An ember wyrm! Mossgrim must have saved up for years. coop.m17.t1 = Огненный змий! Моссгрим, видно, копил на него годами.
coop.m17.t2 = The garden is wilting. Gardening is not your strength, masters. That is a compliment. coop.m17.t2 = Сад вянет. Садоводство - не ваша сильная сторона, повелители. Это комплимент.
coop.m18.title = The Last Fortress coop.m18.title = Последняя крепость
coop.m18.story = Three fortresses guard the stairs down to the High Marshal's hall. They are the last thing between you and the end of the Lightwatch's crusade.\n\nRaze them all. Then the stairs are yours. coop.m18.story = Три крепости стерегут лестницу вниз, в зал Верховного маршала. Это последнее, что стоит между вами и концом крестового похода Светлого Дозора.\n\nСнесите их все. Тогда лестница ваша.
coop.m18.start = Three fortresses. They really did save the best for last. coop.m18.start = Три крепости. Самое лучшее они и правда оставили напоследок.
coop.m18.goal = Raze the knight towers. coop.m18.goal = Снесите рыцарские башни.
coop.m18.t1 = Each master a fortress, and the third one together. I will count. coop.m18.t1 = Каждому повелителю по крепости, а третью - вместе. Я буду считать.
coop.m18.t2 = I can see the stairs already. Dusty. Nobody comes down here voluntarily. coop.m18.t2 = Я уже вижу лестницу. Пыльная. Сюда никто не спускается по своей воле.
coop.m19.title = The High Marshal coop.m19.title = Верховный маршал
coop.m19.story = The High Marshal of the Lightwatch himself, in armour polished for a hundred years. His guards come from north and south.\n\nBring him down, and the Order will have nobody left to give the orders. coop.m19.story = Сам Верховный маршал Светлого Дозора в доспехах, которые начищали сто лет. Его стража идёт с севера и с юга.\n\nСвалите его, и в Ордене не останется никого, кто отдаёт приказы.
coop.m19.start = The High Marshal. I have dreamed of this. Mostly the part where he falls over. coop.m19.start = Верховный маршал. Я мечтал об этом. Особенно о том моменте, когда он падает.
coop.m19.goal = Defeat the High Marshal. coop.m19.goal = Победите Верховного маршала.
coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped cutlery drawer. coop.m19.boss = Верховный маршал пал! Его доспехи гремят, как опрокинутый ящик со столовыми приборами.
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first. coop.m19.t1 = Он ждёт на востоке. Пусть ждёт. Сначала разберитесь со стражей.
coop.m19.t2 = Knights and archers again. The Order is running out of ideas. coop.m19.t2 = Снова рыцари и лучницы. У Ордена кончаются идеи.
coop.m20.title = Mossgrim's Fall coop.m20.title = Падение Моссгрима
coop.m20.story = With the High Marshal gone, Mossgrim stands alone. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nEnd the traitor. End his army. And then, masters - tea. coop.m20.story = Без верховного маршала Моссгрим остался один — наверху, в лесу, на солнце, подумать только. Он принял облик огненного змея и собрал всех существ, что ещё его слушают.\n\nВыберитесь из скалы и покончите с предателем. Покончите с его армией. А потом, мастера, — чай.
coop.m20.start = Mossgrim, you old fungus. The league sends its regards. coop.m20.start = Моссгрим, старый гриб. Лига шлёт тебе привет.
coop.m20.goal = Defeat Mossgrim and all his creatures. coop.m20.goal = Победите Моссгрима и всех его существ.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not. coop.m20.boss = Моссгрима больше нет. Мох отрастёт. Он - нет.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go. coop.m20.t1 = Этот змий на востоке - Моссгрим. Да, он себя запустил.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong. coop.m20.t2 = Каждое побеждённое существо - на одного меньше, кто перескажет эту историю неправильно.
# achievements and statistics
menu.achievements = Достижения
ach.count = Открыто достижений: {0} из {1} · Сыграно минут: {2}
ach.hidden = Секрет. Кривопал не расскажет.
msg.achievement = Достижение открыто: {0}!
end.stats2 = Герои {0} · Потеряно существ {1} · Выкопано клеток {2} · Золото {3} · Дерево {4}
ach.heroes.name = Гроза героев {0}
ach.heroes.desc = Победи героев: {0}.
ach.bosses.name = Сокрушитель чемпионов {0}
ach.bosses.desc = Повергни чемпионов ордена: {0}.
ach.dug.name = Глубокий копатель {0}
ach.dug.desc = Выкопай клеток: {0}.
ach.gold.name = Скопидом {0}
ach.gold.desc = Заработай {0} золота.
ach.wood.name = Лесной барон {0}
ach.wood.desc = Сруби {0} дерева.
ach.walls.name = Каменщик {0}
ach.walls.desc = Возведи стен: {0}.
ach.rooms.name = Зодчий теней {0}
ach.rooms.desc = Построй клеток комнат: {0}.
ach.spells.name = Тёмный чародей {0}
ach.spells.desc = Сотвори чар: {0}.
ach.slaps.name = Твёрдая рука {0}
ach.slaps.desc = Отшлёпай своих существ {0} раз. Им нравится. Наверное.
ach.traps.name = Мастер ловушек {0}
ach.traps.desc = Закажи в кузнице ловушек и дверей: {0}.
ach.towers.name = Владыка башен {0}
ach.towers.desc = Построй башен: {0}.
ach.drowned.name = Уроки плавания {0}
ach.drowned.desc = Пусть героев упадёт в воду или лаву: {0}.
ach.burnt.name = Поджигатель мостов {0}
ach.burnt.desc = Сожги досок мостов: {0}.
ach.siege.name = Мастер осады {0}
ach.siege.desc = Построй осадных машин: {0}.
ach.captured.name = Тюремщик {0}
ach.captured.desc = Возьми героев в плен: {0}.
ach.drawbridge.name = Поднять мост! {0}
ach.drawbridge.desc = Подними подъёмные мосты {0} раз.
ach.wins.name = Завоеватель {0}
ach.wins.desc = Выиграй свободных игр: {0}.
ach.missions.name = Искатель осколков {0}
ach.missions.desc = Выиграй миссий кампании: {0}.
ach.coop.name = Член лиги {0}
ach.coop.desc = Выиграй кооп-миссий: {0}.
ach.minutes.name = Полуночник {0}
ach.minutes.desc = Сыграй {0} минут.
ach.flawless.name = Ни царапины
ach.flawless.desc = Победи, не потеряв ни одного существа.
ach.speedrun.name = Пока чай не остыл
ach.speedrun.desc = Победи за пятнадцать минут.
ach.campaign.name = Единый Властелин
ach.campaign.desc = Выиграй последнюю миссию кампании.
ach.surface.name = Солнечный ожог
ach.surface.desc = Выиграй игру, которая доходит до поверхности.
ach.defeat.name = Чай и сочувствие
ach.defeat.desc = Проиграй игру. Кривопал ставит чайник.
ach.legend.name = Живая легенда
ach.legend.desc = Существо достигает десятого уровня.
ach.wyrm.name = Здесь водятся драконы
ach.wyrm.desc = Огненный змий присоединяется к твоему подземелью.
ach.fullhand.name = Полные руки
ach.fullhand.desc = Держи в руке восемь существ одновременно.
# tutorial
menu.tutorial = Обучение
tut.name = Первые шаги
tut.title = Кривопал вводит тебя в курс дела
tut.story = Так ты, значит, новый хозяин. Маловат, но ядро ещё бьётся, а это главное.\n\nЯ покажу тебе основы: копать, комнаты, существа и что делать с рыцарями. Делай, как я говорю, и никто не пострадает. Кроме рыцарей.
tut.start = Кривопал: Урок первый — копать. Возьми кирку (клавиша 1), протяни по земле к востоку от сердца, и твои бесята её выкопают.
tut.obj1 = Выкопай проход к востоку от сердца.
tut.dug = Кривопал: Гляди-ка, за ним зал! Твои бесята сами захватывают пол. Теперь открой «Комнаты» и построй логово на своём полу — там спят твои существа.
tut.obj2 = Построй логово (Комнаты).
tut.lair = Кривопал: Кровати есть. Существа ещё и едят: построй рядом грибной сад.
tut.obj3 = Построй грибной сад (Комнаты).
tut.farm = Кривопал: Стол и кров — теперь портал присылает тебе существ. Терпение, они придут сами. Дождись двоих.
tut.obj4 = Дождись, пока через портал придут два существа.
tut.creatures = Кривопал: Вот и они! Немного мотивации никогда не повредит: возьми руку (клавиша 2) и щёлкни по существу, удерживая Shift (на сенсорном экране: коснись дважды), чтобы отвесить ему шлепок. Какое-то время оно будет работать быстрее.
tut.obj5 = Отвесь шлепок одному из своих существ (рука, Shift + щелчок).
tut.slap = Кривопал: Ха! Слышал? С востока что-то идёт. Готовься: молния (Чары) бьёт сильно, а твои существа сражаются сами.
tut.obj6 = Победи двух рыцарей.
tut.knights = Кривопал: Два рыцаря! Пошли своих существ флагом атаки (Приказы) или порази их молнией.
tut.win = Кривопал: Молодец, хозяин! Вот и всё. Ну — почти. Остальное расскажет кампания. Пойду поставлю чайник.
# master talents
cat.master = Мастер
talent.title = Таланты мастера
talent.points = Свободные очки талантов: {0}
talent.next = {0}/{1} опыта до следующего очка
talent.all = Все таланты изучены.
talent.builder = Строитель
talent.warlord = Полководец
talent.warlock = Чернокнижник
talent.merchant = Торговец
talent.builder.desc = Бесята копают на {0}% быстрее, комнаты стоят на {1}% меньше.
talent.warlord.desc = Существа бьют на {0}% сильнее и получают на {1}% меньше урона.
talent.warlock.desc = Мана течёт на {0}% быстрее, чары стоят на {1}% меньше.
talent.merchant.desc = Жалованье на {0}% ниже, доставленные сокровища ценятся на {1}% выше.
talent.none = Ещё не изучено.
talent.then = Следующий уровень: {0}
msg.talent_point = Новое очко таланта! Потрать его в окне мастера.
msg.talent_learned = {0}: изучен уровень {1}.
# weather above ground
weather.fair = Ясно
weather.rain = Дождь
weather.fog = Туман
weather.storm = Гроза
msg.weather_clear = Наверху проясняется.
msg.weather_rain = Наверху начинается дождь - огонь не разгорается.
msg.weather_fog = Туман стелется над землёй - снаружи все видят хуже.
msg.weather_storm = Наверху надвигается гроза! Молнии бьют по бойцам и деревьям.
msg.lightning_unit = Удар молнии: {0}!
# creature names
names.imp = Крошка, Сопля, Копуша, Грязнуля, Чумазик, Шкет, Рылька, Замарашка, Козявка, Царапка, Комок, Хрюндель, Плюх, Пачкун, Лопатка, Шмыга, Нюхля, Мелочь, Ковырь, Бултых
names.kobold = Тявка, Щипун, Зубило, Паршивец, Хвать, Ржавчик, Шмыг, Кусака, Разиня, Крыс, Лохмотик, Колючка, Коготь, Склочник, Корешок, Хихикс, Пихало, Зазубрина, Хрясь, Царапыч
names.spider = Шуршик, Мохнолап, Паучок, Щекотун, Сеточка, Восьминог, Ниточка, Жуткоша, Перебирка, Шёлковка, Пряха, Прыгуля, Пушистик, Текла, Катушка, Хватайка, Клубочек, Липучка, Вертушка, Иголочка
names.mage = Варфоломей, Игнатий, Леопольд, Феофан, Евлампий, Иероним, Аполлинарий, Пафнутий, Корнилий, Онуфрий, Флорентий, Модест, Панкратий, Евстафий, Константин, Евсевий, Северин, Варнава, Акакий, Ферапонт
names.ogre = Громила, Чурбан, Бумбум, Валун, Тупица, Дубина, Бухтя, Колотун, Топтыга, Пень, Плюх, Брюхан, Балда, Пузан, Дубинка, Бугай, Грохот, Глыба, Барабан, Горбо
names.jailer = Хмурый, Засов, Кандальник, Замок, Темница, Железнобород, Скоба, Болт, Суровый, Решёткин, Насупленный, Крюк, Петля, Ключник, Мрачный, Звякун, Колодник, Стражник, Клеть, Горький
names.priestess = Жара, Пламя, Пепелина, Серафима, Полымя, Огнеслава, Морана, Брунгильда, Игния, Искра, Багряна, Жарка, Ворона, Лилит, Лучина, Изольда, Огнеяра, Никта, Вечерня, Купава
names.chainbreaker = Цепелом, Ульфгар, Железнодух, Браннок, Крагмор, Вальдрек, Терн, Горат, Несломленный, Рюрик, Торвальд, Хаген, Дурнок, Осколок, Пламень, Волкослав, Коррун, Арвак, Освободитель, Хальвард
names.bat = Писк, Порх, Пипа, Шорох, Эхо, Махалка, Сумрак, Свистун, Цвирк, Зубик, Вихрь, Нетопырь, Моль, Пискля, Шурша, Висюлька, Бархат, Шмыгун, Визгля, Молния
names.gargoyle = Гранит, Сланец, Базальт, Каменюка, Раствор, Галька, Щебень, Кремень, Шов, Руина, Кварц, Пемза, Карниз, Нарост, Каменобород, Угрюм, Фронтон, Водосток, Долото, Цоколь
names.owl = Филин, Умник, Пёрышко, Бартольд, Минерва, Товий, Ухух, Совушка, Пухлик, Вздох, Мыслитель, Старейшина, Сыч, Ноктуа, Совуслав, Пергамент, Сумеречник, Неясыть, Профессор, Пушок
names.wyrm = Вермитракс, Пеплопасть, Палёный, Кальдерон, Игнивар, Жаророг, Драккар, Пирр, Обсидиан, Жаропасть, Вулканор, Палезуб, Малграт, Тиракс, Инферно, Сера, Кальдор, Фафнир, Рагнор, Уголёк
names.cogling = Шестерёнка, Колёсико, Винтик, Трещотка, Заклёпка, Привод, Мастерок, Штуковина, Ключик, Шпиндель, Звякалка, Ковыряшка, Гаечка, Жужжа, Рычажок, Юла, Фланец, Щёлк, Тиктак, Шурупчик
names.revenant = Тоска, Горечь, Пепельный, Стон, Пустота, Скорбь, Бледный, Кручина, Тень, Прах, Могильщик, Сумрак, Склеп, Печаль, Бдение, Курган, Саван, Элегия, Осадок, Пустошь
names.beast = Лохмач, Мишка, Бруно, Косматый, Чавка, Мохоспин, Ворчун, Медвежонок, Кустик, Хрум, Щёлкун, Ползун, Щетина, Мох, Рокот, Ежевика, Стрекот, Бурчун, Топтыгин, Нюхач
names.human = Жерар, Роланд, Конрад, Альберт, Тристан, Экхарт, Гавейн, Ланселот, Парцифаль, Гуго, Бертрам, Готфрид, Ортвин, Балдуин, Рейнольд, Вильфрид, Дитрих, Жильбер, Руперт, Ратибор
epithets.mid = Костолом, Грязнопят, Крысогрыз, Чумазорыл, Камнежуй, Черепокол, Смердокулак, Ржавокост, Брюхобой, Лягун, Плеснебород, Грязекоготь, Соплемёт, Ухогрыз, Коленолом, Червеед
epithets.high = Цареубийца, Рокорук, Ужасозуб, Мирокрушитель, Героебой, Бурекулак, Ночестрах, Душежнец, Железнодлань, Градолом, Драконобой, Адоход, Теневладыка, Крововенец, Рокопад, Звездолом
name.epithet = {0} {1}
msg.creature_fallen = Пал твой боец: {0}. Памятный камень хранит имя.
tile.memorial = Памятный камень
tile.memorial_info = Здесь покоится {0} ({1}, уровень {2}).
# hall of the fallen
hall.title = Зал павших
hall.count = Павших существ в памяти: {0}
hall.empty = Пока никто не пал. Пусть так и будет.
hall.kills = Убито героев: {0}
ov.fallen = Павшие ({0})
ov.living = Живые
cause.slain = убийца: {0}
cause.lava = гибель в лаве
cause.water = утопление
cause.snare = гибель в капкане
cause.lightning = удар молнии
cause.fell = гибель в бою
+1770
View File
File diff suppressed because it is too large. Load diff
+1770
View File
File diff suppressed because it is too large. Load diff
+1770
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -4,6 +4,13 @@ server {
index index.html; index index.html;
# test build: always reload fresh files # test build: always reload fresh files
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
# compressed downloads: the big files are packed once at build time (*.gz, see Dockerfile),
# the rest on the fly (the wasm goes from about 8.8 MB to 2.6 MB)
gzip_static on;
gzip on;
gzip_types application/wasm application/javascript text/css application/json application/manifest+json image/svg+xml;
gzip_min_length 1024;
gzip_vary on;
location / { location / {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
+38
View File
@@ -562,6 +562,43 @@ async fn list_campaigns(State(st): State<AppState>, headers: HeaderMap) -> Respo
Json(campaign_infos(&st.campaign_dir(&u.name)).await).into_response() Json(campaign_infos(&st.campaign_dir(&u.name)).await).into_response()
} }
// the player's profile: lifetime statistics and achievements (an opaque JSON object; the client
// merges it with what it kept locally)
const MAX_PROFILE: usize = 64 * 1024;
async fn get_profile(State(st): State<AppState>, headers: HeaderMap) -> Response {
let u = match st.auth(&headers).await {
Ok(u) => u,
Err(r) => return r,
};
match tokio::fs::read(st.user_dir(&u.name).join("profile.json")).await {
Ok(b) => ([("content-type", "application/json")], b).into_response(),
Err(_) => ([("content-type", "application/json")], b"{}".to_vec()).into_response(),
}
}
async fn put_profile(State(st): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
let u = match st.auth(&headers).await {
Ok(u) => u,
Err(r) => return r,
};
if body.len() > MAX_PROFILE {
return err(StatusCode::PAYLOAD_TOO_LARGE, "profile too large");
}
if limited(&st.writes, WRITES_PER_MINUTE).await {
return err(StatusCode::TOO_MANY_REQUESTS, "too many requests");
}
if !serde_json::from_slice::<serde_json::Value>(&body).map_or(false, |v| v.is_object()) {
return err(StatusCode::BAD_REQUEST, "not a JSON object");
}
let dir = st.user_dir(&u.name);
let _ = tokio::fs::create_dir_all(&dir).await;
match tokio::fs::write(dir.join("profile.json"), &body).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(_) => err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"),
}
}
async fn get_campaign(State(st): State<AppState>, headers: HeaderMap, Path(id): Path<String>) -> Response { async fn get_campaign(State(st): State<AppState>, headers: HeaderMap, Path(id): Path<String>) -> Response {
let u = match st.auth(&headers).await { let u = match st.auth(&headers).await {
Ok(u) => u, Ok(u) => u,
@@ -754,6 +791,7 @@ fn router(state: AppState) -> Router {
.route("/api/users", get(list_users).post(create_user)) .route("/api/users", get(list_users).post(create_user))
.route("/api/users/{name}", delete(delete_user)) .route("/api/users/{name}", delete(delete_user))
.route("/api/netplay/ws", get(netplay::netplay_ws)) .route("/api/netplay/ws", get(netplay::netplay_ws))
.route("/api/profile", get(get_profile).put(put_profile))
.route("/api/saves", get(list).post(create)) .route("/api/saves", get(list).post(create))
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save)) .route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save))
.route("/api/campaigns", get(list_campaigns).post(create_campaign).layer(campaign_limit())) .route("/api/campaigns", get(list_campaigns).post(create_campaign).layer(campaign_limit()))
+39 -3
View File
@@ -6,9 +6,12 @@
// //
// Lobby messages (JSON, one object per WebSocket frame): // Lobby messages (JSON, one object per WebSocket frame):
// {"t":"rooms"} -> list of open matches // {"t":"rooms"} -> list of open matches
// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?,"mission":12?,"doc":{..}?} // {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?,"mission":12?,"doc":{..}?,"surface":true?}
// -> "surface": a free match whose eastern half lies above ground
// -> "doc": an editor campaign (CampaignDoc JSON) for a co-op // -> "doc": an editor campaign (CampaignDoc JSON) for a co-op
// match of its mission "mission"; it goes along with "start" // match of its mission "mission"; it goes along with "start"
// "seed":77?,"carry":[..]? with a doc: an interrupted run goes
// on - "start" then has this seed, the carry and "resume":true
// {"t":"join","code":"ABC123"} // {"t":"join","code":"ABC123"}
// {"t":"take_side","side":2} // {"t":"take_side","side":2}
// {"t":"ready","ready":true} // {"t":"ready","ready":true}
@@ -85,6 +88,9 @@ struct Room {
difficulty: u8, difficulty: u8,
mission: Option<usize>, mission: Option<usize>,
doc: Option<Value>, // editor campaign of a co-op match (sent to every client at the start) doc: Option<Value>, // editor campaign of a co-op match (sent to every client at the start)
resume: Option<(u32, Value)>, // a resumed editor campaign run: its seed and what the masters carry
surface: bool, // a free match half above ground
events: bool, // random events in a free match
} }
#[derive(Debug)] #[derive(Debug)]
@@ -358,6 +364,10 @@ impl Netplay {
let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8; let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8;
let mission = mission_of(msg.get("mission")); let mission = mission_of(msg.get("mission"));
let doc = msg.get("doc").filter(|d| !d.is_null()).cloned(); let doc = msg.get("doc").filter(|d| !d.is_null()).cloned();
let resume = match (&doc, msg.get("seed").and_then(|v| v.as_u64())) {
(Some(_), Some(seed)) => Some((seed as u32, msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or_else(|| json!([])))),
_ => None,
};
match &doc { match &doc {
Some(d) => { Some(d) => {
let count = d.get("missions").and_then(|m| m.as_array()).map_or(0, |m| m.len()); let count = d.get("missions").and_then(|m| m.as_array()).map_or(0, |m| m.len());
@@ -375,7 +385,7 @@ impl Netplay {
} }
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
let seats = sides.iter().map(|s| (*s, new_seat(*s, false))).collect::<HashMap<_, _>>(); let seats = sides.iter().map(|s| (*s, new_seat(*s, false))).collect::<HashMap<_, _>>();
let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission, doc }; let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission, doc, resume, surface: msg.get("surface").and_then(|v| v.as_bool()).unwrap_or(false), events: msg.get("events").and_then(|v| v.as_bool()).unwrap_or(false) };
self.rooms.insert(code.clone(), room); self.rooms.insert(code.clone(), room);
if let Some(c) = self.clients.get_mut(&conn) { if let Some(c) = self.clients.get_mut(&conn) {
c.room = Some(code.clone()); c.room = Some(code.clone());
@@ -630,7 +640,7 @@ impl Netplay {
r.started = true; r.started = true;
r.paused = false; r.paused = false;
r.missing = false; r.missing = false;
r.seed = Some(random_seed()); r.seed = Some(r.resume.as_ref().map_or_else(random_seed, |x| x.0));
r.turn = 0; r.turn = 0;
r.sides = active; r.sides = active;
} }
@@ -640,6 +650,16 @@ impl Netplay {
if let Some(doc) = &r.doc { if let Some(doc) = &r.doc {
m["doc"] = doc.clone(); m["doc"] = doc.clone();
} }
if r.surface {
m["surface"] = json!(true);
}
if r.events {
m["events"] = json!(true);
}
if let Some((_, carry)) = &r.resume {
m["carry"] = carry.clone();
m["resume"] = json!(true);
}
m m
} }
None => return, None => return,
@@ -971,6 +991,22 @@ mod tests {
assert_eq!(start_red["mission"].as_u64(), Some(2)); assert_eq!(start_red["mission"].as_u64(), Some(2));
} }
#[test]
fn a_resumed_run_starts_with_its_seed_and_carry() {
let mut net = Netplay::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let red = net.connect(tx, Some("red".into()), None);
let doc = json!({"format":"dungeoncraft-campaign","name":"Mine","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second","size":[96,96]}]});
let carry = json!([[0, {"gold": 100.0, "creatures": []}]]);
net.handle(red, json!({"t":"create","sides":[0,2],"mission":1,"doc":doc,"seed":4242,"carry":carry}));
net.handle(red, json!({"t":"ready"}));
net.handle(red, json!({"t":"start"}));
let start = until(&mut rx, "start");
assert_eq!(start["seed"].as_u64(), Some(4242));
assert_eq!(start["carry"], carry);
assert_eq!(start["resume"].as_bool(), Some(true));
}
#[test] #[test]
fn an_editor_campaign_goes_from_the_create_to_every_start() { fn an_editor_campaign_goes_from_the_create_to_every_start() {
let mut net = Netplay::new(); let mut net = Netplay::new();
+347
View File
@@ -0,0 +1,347 @@
// Achievements: a lifetime profile of the player (totals of every game's statistics, wins,
// minutes played) and 64 achievements - tiers of counters (bronze, silver, gold, ...) and a few
// special, partly hidden ones. Kept in the browser; after a login merged with the server's copy
// (the larger counter wins, unlocked stays unlocked) and stored there too.
use crate::game::*;
use crate::stats as k;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const PROFILE_KEY: &str = "dc.profile";
// counters of the profile: the game statistics (stats.rs) and a few of its own
pub const STAT_NAMES: [&str; k::COUNTERS] = [
"heroes", "bosses", "lost", "dug", "wood", "walls", "rooms", "spells", "slaps", "traps", "towers", "drowned", "burnt", "siege", "captured", "gold", "drawbridge",
];
// tiered achievements: id (= counter), thresholds, badge (badges.rs)
pub const TIERS: [(&str, &[f64], u32); 20] = [
("heroes", &[1.0, 50.0, 250.0, 1000.0], crate::badges::HEROES),
("bosses", &[1.0, 5.0, 20.0], crate::badges::BOSSES),
("dug", &[100.0, 1000.0, 10000.0], crate::badges::DUG),
("gold", &[10000.0, 100000.0, 1000000.0], crate::badges::GOLD),
("wood", &[50.0, 500.0, 2000.0], crate::badges::WOOD),
("walls", &[10.0, 100.0], crate::badges::WALLS),
("rooms", &[50.0, 500.0, 2000.0], crate::badges::ROOMS),
("spells", &[10.0, 100.0, 500.0], crate::badges::SPELLS),
("slaps", &[10.0, 100.0, 1000.0], crate::badges::SLAPS),
("traps", &[5.0, 50.0, 200.0], crate::badges::TRAPS),
("towers", &[5.0, 50.0], crate::badges::TOWERS),
("drowned", &[1.0, 25.0, 100.0], crate::badges::DROWNED),
("burnt", &[5.0, 50.0], crate::badges::BURNT),
("siege", &[1.0, 10.0], crate::badges::SIEGE),
("captured", &[1.0, 25.0, 100.0], crate::badges::CAPTURED),
("drawbridge", &[1.0, 50.0], crate::badges::DRAWBRIDGE),
("wins", &[1.0, 10.0, 50.0], crate::badges::WINS),
("missions", &[1.0, 10.0, 30.0], crate::badges::MISSIONS),
("coop", &[1.0, 10.0, 20.0], crate::badges::COOP),
("minutes", &[60.0, 600.0, 3000.0], crate::badges::MINUTES),
];
// special achievements: id, hidden until unlocked, badge (badges.rs)
pub const SPECIAL: [(&str, bool, u32); 8] = [
("flawless", false, crate::badges::FLAWLESS),
("speedrun", false, crate::badges::SPEEDRUN),
("campaign", false, crate::badges::CAMPAIGN),
("surface", false, crate::badges::SURFACE),
("defeat", true, crate::badges::DEFEAT),
("legend", false, crate::badges::LEGEND),
("wyrm", true, crate::badges::WYRM),
("fullhand", true, crate::badges::FULLHAND),
];
// number of achievements in all
#[cfg(test)]
pub fn count() -> usize {
TIERS.iter().map(|t| t.1.len()).sum::<usize>() + SPECIAL.len()
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Profile {
#[serde(default)]
pub totals: BTreeMap<String, f64>,
#[serde(default)]
pub unlocked: BTreeMap<String, u64>, // achievement id ("heroes.2", "flawless") -> unix time
#[serde(default)]
pub hall: Vec<crate::names::Fallen>, // the hall of the fallen over all games (newest last)
}
// the hall of the fallen keeps this many
pub const HALL_MAX: usize = 200;
impl Profile {
pub fn total(&self, id: &str) -> f64 {
self.totals.get(id).copied().unwrap_or(0.0)
}
// the larger counter wins, unlocked stays unlocked (with the earlier date)
pub fn merge(&mut self, other: &Profile) {
for (k, v) in &other.totals {
let e = self.totals.entry(k.clone()).or_insert(0.0);
*e = e.max(*v);
}
for (k, t) in &other.unlocked {
let e = self.unlocked.entry(k.clone()).or_insert(*t);
*e = (*e).min(*t);
}
// the fallen of both, once each, in the order they fell
for f in &other.hall {
if !self.hall.iter().any(|h| h.name == f.name && h.kind == f.kind && h.when == f.when) {
self.hall.push(f.clone());
}
}
self.hall.sort_by_key(|h| h.when);
let extra = self.hall.len().saturating_sub(HALL_MAX);
self.hall.drain(..extra);
}
// unlocks what the totals have reached; returns the new ones
pub fn check_tiers(&mut self, now: u64) -> Vec<String> {
let mut new = Vec::new();
for (id, steps, _) in TIERS.iter() {
let v = self.total(id);
for (t, &need) in steps.iter().enumerate() {
let key = format!("{}.{}", id, t + 1);
if v >= need && !self.unlocked.contains_key(&key) {
self.unlocked.insert(key.clone(), now);
new.push(key);
}
}
}
new
}
pub fn unlock(&mut self, id: &str, now: u64) -> bool {
if self.unlocked.contains_key(id) {
return false;
}
self.unlocked.insert(id.to_string(), now);
true
}
}
// what the tracker needs to know about the running game besides the game itself
pub struct Context {
pub coop: bool,
pub editor_test: bool, // test play from the editor counts nothing
}
// Follows the running game: adds what happened to the profile every few seconds and unlocks
pub struct Tracker {
pub profile: Profile,
flushed: Vec<f32>,
hall_seen: usize, // entries of the game's hall already in the profile
last_time: f32,
ended: bool,
t: f32,
pub dirty: bool, // changed since the last store
pub upload: bool, // something new for the server
}
impl Tracker {
pub fn new(profile: Profile) -> Tracker {
Tracker { profile, flushed: vec![0.0; k::COUNTERS], hall_seen: 0, last_time: 0.0, ended: false, t: 0.0, dirty: false, upload: false }
}
// a different game started (new, loaded): nothing of it has been counted yet
fn restart(&mut self, g: &Game) {
self.flushed = (0..k::COUNTERS).map(|i| g.stats.get(i)).collect();
self.hall_seen = g.hall.len();
self.ended = g.state != 0;
}
// called every frame; returns achievements unlocked just now
pub fn update(&mut self, g: &Game, ctx: &Context, dt: f32, now: u64) -> Vec<String> {
if g.demo.is_some() || ctx.editor_test {
self.last_time = 0.0;
return Vec::new();
}
// a loaded or new game: its old counters are not counted again
if g.time + 0.5 < self.last_time || self.last_time == 0.0 {
self.restart(g);
}
self.last_time = g.time.max(0.01);
self.t += dt;
let ended_now = g.state != 0 && !self.ended;
if self.t < 5.0 && !ended_now {
return Vec::new();
}
let played = self.t;
self.t = 0.0;
let mut new = Vec::new();
// the newly fallen go into the profile's hall
if g.hall.len() > self.hall_seen {
for f in &g.hall[self.hall_seen..] {
self.profile.hall.push(crate::names::Fallen { when: now, ..f.clone() });
}
self.hall_seen = g.hall.len();
let extra = self.profile.hall.len().saturating_sub(HALL_MAX);
self.profile.hall.drain(..extra);
self.dirty = true;
self.upload = true;
}
// the counters of the game
for i in 0..k::COUNTERS {
let cur = g.stats.get(i);
let d = cur - self.flushed[i];
if d > 0.0 {
*self.profile.totals.entry(STAT_NAMES[i].to_string()).or_insert(0.0) += d as f64;
self.flushed[i] = cur;
self.dirty = true;
}
}
if g.state == 0 {
*self.profile.totals.entry("minutes".into()).or_insert(0.0) += (played / 60.0) as f64;
self.dirty = true;
}
// the special ones that can happen any time
if g.ents.iter().any(|e| e.alive && e.creature() && e.level >= 10) && self.profile.unlock("legend", now) {
new.push("legend".to_string());
}
if g.ents.iter().any(|e| e.alive && e.creature() && e.kind == EK::Wyrm) && self.profile.unlock("wyrm", now) {
new.push("wyrm".to_string());
}
if g.hand.len() >= 8 && self.profile.unlock("fullhand", now) {
new.push("fullhand".to_string());
}
// the end of the game
if ended_now {
self.ended = true;
if g.state == 1 {
let key = if ctx.coop {
"coop"
} else if g.mission.is_some() {
"missions"
} else {
"wins"
};
*self.profile.totals.entry(key.into()).or_insert(0.0) += 1.0;
if g.stats.get(k::LOST) == 0.0 && self.profile.unlock("flawless", now) {
new.push("flawless".into());
}
if g.time < 15.0 * 60.0 && self.profile.unlock("speedrun", now) {
new.push("speedrun".into());
}
let last = crate::campaign::missions().len().saturating_sub(1);
if !ctx.coop && g.mission.as_ref().map_or(false, |m| m.index == last) && self.profile.unlock("campaign", now) {
new.push("campaign".into());
}
if !g.map.sky.is_empty() && self.profile.unlock("surface", now) {
new.push("surface".into());
}
} else if self.profile.unlock("defeat", now) {
new.push("defeat".into());
}
self.dirty = true;
}
new.extend(self.profile.check_tiers(now));
if !new.is_empty() {
self.dirty = true;
self.upload = true;
}
new
}
}
// the name and description of an achievement ("heroes.2" or "flawless") in the current language
pub fn texts(id: &str) -> (String, String) {
use crate::i18n::{tr, trf};
match id.split_once('.') {
Some((cat, tier)) => {
let t: usize = tier.parse().unwrap_or(1);
let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t - 1)).copied().unwrap_or(0.0);
let roman = ["I", "II", "III", "IV", "V"][(t - 1).min(4)];
(trf(&format!("ach.{}.name", cat), &[roman.to_string()]), trf(&format!("ach.{}.desc", cat), &[fmt_count(need)]))
}
None => (tr(&format!("ach.{}.name", id)), tr(&format!("ach.{}.desc", id))),
}
}
pub fn fmt_count(v: f64) -> String {
let n = v.round() as i64;
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
out.push('.');
}
out.push(c);
}
out
}
// every achievement in display order: (id, hidden, badge, tier 0..)
pub fn all() -> Vec<(String, bool, u32, usize)> {
let mut v = Vec::new();
for (id, steps, icon) in TIERS.iter() {
for t in 0..steps.len() {
v.push((format!("{}.{}", id, t + 1), false, *icon, t));
}
}
for (id, hidden, icon) in SPECIAL.iter() {
v.push((id.to_string(), *hidden, *icon, 9));
}
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn more_than_fifty_achievements() {
assert!(count() >= 50, "{}", count());
assert_eq!(all().len(), count());
}
#[test]
fn every_achievement_has_its_own_badge() {
let mut seen = std::collections::BTreeSet::new();
for b in TIERS.iter().map(|t| t.2).chain(SPECIAL.iter().map(|s| s.2)) {
assert!(b < crate::badges::COUNT, "badge {} out of range", b);
assert!(seen.insert(b), "badge {} used twice", b);
}
assert_eq!(seen.len() as u32, crate::badges::COUNT);
}
#[test]
fn the_tracker_counts_a_game_and_unlocks() {
let mut g = Game::new(3, 64, 64);
let mut t = Tracker::new(Profile::default());
let ctx = Context { coop: false, editor_test: false };
g.time = 1.0;
t.update(&g, &ctx, 0.1, 1);
g.count(k::HEROES, 3.0);
g.count(k::DUG, 150.0);
g.time = 10.0;
let new = t.update(&g, &ctx, 6.0, 2);
assert!(new.contains(&"heroes.1".to_string()) && new.contains(&"dug.1".to_string()), "{:?}", new);
assert_eq!(t.profile.total("heroes"), 3.0);
// the same counters are not counted twice
g.time = 20.0;
t.update(&g, &ctx, 6.0, 3);
assert_eq!(t.profile.total("heroes"), 3.0);
// a won free game without losses
g.state = 1;
let new = t.update(&g, &ctx, 0.1, 4);
assert!(new.contains(&"wins.1".to_string()) && new.contains(&"flawless".to_string()), "{:?}", new);
}
#[test]
fn profiles_merge() {
let mut a = Profile::default();
a.totals.insert("heroes".into(), 10.0);
a.unlocked.insert("heroes.1".into(), 5);
let mut b = Profile::default();
b.totals.insert("heroes".into(), 7.0);
b.totals.insert("dug".into(), 100.0);
b.unlocked.insert("heroes.1".into(), 3);
b.unlocked.insert("dug.1".into(), 4);
a.merge(&b);
assert_eq!(a.total("heroes"), 10.0);
assert_eq!(a.total("dug"), 100.0);
assert_eq!(a.unlocked["heroes.1"], 3);
assert!(a.unlocked.contains_key("dug.1"));
}
}
+454
View File
@@ -0,0 +1,454 @@
// AI dungeon masters: a rival (free play, campaign), an AI seat of a match or the co-op partner is
// a real master with gold, mana, imps, rooms and research - the same rules as the player. This
// brain gives him the player's orders every few seconds: summon imps, mark earth and veins to dig
// in growing rings, build rooms in a sensible order, put towers at the border, set the attack
// flag on an enemy heart once the army is strong enough (and take it back after heavy losses),
// guard its own heart with a shield. The difficulty sets how often it thinks and how bold it is.
// Everything runs in the shared step with the shared random numbers (the same on every client).
use crate::game::*;
use crate::map::*;
// per difficulty (easy, medium, hard)
const THINK: [f32; 3] = [5.2, 3.25, 1.95]; // seconds between two thoughts (a calm pace)
const MAX_RADIUS: [i32; 3] = [11, 15, 19]; // how far the dungeon grows around the heart
const MAX_IMPS: [usize; 3] = [5, 7, 9];
const FIRST_ATTACK: [f32; 3] = [940.0, 625.0, 430.0]; // no attack before (seconds)
const BOLDNESS: [f32; 3] = [1.0, 1.2, 1.35]; // own army / enemy army needed to attack (easy is reckless, hard waits)
const MARK_REACH: i32 = 5; // an AI master marks only this close to its own ground
const CREATURE_TARGET: [usize; 3] = [24, 50, 80]; // beds an AI master builds up to
const MAX_TOWERS: [usize; 3] = [1, 2, 4];
const START_GOLD: [f32; 3] = [1500.0, 2500.0, 3500.0];
// the rooms a master builds, in this order: (room index, width, height, how many of them)
const BUILD: [(usize, i32, i32, usize); 10] = [
(0, 3, 3, 1), // treasury
(1, 4, 3, 1), // lair
(2, 4, 3, 1), // mushroom garden
(3, 3, 3, 1), // training hall
(4, 3, 3, 1), // library
(1, 4, 3, 2), // a second lair
(2, 4, 3, 2), // a second garden
(8, 3, 3, 1), // forge
(6, 3, 3, 1), // temple
(9, 3, 2, 1), // prison
];
pub fn start_gold(difficulty: u8) -> f32 {
START_GOLD[difficulty.min(2) as usize]
}
impl Game {
// the brain of rival k (its master is the one being processed)
pub(crate) fn ai_think(&mut self, k: usize, dt: f32) {
let d = self.rivals[k].difficulty.min(2) as usize;
self.rivals[k].think_t -= dt;
if self.rivals[k].think_t > 0.0 {
return;
}
self.rivals[k].think_t = THINK[d] * (0.85 + self.rng.f() * 0.3);
let side = self.rivals[k].side;
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
self.ai_imps(d, cx, cy);
self.ai_dig(k, d, cx, cy);
self.ai_gold(cx, cy);
self.ai_rooms(d, cx, cy);
self.ai_towers(d, cx, cy);
self.ai_fight(k, d, side, cx, cy);
let focus = self.rivals[k].focus;
self.ai_talents(focus);
}
// the tiles an AI master may mark: within MARK_REACH tiles of its own ground
fn ai_reach_mask(&self) -> Vec<bool> {
let me = self.me;
let mut dist = vec![u8::MAX; self.map.t.len()];
let mut queue = std::collections::VecDeque::new();
for (i, t) in self.map.t.iter().enumerate() {
if t.owner == me {
dist[i] = 0;
queue.push_back(i);
}
}
while let Some(i) = queue.pop_front() {
let d = dist[i];
if d as i32 >= MARK_REACH {
continue;
}
let (x, y) = xy(i);
for &(dx, dy) in DIR8.iter() {
if inb(x + dx, y + dy) {
let j = idx(x + dx, y + dy);
if dist[j] > d + 1 {
dist[j] = d + 1;
queue.push_back(j);
}
}
}
}
dist.into_iter().map(|d| d as i32 <= MARK_REACH).collect()
}
// marks a tile for this master's imps (only near its own ground)
fn ai_mark(&mut self, i: usize, mask: &[bool]) -> bool {
if !mask.get(i).copied().unwrap_or(false) {
return false;
}
let side = self.me.side().min(7);
let t = &mut self.map.t[i];
if t.marked && t.mark_by != side {
return false; // another master's plan
}
t.marked = true;
t.mark_by = side;
true
}
// enough imps for the dungeon's size
fn ai_imps(&mut self, d: usize, cx: i32, cy: i32) {
let claimed = self.map.t.iter().filter(|t| t.owner == self.me).count();
let want = (4 + claimed / 30).min(MAX_IMPS[d]);
if self.worker_count() < want && self.mana >= self.summon_cost() {
self.summon(cx as f32 + 0.5, cy as f32 + 2.5);
}
}
// dig: rings of earth around the heart, veins first; the ring grows when it is dug out
fn ai_dig(&mut self, k: usize, d: usize, cx: i32, cy: i32) {
let me = self.me;
let side = me.side().min(7);
let r = self.rivals[k].radius.max(5);
let marked_mine = |g: &Game| {
let mut n = 0;
for y in cy - r - 4..=cy + r + 4 {
for x in cx - r - 4..=cx + r + 4 {
if g.map.get(x, y).map_or(false, |t| t.marked && t.mark_by == side) {
n += 1;
}
}
}
n
};
let open = marked_mine(self);
if open > self.worker_count() * 4 {
return; // the imps have enough to do
}
let mut added = 0;
let mask = self.ai_reach_mask();
let diggable = |t: &Tile| t.diggable() && !t.plant() && !t.reinforced && t.kind != Kind::Steel && (t.owner == Owner::None || t.owner == me);
// veins a little beyond the ring, then the ring itself
for pass in 0..2 {
let reach = if pass == 0 { r + 3 } else { r };
for y in cy - reach..=cy + reach {
for x in cx - reach..=cx + reach {
if added >= 11 || !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 {
continue;
}
let i = idx(x, y);
let t = self.map.t[i];
if t.marked || !diggable(&t) || (pass == 0 && t.vein().is_none()) {
continue;
}
if self.ai_mark(i, &mask) {
added += 1;
}
}
}
}
// the ring is done: grow
if added == 0 && open == 0 && r < MAX_RADIUS[d] {
self.rivals[k].radius = r + 2;
}
}
// gold running low: a tunnel to the nearest vein that nobody works on yet
fn ai_gold(&mut self, cx: i32, cy: i32) {
// gold for one and a half paydays and a little more: enough
if self.gold > self.next_payday_cost() * 1.5 + 1000.0 {
return;
}
let me = self.me;
// marked veins the imps can reach (next to own ground) are being worked already; one vein
// for every three imps at a time
let side = me.side().min(7);
let busy = (0..self.map.t.len())
.filter(|&i| {
let t = &self.map.t[i];
let (x, y) = xy(i);
t.marked && t.mark_by == side && t.vein().is_some() && DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable()))
})
.count();
if busy >= (self.worker_count() / 3).max(1) {
return;
}
self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| {
let t = &g.map.t[i];
t.vein().is_some() && !t.marked && (t.owner == Owner::None || t.owner == me)
}, 40);
}
// marks the diggable tiles on the cheapest way from the heart to a goal tile (and the goal
// with the veins next to it); false if there is none within `reach` tiles
fn ai_mark_tunnel(&mut self, cx: i32, cy: i32, goal: impl Fn(&Game, usize) -> bool, reach: i32) -> bool {
let start = [(cx, cy + 2), (cx, cy - 2), (cx + 2, cy), (cx - 2, cy)].into_iter().find(|&(x, y)| self.map.walk(x, y));
let Some((sx, sy)) = start else { return false };
let found = {
let cost = self.map.hero_cost_as(false);
let near = |i: usize| {
let (x, y) = xy(i);
(x - cx).abs().max((y - cy).abs()) <= reach
};
self.map.search(idx(sx, sy), |i| if near(i) { cost(i) } else { None }, |i| goal(self, i), false)
};
let Some(path) = found else { return false };
let me = self.me;
let mask = self.ai_reach_mask();
let mut cluster = Vec::new();
for &i in &path {
let t = self.map.t[i];
if t.diggable() && !t.plant() && t.kind != Kind::Steel && (t.owner == Owner::None || t.owner == me || t.reinforced) && self.ai_mark(i, &mask) {
if t.vein().is_some() {
cluster.push(i);
}
}
}
// the rest of the vein
for i in cluster {
let (x, y) = xy(i);
for &(dx, dy) in DIR8.iter() {
if let Some(t) = self.map.get(x + dx, y + dy) {
if t.vein().is_some() && t.owner == Owner::None {
self.ai_mark(idx(x + dx, y + dy), &mask);
}
}
}
}
true
}
// the next room of the build order, where there is free own floor for it
fn ai_rooms(&mut self, d: usize, cx: i32, cy: i32) {
for &(room, w, h, n) in BUILD.iter() {
// only what this master has researched (the same rules as the player)
if !self.ai_knows(crate::ui::Tool::Room(room)) {
continue;
}
let tiles = self.counts[room];
if tiles >= (w * h) as usize * n {
continue;
}
let cost = ROOM_COST[room] as f32 * (w * h) as f32;
if self.gold < cost + 200.0 {
return; // saving for it
}
if let Some((x0, y0)) = self.ai_free_rect(cx, cy, w, h) {
self.build_rect(room, x0, y0, x0 + w - 1, y0 + h - 1);
}
return;
}
// the base is done: a bigger treasury when it is nearly full
if self.gold > self.gold_cap() * 0.85 {
let cost = ROOM_COST[0] as f32 * 9.0;
if self.gold >= cost + 400.0 {
if let Some((x0, y0)) = self.ai_free_rect(cx, cy, 3, 3) {
self.build_rect(0, x0, y0, x0 + 2, y0 + 2);
return;
}
}
}
// more beds and food while the army may still grow (by difficulty)
if self.counts[1] >= CREATURE_TARGET[d] || self.free_beds() > 2 {
return;
}
let room = if self.counts[2] * 10 < self.counts[1] * 6 { 2 } else { 1 };
let (w, h) = (4, 3);
let cost = ROOM_COST[room] as f32 * (w * h) as f32;
if self.gold < cost + 400.0 {
return;
}
if let Some((x0, y0)) = self.ai_free_rect(cx, cy, w, h) {
self.build_rect(room, x0, y0, x0 + w - 1, y0 + h - 1);
}
}
// has this master researched the tool (rooms, towers)?
fn ai_knows(&self, tool: crate::ui::Tool) -> bool {
crate::ui::TOOLS.iter().position(|&t| t == tool).map_or(false, |i| self.tool_allowed(i))
}
// a rectangle of own plain floor, as near the heart as possible (not right next to it)
fn ai_free_rect(&self, cx: i32, cy: i32, w: i32, h: i32) -> Option<(i32, i32)> {
let me = self.me;
let free = |x: i32, y: i32| self.map.get(x, y).map_or(false, |t| t.kind == Kind::Floor && t.owner == me) && !self.ents.iter().any(|e| e.alive && e.tile() == idx(x, y) && e.kind == EK::Worker && false);
let mut best: Option<((i32, i32), i32)> = None;
for y0 in cy - 22..=cy + 22 {
for x0 in cx - 22..=cx + 22 {
let (mx, my) = (x0 + w / 2, y0 + h / 2);
let dist = (mx - cx).abs() + (my - cy).abs();
if dist < 4 || best.map_or(false, |b| b.1 <= dist) {
continue;
}
if (y0..y0 + h).all(|y| (x0..x0 + w).all(|x| free(x, y))) {
best = Some(((x0, y0), dist));
}
}
}
best.map(|b| b.0)
}
// a tower or two at the border of the dungeon (towards the open ground)
fn ai_towers(&mut self, d: usize, cx: i32, cy: i32) {
let me = self.me;
let mine = self.towers.iter().filter(|t| !t.hero && self.map.t[t.tile].owner == me).count();
if mine >= MAX_TOWERS[d] || self.gold < 1800.0 || self.counts[0] == 0 || !self.ai_knows(crate::ui::Tool::Tower(TowerKind::Arrow)) {
return;
}
let spot = (0..self.map.t.len()).filter(|&i| {
let (x, y) = xy(i);
let t = &self.map.t[i];
t.kind == Kind::Floor && t.owner == me && (x - cx).abs().max((y - cy).abs()) >= 4
&& DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner != me && n.walkable()))
}).min_by_key(|&i| {
let (x, y) = xy(i);
(x - cx).abs() + (y - cy).abs()
});
if let Some(i) = spot {
let (x, y) = xy(i);
self.build_tower(TowerKind::Arrow, x, y);
}
}
// fight: a shield at the heart; the attack flag on the nearest enemy heart once strong enough,
// taken back after heavy losses. A co-op partner follows the attack flag of his allies.
fn ai_fight(&mut self, k: usize, d: usize, side: u8, cx: i32, cy: i32) {
if self.guard_marks.is_empty() && self.creature_count() >= 3 && d >= 1 {
self.place_mark(cx + 1, cy + 3, false);
}
let army = self.side_army(side);
// heroes or enemy units in the dungeon: everybody goes for them (the rally flag) and the
// master casts lightning on them and heals his hurt creatures
if !self.is_partner(side) && self.ai_defend(k) {
return;
}
// co-op: go where the allies go
if self.is_partner(side) {
let lead = self.master_sides().into_iter().find(|&s| s != side && !self.is_out(s) && self.allied(side, s));
let flag = lead.and_then(|s| self.masters.iter().find(|m| m.side == s).and_then(|m| m.flag));
if flag != self.flag {
self.flag = flag;
}
return;
}
if self.rivals[k].attacking {
// too many lost, or the target is gone: back home
let target_gone = self.flag.map_or(true, |f| !self.ai_enemy_heart_near(side, f));
if army < self.rivals[k].army0 * 0.45 || target_gone {
self.rivals[k].attacking = false;
self.flag = None;
}
return;
}
if self.time < FIRST_ATTACK[d] || self.creature_count() < 4 {
return;
}
// the nearest enemy heart (players, other AI masters)
let target = self
.master_sides()
.into_iter()
.filter(|&s| s != side && !self.is_out(s) && !self.allied(side, s) && self.core_hp_of(s) > 0.0)
.min_by_key(|&s| {
let (x, y) = self.core_of_side(s);
(x - cx).abs() + (y - cy).abs()
});
let Some(t) = target else { return };
if army < self.side_army(t) * BOLDNESS[d] {
return;
}
let (tx, ty) = self.core_of_side(t);
let spot = DIR8.iter().map(|&(dx, dy)| (tx + 1 + dx * 2, ty + 1 + dy * 2)).find(|&(x, y)| self.map.walk(x, y));
// creatures only walk: first a tunnel to the enemy (the imps dig it)
let from = [(cx, cy + 2), (cx, cy - 2), (cx + 2, cy), (cx - 2, cy)].into_iter().find(|&(x, y)| self.map.walk(x, y));
let open = match (from, spot) {
(Some((fx, fy)), Some((x, y))) => self.map.creature_path(false, idx(fx, fy), idx(x, y)).is_some(),
_ => false,
};
if !open {
let target_owner = Owner::of_side(t);
if !self.map.t.iter().any(|tl| tl.marked && tl.reinforced) {
self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| {
let (x, y) = xy(i);
g.map.t[i].owner == target_owner && g.map.t[i].walkable() || ((x - tx - 1).abs() <= 2 && (y - ty - 1).abs() <= 2 && g.map.t[i].walkable())
}, 200);
}
return;
}
if let Some((x, y)) = spot {
self.flag = Some(idx(x, y));
self.rivals[k].attacking = true;
self.rivals[k].army0 = army;
if t == self.local_side {
let key = match side {
2 => "msg.rival1_attack",
3 => "msg.rival2_attack",
_ => "msg.rival3_attack",
};
let n = self.creature_count().to_string();
self.tell_local(key, vec![n], Some(Sfx::WaveAlarm));
}
}
}
// the defence of the dungeon; true while there are intruders
fn ai_defend(&mut self, k: usize) -> bool {
let me = self.me;
let intruders: Vec<(f32, f32)> = self
.ents
.iter()
.filter(|e| e.alive && !e.held && e.captive == 0 && self.is_hostile(e) && self.map.get(e.x as i32, e.y as i32).map_or(false, |t| t.owner == me))
.map(|e| (e.x, e.y))
.collect();
if intruders.is_empty() {
if self.rivals[k].defending {
self.rivals[k].defending = false;
self.flag = None;
}
return false;
}
// the middle of the intruders, on walkable ground
let n = intruders.len() as f32;
let (mx, my) = intruders.iter().fold((0.0, 0.0), |a, p| (a.0 + p.0 / n, a.1 + p.1 / n));
let near = intruders.iter().min_by(|a, b| ((a.0 - mx).powi(2) + (a.1 - my).powi(2)).partial_cmp(&((b.0 - mx).powi(2) + (b.1 - my).powi(2))).unwrap()).copied().unwrap();
let tile = idx(near.0 as i32, near.1 as i32);
if self.rivals[k].attacking {
self.rivals[k].attacking = false; // home first
}
self.rivals[k].defending = true;
if self.map.t[tile].walkable() && self.flag != Some(tile) {
self.flag = Some(tile);
}
// lightning on the thickest of them, healing for the hurt
let bolt = 50.0 * self.talents.spell_cost();
if self.mana >= bolt + 40.0 && self.known(crate::game::LIGHTNING_TOOL) {
self.lightning(near.0, near.1);
}
let heal = 60.0 * self.talents.spell_cost();
let hurt = self.ents.iter().filter(|e| e.alive && e.creature() && e.hp < e.max_hp * 0.5 && (e.x - near.0).powi(2) + (e.y - near.1).powi(2) < 36.0).map(|e| (e.x, e.y)).next();
if let Some((hx, hy)) = hurt {
if self.mana >= heal + 40.0 && self.known(crate::game::HEAL_TOOL) {
self.heal(hx, hy);
}
}
true
}
// is an enemy heart near tile f?
fn ai_enemy_heart_near(&self, side: u8, f: usize) -> bool {
let (fx, fy) = xy(f);
self.master_sides().into_iter().any(|s| {
s != side && !self.is_out(s) && self.core_hp_of(s) > 0.0 && {
let (x, y) = self.core_of_side(s);
(x + 1 - fx).abs() <= 4 && (y + 1 - fy).abs() <= 4
}
})
}
}
+104
View File
@@ -0,0 +1,104 @@
// Creature equipment: when no trap or door is ordered, the forge makes armour and weapons in
// three tiers - iron, steel (once steel walls are known) and rune (once the magic door, the
// last discovery of the library, is known). Every finished piece goes to the strongest creature without anything that good yet:
// more health and harder blows. The tier shows as a little coloured mark at the health bar.
use crate::game::*;
// tier 1..3: forge work, gold, health and attack factor
pub const GEAR_WORK: [f32; 3] = [40.0, 70.0, 110.0];
pub const GEAR_GOLD: [f32; 3] = [60.0, 140.0, 280.0];
pub const GEAR_FACTOR: [f32; 4] = [1.0, 1.1, 1.22, 1.38];
pub fn gear_factor(gear: u8) -> f32 {
GEAR_FACTOR[(gear as usize).min(3)]
}
impl Game {
// the best tier the forge can make now (1..3)
pub fn gear_tier(&self) -> u8 {
if self.known(31) {
3
} else if self.known(19) {
2
} else {
1
}
}
// is a creature waiting for a piece of the best tier (and none in stock)?
pub fn gear_wanted(&self) -> bool {
let tier = self.gear_tier();
self.gear_stock.get(tier as usize - 1).copied().unwrap_or(0) == 0 && self.ents.iter().any(|e| e.alive && e.creature() && e.gear < tier)
}
// forge work without an order: the next piece of equipment
pub(crate) fn forge_gear(&mut self, work: f32) {
if !self.gear_wanted() {
return;
}
let tier = self.gear_tier();
let k = tier as usize - 1;
self.gear_work += work;
if self.gear_work >= GEAR_WORK[k] && self.gold >= GEAR_GOLD[k] {
self.gear_work -= GEAR_WORK[k];
self.gold -= GEAR_GOLD[k];
if self.gear_stock.len() < 3 {
self.gear_stock.resize(3, 0);
}
self.gear_stock[k] += 1;
}
self.equip_from_stock();
}
// finished pieces go to the strongest creatures that have nothing that good
pub(crate) fn equip_from_stock(&mut self) {
for k in (0..self.gear_stock.len().min(3)).rev() {
while self.gear_stock[k] > 0 {
let tier = k as u8 + 1;
let best = (0..self.ents.len())
.filter(|&j| {
let e = &self.ents[j];
e.alive && e.creature() && e.gear < tier
})
.max_by(|&a, &b| {
let (ea, eb) = (&self.ents[a], &self.ents[b]);
(ea.level, ea.max_hp as i32).cmp(&(eb.level, eb.max_hp as i32))
});
let Some(j) = best else { break };
self.gear_stock[k] -= 1;
let e = &mut self.ents[j];
let f = gear_factor(tier) / gear_factor(e.gear);
e.max_hp *= f;
e.hp *= f;
e.gear = tier;
let (x, y, kind) = (e.x, e.y, e.kind);
self.fx(7, x, y, 0.0, 0.0, 0.6, 0xff40c0ff);
self.msg("msg.gear_given", vec![format!("@gear.{}", tier), format!("@{}", st(kind).name)]);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_forge_equips_the_strongest_creature() {
let mut g = Game::new(4, 64, 64);
let (cx, cy) = g.map.core;
let weak = g.spawn(EK::Kobold, false, cx as f32 + 4.5, cy as f32 + 1.5, 1);
let strong = g.spawn(EK::Ogre, false, cx as f32 + 5.5, cy as f32 + 1.5, 5);
g.gold = 1000.0;
assert!(g.gear_wanted());
let hp0 = g.ents.iter().find(|e| e.id == strong).unwrap().max_hp;
g.forge_gear(GEAR_WORK[0] + 1.0);
let s = g.ents.iter().find(|e| e.id == strong).unwrap();
let w = g.ents.iter().find(|e| e.id == weak).unwrap();
assert_eq!(s.gear, 1, "the ogre got the first piece");
assert_eq!(w.gear, 0);
assert!(s.max_hp > hp0 && s.atk() > st(EK::Ogre).atk * (1.0 + 0.12 * 4.0));
assert!(g.gold < 1000.0);
}
}
+154 -2
View File
@@ -1,6 +1,7 @@
// Audio engine: music (XM players with crossfades) and synthesised sound effects. // Audio engine: music (XM players with crossfades) and synthesised sound effects.
// The browser pulls samples through dc_audio_render() from our JS plugin (www/dc_plugin.js), // The browser pulls samples through dc_audio_render() from our JS plugin (www/dc_plugin.js),
// which starts the AudioContext on the first user interaction. // which starts the AudioContext on the first user interaction. Desktop builds play through
// the system's sound output (module `native` below).
use crate::sfx; use crate::sfx;
use crate::tracks::TRACKS; use crate::tracks::TRACKS;
@@ -21,6 +22,8 @@ pub enum Category {
Temple, Temple,
Victory, Victory,
Defeat, Defeat,
Surface, // above ground: flute, harp, birdsong
SurfaceBattle, // fighting above ground
} }
// indices of all tracks of a category, in play order // indices of all tracks of a category, in play order
@@ -28,6 +31,36 @@ pub fn tracks_in(c: Category) -> Vec<usize> {
TRACKS.iter().enumerate().filter(|(_, t)| t.1 == c).map(|(i, _)| i).collect() TRACKS.iter().enumerate().filter(|(_, t)| t.1 == c).map(|(i, _)| i).collect()
} }
// the calm tracks the music player offers (no battle, victory or defeat music)
pub fn player_tracks() -> Vec<usize> {
use Category as C;
TRACKS.iter().enumerate().filter(|(_, t)| matches!(t.1, C::Title | C::Build | C::Surface | C::Torture | C::Temple)).map(|(i, _)| i).collect()
}
pub fn track_id(t: usize) -> &'static str {
TRACKS.get(t).map_or("", |t| t.0)
}
pub fn track_by_id(id: &str) -> Option<usize> {
if id.is_empty() {
return None;
}
TRACKS.iter().position(|t| t.0 == id)
}
// the song title stored in the XM module (without the "DungeonCraft - " prefix)
pub fn track_title(t: usize) -> String {
let Some(d) = TRACKS.get(t).map(|t| t.2) else { return String::new() };
let raw = d.get(17..37).unwrap_or(&[]);
let name: String = raw.iter().take_while(|&&b| b != 0).map(|&b| b as char).collect();
let name = name.trim().trim_start_matches("DungeonCraft - ").trim().to_string();
if name.is_empty() {
track_id(t).to_string()
} else {
name
}
}
const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects
const SFX_GAIN: f32 = 0.55; const SFX_GAIN: f32 = 0.55;
@@ -212,16 +245,135 @@ pub extern "C" fn dc_audio_render(frames: u32) -> *const f32 {
}) })
} }
// ------------------------------------------------------------------ desktop sound output
// The game renders a little ahead into a queue each frame; the sound card's thread takes from it.
// The engine itself stays on the game thread, like in the browser.
#[cfg(not(target_arch = "wasm32"))]
pub mod native {
use super::STATE;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::cell::RefCell;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
// seconds rendered ahead: covers a slow frame or two
const AHEAD: f32 = 0.07;
struct Out {
_stream: cpal::Stream,
queue: Arc<Mutex<VecDeque<f32>>>, // interleaved stereo
rate: f32,
}
thread_local! {
static OUT: RefCell<Option<Out>> = const { RefCell::new(None) };
}
// opens the default output device and starts the engine; without one the game stays silent
pub fn start() {
match open() {
Ok(out) => {
super::dc_audio_init(out.rate);
OUT.with(|o| *o.borrow_mut() = Some(out));
}
Err(e) => eprintln!("DungeonCraft: no sound ({})", e),
}
}
fn open() -> Result<Out, String> {
let dev = cpal::default_host().default_output_device().ok_or("no output device")?;
let cfg = dev.default_output_config().map_err(|e| e.to_string())?;
let rate = cfg.sample_rate() as f32;
let channels = cfg.channels() as usize;
let queue = Arc::new(Mutex::new(VecDeque::new()));
let sc: cpal::StreamConfig = cfg.config();
use cpal::SampleFormat as F;
let stream = match cfg.sample_format() {
F::F32 => build::<f32>(&dev, &sc, channels, queue.clone()),
F::I16 => build::<i16>(&dev, &sc, channels, queue.clone()),
F::U16 => build::<u16>(&dev, &sc, channels, queue.clone()),
F::I32 => build::<i32>(&dev, &sc, channels, queue.clone()),
f => Err(format!("sample format {:?}", f)),
}?;
stream.play().map_err(|e| e.to_string())?;
Ok(Out { _stream: stream, queue, rate })
}
fn build<T: cpal::SizedSample + cpal::FromSample<f32>>(dev: &cpal::Device, sc: &cpal::StreamConfig, channels: usize, queue: Arc<Mutex<VecDeque<f32>>>) -> Result<cpal::Stream, String> {
let channels = channels.max(1);
dev.build_output_stream(
sc.clone(),
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
let mut q = queue.lock().unwrap_or_else(|e| e.into_inner());
for frame in out.chunks_mut(channels) {
// an empty queue (a stalled frame) plays silence
let (l, r) = if q.len() >= 2 { (q.pop_front().unwrap_or(0.0), q.pop_front().unwrap_or(0.0)) } else { (0.0, 0.0) };
for (c, s) in frame.iter_mut().enumerate() {
let v = match (channels, c) {
(1, _) => (l + r) * 0.5,
(_, 0) => l,
(_, 1) => r,
_ => 0.0,
};
*s = T::from_sample(v);
}
}
},
|e| eprintln!("DungeonCraft: sound output: {}", e),
None,
)
.map_err(|e| e.to_string())
}
// called once per frame: tops the queue up to AHEAD seconds
pub fn pump() {
OUT.with(|o| {
let o = o.borrow();
let Some(o) = o.as_ref() else { return };
let want = (o.rate * AHEAD) as usize;
let have = o.queue.lock().unwrap_or_else(|e| e.into_inner()).len() / 2;
if have >= want {
return;
}
STATE.with(|s| {
let mut s = s.borrow_mut();
let (mv, sv, muted) = (s.music_vol, s.sfx_vol, s.muted);
if let Some(e) = s.engine.as_mut() {
e.render(want - have, mv, sv, muted);
o.queue.lock().unwrap_or_else(|e| e.into_inner()).extend(e.buf.iter().copied());
}
});
});
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn the_music_player_offers_only_calm_tracks_with_titles() {
let l = player_tracks();
assert!(l.len() > 20);
for t in l {
let id = track_id(t);
assert!(!id.starts_with("battle") && !id.starts_with("victory") && id != "defeat" && !id.starts_with("assault"), "{}", id);
assert!(!track_title(t).is_empty());
assert_eq!(track_by_id(id), Some(t));
}
assert_eq!(track_by_id(""), None);
assert_eq!(track_title(track_by_id("build13").unwrap()), "Underground River");
}
#[test] #[test]
fn engine_crossfade_and_sfx() { fn engine_crossfade_and_sfx() {
dc_audio_init(48000.0); dc_audio_init(48000.0);
let build = tracks_in(Category::Build); let build = tracks_in(Category::Build);
let battle = tracks_in(Category::Battle); let battle = tracks_in(Category::Battle);
assert_eq!(build.len(), 15); assert_eq!(build.len(), 19);
assert_eq!(tracks_in(Category::Surface).len(), 4);
assert_eq!(tracks_in(Category::SurfaceBattle).len(), 2);
play_music(build[0], 0.0); play_music(build[0], 0.0);
let mut peak = 0.0f32; let mut peak = 0.0f32;
for k in 0..200 { for k in 0..200 {
+929
View File
@@ -0,0 +1,929 @@
// Achievement badges: one 32x32 pixel-art symbol per achievement, generated at start into a
// texture of its own (the main atlas is full). Each symbol is painted from simple shapes in
// materials (steel, gold, wood, ...); every material has a ramp of four shades, and the shapes
// are bevelled automatically (lit from the top left) and get a dark outline.
use macroquad::prelude::*;
pub const SIZE: u32 = 32;
const COLS: u32 = 8;
// badge indices (the icon field of achievements::TIERS / SPECIAL)
pub const HEROES: u32 = 0;
pub const BOSSES: u32 = 1;
pub const DUG: u32 = 2;
pub const GOLD: u32 = 3;
pub const WOOD: u32 = 4;
pub const WALLS: u32 = 5;
pub const ROOMS: u32 = 6;
pub const SPELLS: u32 = 7;
pub const SLAPS: u32 = 8;
pub const TRAPS: u32 = 9;
pub const TOWERS: u32 = 10;
pub const DROWNED: u32 = 11;
pub const BURNT: u32 = 12;
pub const SIEGE: u32 = 13;
pub const CAPTURED: u32 = 14;
pub const DRAWBRIDGE: u32 = 15;
pub const WINS: u32 = 16;
pub const MISSIONS: u32 = 17;
pub const COOP: u32 = 18;
pub const MINUTES: u32 = 19;
pub const FLAWLESS: u32 = 20;
pub const SPEEDRUN: u32 = 21;
pub const CAMPAIGN: u32 = 22;
pub const SURFACE: u32 = 23;
pub const DEFEAT: u32 = 24;
pub const LEGEND: u32 = 25;
pub const WYRM: u32 = 26;
pub const FULLHAND: u32 = 27;
pub const COUNT: u32 = 28;
// the source rectangle of a badge in the badge texture
pub fn badge_rect(i: u32) -> Rect {
Rect::new(((i % COLS) * SIZE) as f32, ((i / COLS) * SIZE) as f32, SIZE as f32, SIZE as f32)
}
// ------------------------------------------------------------------ materials
const EMPTY: u8 = 0;
const STEEL: u8 = 1;
const GOLDM: u8 = 2;
const WOODM: u8 = 3;
const STONE: u8 = 4;
const RED: u8 = 5;
const FIRE: u8 = 6;
const WATER: u8 = 7;
const CRYSTAL: u8 = 8;
const GREEN: u8 = 9;
const BONE: u8 = 10;
const PURPLE: u8 = 11;
const IRON: u8 = 12; // dark iron, also slits and holes
const BRICK: u8 = 13;
const SKIN: u8 = 14; // imp green (the keeper's hand)
const DKWOOD: u8 = 15;
const GLASS: u8 = 16;
const CYAN: u8 = 17; // magic glow
const SUN: u8 = 18;
const BLUE: u8 = 19;
const SPARK: u8 = 20; // white sparkles and motion lines, no outline
const EMBER: u8 = 21; // floating embers and steam, no outline
const TEA: u8 = 22;
fn ramp(m: u8) -> [(u8, u8, u8); 4] {
match m {
STEEL => [(66, 72, 90), (116, 124, 144), (166, 174, 190), (222, 228, 238)],
GOLDM => [(124, 76, 22), (196, 138, 38), (238, 192, 70), (255, 240, 150)],
WOODM => [(78, 46, 26), (120, 76, 42), (156, 106, 62), (196, 146, 92)],
STONE => [(58, 54, 64), (90, 84, 96), (124, 118, 130), (160, 154, 166)],
RED => [(96, 18, 24), (156, 34, 36), (204, 62, 50), (240, 122, 92)],
FIRE => [(196, 54, 18), (240, 124, 30), (255, 192, 60), (255, 244, 170)],
WATER => [(22, 44, 96), (38, 84, 148), (66, 134, 198), (150, 200, 240)],
CRYSTAL => [(60, 34, 120), (110, 66, 196), (164, 120, 244), (226, 206, 255)],
GREEN => [(34, 66, 30), (58, 108, 42), (98, 150, 58), (152, 202, 92)],
BONE => [(120, 104, 84), (178, 160, 128), (216, 202, 170), (244, 236, 214)],
PURPLE => [(50, 26, 60), (88, 42, 100), (128, 68, 140), (176, 110, 182)],
IRON => [(22, 18, 24), (40, 34, 42), (62, 54, 64), (88, 80, 90)],
BRICK => [(88, 36, 30), (134, 60, 44), (170, 88, 62), (204, 126, 90)],
SKIN => [(56, 84, 44), (98, 138, 72), (140, 184, 102), (184, 222, 140)],
DKWOOD => [(50, 30, 20), (78, 48, 30), (104, 68, 42), (134, 94, 60)],
GLASS => [(92, 128, 150), (136, 176, 196), (180, 214, 228), (232, 246, 252)],
CYAN => [(30, 110, 150), (60, 180, 220), (130, 230, 250), (220, 255, 255)],
SUN => [(220, 120, 30), (250, 180, 50), (255, 222, 90), (255, 250, 200)],
BLUE => [(24, 30, 80), (40, 52, 128), (66, 84, 172), (110, 132, 214)],
SPARK => [(200, 200, 210), (236, 236, 244), (255, 255, 255), (255, 255, 255)],
EMBER => [(200, 80, 30), (255, 150, 50), (255, 210, 110), (255, 250, 220)],
TEA => [(70, 36, 20), (104, 58, 30), (134, 82, 44), (170, 112, 66)],
_ => [(0, 0, 0); 4],
}
}
fn outlined(m: u8) -> bool {
m != EMPTY && m != SPARK && m != EMBER
}
const OUTLINE: (u8, u8, u8) = (18, 12, 16);
const AUTO: u8 = 255;
#[derive(Clone, Copy)]
struct P {
m: u8,
s: u8, // shade 0..3, or AUTO (bevelled)
}
fn a(m: u8) -> P {
P { m, s: AUTO }
}
fn f(m: u8, s: u8) -> P {
P { m, s }
}
const CUT: P = P { m: EMPTY, s: AUTO };
// one badge being painted
struct Pad {
m: [u8; 1024],
s: [u8; 1024],
}
impl Pad {
fn new() -> Pad {
Pad { m: [EMPTY; 1024], s: [AUTO; 1024] }
}
fn px(&mut self, x: i32, y: i32, p: P) {
if (0..32).contains(&x) && (0..32).contains(&y) {
let i = (y * 32 + x) as usize;
self.m[i] = p.m;
self.s[i] = p.s;
}
}
fn get(&self, x: i32, y: i32) -> u8 {
if (0..32).contains(&x) && (0..32).contains(&y) {
self.m[(y * 32 + x) as usize]
} else {
EMPTY
}
}
// every pixel whose centre passes the test
fn fill(&mut self, p: P, test: impl Fn(f32, f32) -> bool) {
for y in 0..32 {
for x in 0..32 {
if test(x as f32 + 0.5, y as f32 + 0.5) {
self.px(x, y, p);
}
}
}
}
// inclusive pixel rectangle
fn rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, p: P) {
for y in y0..=y1 {
for x in x0..=x1 {
self.px(x, y, p);
}
}
}
fn disc(&mut self, cx: f32, cy: f32, r: f32, p: P) {
self.fill(p, |x, y| (x - cx).powi(2) + (y - cy).powi(2) <= r * r);
}
fn ring(&mut self, cx: f32, cy: f32, r0: f32, r1: f32, p: P) {
self.fill(p, |x, y| {
let d = (x - cx).powi(2) + (y - cy).powi(2);
d <= r1 * r1 && d >= r0 * r0
});
}
// ellipse turned by `ang` (radians)
fn oval(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, ang: f32, p: P) {
let (s, c) = ang.sin_cos();
self.fill(p, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let u = dx * c + dy * s;
let v = -dx * s + dy * c;
(u / rx).powi(2) + (v / ry).powi(2) <= 1.0
});
}
// a thick line (round ends)
fn line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, w: f32, p: P) {
let (dx, dy) = (x1 - x0, y1 - y0);
let len2 = (dx * dx + dy * dy).max(1e-6);
let r = w * 0.5;
self.fill(p, |x, y| {
let t = (((x - x0) * dx + (y - y0) * dy) / len2).clamp(0.0, 1.0);
let (qx, qy) = (x0 + t * dx - x, y0 + t * dy - y);
qx * qx + qy * qy <= r * r
});
}
// a line through several points
fn path(&mut self, pts: &[(f32, f32)], w: f32, p: P) {
for s in pts.windows(2) {
self.line(s[0].0, s[0].1, s[1].0, s[1].1, w, p);
}
}
fn poly(&mut self, pts: &[(f32, f32)], p: P) {
self.fill(p, |x, y| {
let mut inside = false;
let n = pts.len();
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = pts[i];
let (xj, yj) = pts[j];
if (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi {
inside = !inside;
}
j = i;
}
inside
});
}
// a five-pointed star
fn star(&mut self, cx: f32, cy: f32, r0: f32, r1: f32, p: P) {
let pts: Vec<(f32, f32)> = (0..10)
.map(|k| {
let ang = -std::f32::consts::FRAC_PI_2 + k as f32 * std::f32::consts::PI / 5.0;
let r = if k % 2 == 0 { r1 } else { r0 };
(cx + r * ang.cos(), cy + r * ang.sin())
})
.collect();
self.poly(&pts, p);
}
// a small four-pointed sparkle
fn sparkle(&mut self, x: i32, y: i32) {
let p = f(SPARK, 2);
self.px(x, y, p);
for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
self.px(x + dx, y + dy, f(SPARK, 0));
}
}
// bevel, outline and write into the image at cell `i`
fn blit(&self, img: &mut Image, i: u32) {
let (ox, oy) = ((i % COLS) * SIZE, (i / COLS) * SIZE);
for y in 0..32i32 {
for x in 0..32i32 {
let m = self.get(x, y);
let col = if m == EMPTY {
let near = [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| outlined(self.get(x + dx, y + dy)));
if !near {
continue;
}
OUTLINE
} else {
let mut s = self.s[(y * 32 + x) as usize];
if s == AUTO {
let lit = self.get(x - 1, y) != m || self.get(x, y - 1) != m;
let dark = self.get(x + 1, y) != m || self.get(x, y + 1) != m;
s = match (lit, dark) {
(true, false) => 2,
(false, true) => 0,
_ => 1,
};
// the top-left corner catches the light
if lit && !dark && self.get(x - 1, y) != m && self.get(x, y - 1) != m {
s = 3;
}
}
ramp(m)[s.min(3) as usize]
};
img.set_pixel(ox + x as u32, oy + y as u32, Color::from_rgba(col.0, col.1, col.2, 255));
}
}
}
}
// ------------------------------------------------------------------ the symbols
fn heroes(p: &mut Pad) {
// a knight's great helm, split by a blow
p.poly(&[(16.0, 1.5), (21.0, 4.0), (19.0, 7.0), (16.0, 6.0)], a(RED)); // plume
p.disc(16.0, 13.0, 8.5, a(STEEL));
p.rect(8, 13, 24, 25, a(STEEL));
p.rect(9, 14, 23, 15, f(IRON, 0)); // visor slit
p.line(16.0, 6.0, 16.0, 25.0, 1.0, f(STEEL, 0)); // ridge
for (x, y) in [(11, 19), (13, 21), (11, 22), (20, 19), (22, 21)] {
p.px(x, y, f(IRON, 1));
}
// the crack and the broken-off piece
p.poly(&[(19.0, 3.0), (26.0, 3.0), (26.0, 12.0), (21.0, 9.0)], CUT);
p.path(&[(21.0, 9.0), (18.0, 13.0), (20.0, 17.0), (17.5, 22.0)], 1.0, f(IRON, 0));
p.poly(&[(23.0, 26.0), (28.0, 24.0), (29.5, 29.5), (24.0, 29.5)], a(STEEL));
p.line(24.0, 26.5, 28.0, 25.0, 0.8, f(STEEL, 3));
p.px(27, 7, f(STEEL, 2));
p.px(29, 11, f(STEEL, 1));
p.sparkle(28, 18);
}
fn bosses(p: &mut Pad) {
// a skull wearing a cracked crown
p.disc(16.0, 19.0, 9.0, a(BONE));
p.rect(11, 24, 21, 29, a(BONE));
p.disc(12.0, 19.0, 2.6, f(IRON, 0));
p.disc(20.0, 19.0, 2.6, f(IRON, 0));
p.px(12, 19, f(RED, 3));
p.px(20, 19, f(RED, 3));
p.poly(&[(16.0, 21.5), (14.5, 24.5), (17.5, 24.5)], f(IRON, 0));
for x in [12, 14, 16, 18, 20] {
p.line(x as f32 + 0.5, 26.0, x as f32 + 0.5, 29.0, 0.8, f(BONE, 0));
}
p.poly(&[(6.0, 13.0), (6.0, 3.0), (10.5, 8.0), (16.0, 1.0), (21.5, 8.0), (26.0, 3.0), (26.0, 13.0)], a(GOLDM));
p.rect(6, 11, 25, 13, f(GOLDM, 0));
p.disc(16.0, 8.5, 1.8, a(RED));
p.disc(9.0, 10.0, 1.2, f(CRYSTAL, 2));
p.disc(23.0, 10.0, 1.2, f(CRYSTAL, 2));
p.path(&[(13.0, 3.0), (14.5, 7.0), (12.5, 10.0), (13.5, 13.5)], 1.2, CUT);
}
fn dug(p: &mut Pad) {
// a pickaxe struck into a rock
p.poly(&[(2.0, 30.0), (4.0, 21.0), (10.0, 16.0), (18.0, 17.0), (25.0, 20.0), (30.0, 30.0)], a(STONE));
p.path(&[(8.0, 22.0), (12.0, 25.0), (11.0, 29.0)], 1.0, f(STONE, 0));
p.path(&[(21.0, 23.0), (24.0, 27.0)], 1.0, f(STONE, 0));
p.rect(5, 26, 6, 27, a(GOLDM));
p.rect(16, 27, 17, 28, a(GOLDM));
p.px(26, 26, f(GOLDM, 2));
p.line(25.0, 5.0, 13.0, 23.0, 2.6, a(WOODM));
p.path(&[(10.0, 7.0), (16.0, 3.0), (23.0, 4.0)], 3.2, a(STEEL));
p.path(&[(23.0, 4.0), (28.0, 9.0), (29.0, 15.0)], 3.0, a(STEEL));
p.line(9.0, 7.5, 10.0, 7.0, 1.6, a(STEEL));
p.disc(24.0, 5.5, 2.4, a(IRON));
// chips flying
p.rect(5, 12, 6, 13, a(STONE));
p.rect(8, 9, 8, 10, a(STONE));
p.px(3, 16, f(STONE, 2));
}
fn gold(p: &mut Pad) {
// a pile of coins
p.poly(&[(3.0, 29.0), (7.0, 21.0), (13.0, 17.0), (20.0, 17.0), (26.0, 21.0), (29.0, 29.0)], a(GOLDM));
for (x, y) in [(8, 25), (14, 22), (20, 25), (24, 23), (11, 27), (17, 27)] {
p.oval(x as f32 + 0.5, y as f32, 2.6, 1.2, 0.0, f(GOLDM, 0));
p.oval(x as f32 + 0.5, y as f32 - 0.5, 2.0, 0.8, 0.0, f(GOLDM, 2));
}
// a standing coin
p.disc(12.0, 13.0, 7.0, a(GOLDM));
p.ring(12.0, 13.0, 4.6, 5.6, f(GOLDM, 0));
p.rect(11, 10, 12, 16, f(GOLDM, 0));
p.rect(10, 10, 13, 10, f(GOLDM, 0));
p.rect(10, 16, 13, 16, f(GOLDM, 0));
// another coin flying
p.oval(24.0, 9.0, 4.0, 4.0, 0.0, a(GOLDM));
p.ring(24.0, 9.0, 2.0, 2.9, f(GOLDM, 0));
p.sparkle(27, 16);
p.sparkle(5, 7);
}
fn wood(p: &mut Pad) {
// an axe buried in a log
p.rect(3, 18, 23, 27, a(WOODM));
for (x0, x1, y) in [(5, 12, 20), (9, 19, 23), (4, 9, 25), (14, 21, 26)] {
p.rect(x0, y, x1, y, f(WOODM, 0));
}
p.oval(24.5, 22.5, 4.0, 5.2, 0.0, f(BONE, 1));
p.ring(24.5, 22.5, 1.5, 2.3, f(WOODM, 2));
p.ring(24.5, 22.5, 3.2, 3.9, f(WOODM, 2));
p.px(24, 22, f(WOODM, 1));
p.line(5.0, 3.0, 15.0, 17.0, 2.6, a(DKWOOD));
p.poly(&[(10.0, 14.0), (17.0, 11.0), (20.5, 17.5), (13.0, 19.0)], a(STEEL));
p.line(12.0, 18.6, 20.0, 17.6, 1.0, f(STEEL, 3));
p.rect(9, 17, 21, 18, a(WOODM));
p.rect(12, 17, 19, 17, f(IRON, 0));
// chips
p.rect(23, 9, 24, 10, f(WOODM, 2));
p.rect(26, 13, 26, 14, f(WOODM, 2));
}
fn walls(p: &mut Pad) {
// a brick wall with battlements
p.rect(3, 9, 28, 28, f(IRON, 1));
for (x0, x1) in [(3, 8), (13, 18), (23, 28)] {
p.rect(x0, 3, x1, 8, f(IRON, 1));
}
for (row, y) in [8, 13, 18, 23].iter().enumerate() {
let off = if row % 2 == 0 { 0 } else { 4 };
let mut x = 3 - off;
while x <= 28 {
p.rect(x.max(3), y + 1, (x + 6).min(28), y + 4, a(BRICK));
x += 8;
}
}
for (x0, x1) in [(3, 8), (13, 18), (23, 28)] {
p.rect(x0, 4, x1, 7, a(BRICK));
}
p.rect(3, 9, 28, 9, f(IRON, 1));
// a crack and a loose brick
p.path(&[(19.0, 14.0), (17.0, 18.0), (19.0, 21.0)], 1.0, f(IRON, 0));
}
fn rooms(p: &mut Pad) {
// a stone arch with a barred wooden door
p.disc(16.0, 15.0, 13.0, a(STONE));
p.rect(3, 15, 28, 29, a(STONE));
p.disc(16.0, 15.0, 8.5, f(IRON, 0));
p.rect(8, 15, 23, 29, f(IRON, 0));
for k in 0..7 {
let ang = std::f32::consts::PI * (1.0 + k as f32 / 6.0);
p.line(16.0 + 9.0 * ang.cos(), 15.0 + 9.0 * ang.sin(), 16.0 + 13.0 * ang.cos(), 15.0 + 13.0 * ang.sin(), 0.9, f(STONE, 0));
}
p.rect(3, 22, 7, 22, f(STONE, 0));
p.rect(24, 22, 28, 22, f(STONE, 0));
p.disc(16.0, 15.5, 7.5, a(WOODM));
p.rect(9, 15, 22, 29, a(WOODM));
for x in [12, 16, 19] {
p.line(x as f32 + 0.5, 8.5, x as f32 + 0.5, 29.0, 0.9, f(WOODM, 0));
}
p.rect(9, 13, 22, 14, a(IRON));
p.rect(9, 23, 22, 24, a(IRON));
p.ring(20.0, 19.0, 1.2, 2.2, a(GOLDM));
p.rect(2, 29, 29, 30, a(STONE));
}
fn spells(p: &mut Pad) {
// an open spell book with a swirl of magic
p.poly(&[(2.0, 15.0), (16.0, 19.0), (30.0, 15.0), (30.0, 28.0), (16.0, 31.0), (2.0, 28.0)], a(PURPLE));
p.poly(&[(3.5, 13.0), (15.5, 17.0), (15.5, 28.5), (3.5, 25.5)], a(BONE));
p.poly(&[(16.5, 17.0), (28.5, 13.0), (28.5, 25.5), (16.5, 28.5)], a(BONE));
for k in 0..3 {
let y = 18.0 + k as f32 * 3.0;
p.line(5.5, y, 13.5, y + 2.4, 0.8, f(BONE, 0));
p.line(18.5, y + 2.4, 26.5, y, 0.8, f(BONE, 0));
}
p.px(5, 16, f(RED, 2));
p.px(6, 16, f(RED, 2));
// the swirl rising from the pages
let mut prev = (16.0, 15.0);
for k in 1..22 {
let t = k as f32 / 21.0;
let ang = t * 9.0;
let r = 1.0 + t * 6.5;
let pt = (16.0 + r * ang.cos(), 14.0 - t * 8.0 + r * 0.5 * ang.sin());
p.line(prev.0, prev.1, pt.0, pt.1, 1.0 + t * 1.2, f(CYAN, if t > 0.6 { 1 } else { 2 }));
prev = pt;
}
p.disc(16.0, 9.0, 1.4, f(CYAN, 3));
p.sparkle(5, 4);
p.sparkle(27, 5);
p.sparkle(26, 10);
}
fn slaps(p: &mut Pad) {
// the keeper's open hand, swinging
let tilt = 0.25f32;
let (s, c) = tilt.sin_cos();
let rot = |x: f32, y: f32| (19.0 + (x - 19.0) * c - (y - 19.0) * s, 19.0 + (x - 19.0) * s + (y - 19.0) * c);
let fingers = [(13.5, 6.5), (17.0, 4.5), (20.5, 5.0), (24.0, 8.0)];
for (x, y) in fingers {
let (x0, y0) = rot(x, 18.0);
let (x1, y1) = rot(x, y);
p.line(x0, y0, x1, y1, 3.2, a(SKIN));
p.px((x1 - 0.5) as i32, (y1 - 1.6) as i32, f(BONE, 2));
}
let (px0, py0) = rot(19.0, 20.0);
p.oval(px0, py0, 6.5, 7.0, tilt, a(SKIN));
let (tx0, ty0) = rot(13.5, 21.0);
let (tx1, ty1) = rot(8.5, 15.0);
p.line(tx0, ty0, tx1, ty1, 3.2, a(SKIN));
p.px((tx1 - 1.0) as i32, (ty1 - 1.0) as i32, f(BONE, 2));
let (wx0, wy0) = rot(17.0, 26.0);
let (wx1, wy1) = rot(17.0, 31.0);
p.line(wx0, wy0, wx1, wy1, 6.0, a(SKIN));
p.line(wx0 - 3.0, wy0 + 2.5, wx0 + 3.5, wy0 + 1.0, 1.6, a(PURPLE)); // sleeve cuff
p.line(15.5, 15.0, 19.5, 22.0, 0.8, f(SKIN, 0));
// motion lines
for (x0, y, x1) in [(1, 9, 6), (2, 14, 7), (1, 19, 5)] {
p.rect(x0, y, x1, y, f(SPARK, 1));
}
}
fn traps(p: &mut Pad) {
// spikes shooting from a floor slab, one of them bloodied
p.rect(2, 23, 29, 29, a(STONE));
p.rect(2, 26, 29, 26, f(STONE, 0));
p.rect(15, 23, 15, 26, f(STONE, 0));
p.rect(8, 26, 8, 29, f(STONE, 0));
p.rect(23, 26, 23, 29, f(STONE, 0));
for (cx, tip) in [(6.0, 11.0), (11.0, 7.0), (16.0, 3.0), (21.0, 7.0), (26.0, 11.0)] {
p.poly(&[(cx - 2.6, 23.0), (cx, tip), (cx + 2.6, 23.0)], a(STEEL));
p.line(cx - 0.6, tip + 3.0, cx - 1.6, 22.0, 0.8, f(STEEL, 3));
p.rect(cx as i32 - 3, 22, cx as i32 + 2, 23, f(IRON, 1));
}
p.poly(&[(14.6, 8.0), (16.0, 3.0), (17.4, 8.0)], a(RED));
p.rect(16, 9, 16, 11, f(RED, 1));
p.px(16, 13, f(RED, 2));
}
fn towers(p: &mut Pad) {
// a dark tower with a horned roof and a glowing window
p.poly(&[(9.0, 30.0), (11.5, 12.0), (20.5, 12.0), (23.0, 30.0)], a(IRON));
for y in [16, 21, 26] {
p.line(11.0, y as f32 + 0.5, 21.5, y as f32 + 0.5, 0.8, f(IRON, 0));
}
p.rect(8, 9, 23, 12, a(PURPLE));
for x in [8, 13, 18, 22] {
p.rect(x, 7, x + 1, 8, a(PURPLE));
}
p.poly(&[(9.0, 8.0), (16.0, 0.5), (23.0, 8.0)], a(IRON));
p.path(&[(8.5, 10.0), (5.0, 7.0), (4.0, 2.0)], 1.6, a(BONE));
p.path(&[(23.5, 10.0), (27.0, 7.0), (28.0, 2.0)], 1.6, a(BONE));
p.disc(16.0, 18.0, 2.2, f(FIRE, 1));
p.rect(14, 18, 18, 22, f(FIRE, 1));
p.rect(15, 17, 17, 21, f(FIRE, 2));
p.px(16, 18, f(FIRE, 3));
p.rect(14, 27, 18, 30, f(IRON, 0));
p.disc(16.0, 27.0, 2.4, f(IRON, 0));
p.rect(3, 30, 28, 30, a(STONE));
}
fn drowned(p: &mut Pad) {
// a helmet sinking between waves
p.poly(&[(17.0, 2.0), (22.0, 4.0), (20.0, 6.5), (16.5, 6.0)], a(RED));
p.disc(15.5, 14.0, 8.0, a(STEEL));
p.rect(9, 12, 21, 13, f(IRON, 0));
p.line(15.5, 6.0, 15.5, 20.0, 1.0, f(STEEL, 0));
p.fill(a(WATER), |x, y| y >= 18.0 + 1.6 * (x * 0.55).sin() && y < 31.0 && (1.0..31.0).contains(&x));
for (x0, y0) in [(4.0, 23.0), (14.0, 25.0), (22.0, 22.0), (9.0, 28.0), (20.0, 28.5)] {
p.line(x0, y0, x0 + 3.5, y0, 1.0, f(WATER, 3));
}
p.ring(25.0, 12.0, 1.0, 2.0, f(GLASS, 3));
p.ring(27.0, 6.0, 0.6, 1.5, f(GLASS, 3));
p.px(23, 2, f(GLASS, 3));
}
fn burnt(p: &mut Pad) {
// a burning bridge plank
p.poly(&[(2.0, 23.0), (28.0, 16.5), (30.0, 22.0), (4.0, 28.5)], a(WOODM));
p.line(5.0, 25.5, 28.0, 19.5, 0.8, f(WOODM, 0));
p.poly(&[(19.0, 18.8), (28.0, 16.5), (30.0, 22.0), (21.0, 24.3)], a(IRON));
p.rect(6, 23, 7, 24, f(IRON, 2));
p.rect(13, 21, 14, 22, f(IRON, 2));
for (x, y) in [(20, 20), (23, 21), (25, 19), (27, 20)] {
p.px(x, y, f(FIRE, 1));
}
// flames
p.poly(&[(14.0, 21.0), (13.0, 14.0), (16.0, 9.0), (17.0, 13.0), (20.0, 4.0), (22.0, 11.0), (24.5, 8.0), (25.5, 14.0), (28.0, 17.0)], f(FIRE, 0));
p.poly(&[(16.0, 20.0), (16.0, 15.0), (18.0, 12.5), (19.0, 15.0), (20.5, 9.0), (22.0, 14.0), (24.0, 12.0), (24.5, 16.0), (26.0, 18.0)], f(FIRE, 1));
p.poly(&[(18.5, 19.0), (19.0, 16.0), (20.5, 13.5), (21.5, 16.0), (23.0, 15.5), (23.5, 18.0)], f(FIRE, 2));
p.px(21, 17, f(FIRE, 3));
for (x, y) in [(11, 6), (14, 3), (26, 4), (29, 10), (8, 12)] {
p.px(x, y, f(EMBER, 1));
}
}
fn siege(p: &mut Pad) {
// a catapult with a boulder
p.line(13.0, 24.0, 17.0, 13.0, 2.0, a(DKWOOD));
p.line(21.0, 24.0, 17.0, 13.0, 2.0, a(DKWOOD));
p.line(6.0, 22.0, 24.0, 7.0, 2.2, a(WOODM));
p.oval(24.5, 6.5, 3.2, 2.0, -0.7, a(DKWOOD));
p.disc(25.0, 3.5, 2.8, a(STONE));
p.rect(3, 22, 28, 24, a(WOODM));
p.rect(3, 23, 28, 23, f(WOODM, 0));
p.disc(17.0, 13.0, 1.4, a(IRON));
p.line(6.0, 22.0, 10.0, 22.0, 1.0, f(IRON, 1)); // the rope
for cx in [8.0, 23.0] {
p.disc(cx, 25.5, 4.0, a(DKWOOD));
p.ring(cx, 25.5, 2.6, 3.3, f(DKWOOD, 0));
p.disc(cx, 25.5, 1.2, a(STEEL));
}
p.rect(1, 30, 30, 30, a(STONE));
}
fn captured(p: &mut Pad) {
// two iron shackles joined by a chain
p.ring(9.0, 20.0, 3.5, 6.5, a(STEEL));
p.ring(23.0, 20.0, 3.5, 6.5, a(STEEL));
p.rect(7, 13, 11, 15, a(IRON));
p.rect(21, 13, 25, 15, a(IRON));
p.disc(9.0, 24.5, 1.2, f(IRON, 0));
p.disc(23.0, 24.5, 1.2, f(IRON, 0));
// the chain, links turned alternately
let links = [(9.0, 11.0, true), (12.0, 7.5, false), (16.0, 6.0, true), (20.0, 7.5, false), (23.0, 11.0, true)];
for (x, y, upright) in links {
if upright {
p.oval(x, y, 1.6, 2.6, 0.0, a(STEEL));
p.rect(x as i32, y as i32 - 1, x as i32, y as i32, f(IRON, 0));
} else {
p.oval(x, y, 2.6, 1.6, 0.0, a(IRON));
p.rect(x as i32 - 1, y as i32, x as i32, y as i32, f(IRON, 0));
}
}
}
fn drawbridge(p: &mut Pad) {
// a gatehouse with its bridge hanging on chains above the moat
p.rect(14, 4, 29, 29, a(STONE));
for x in [14, 19, 24] {
p.rect(x, 1, x + 2, 3, a(STONE));
}
for y in [9, 15, 21] {
p.rect(14, y, 29, y, f(STONE, 0));
}
p.disc(22.0, 16.0, 4.5, f(IRON, 0));
p.rect(18, 16, 26, 28, f(IRON, 0));
for x in [19, 21, 23, 25] {
p.rect(x, 13, x, 22, f(IRON, 2));
}
p.fill(a(WATER), |x, y| y >= 26.5 + 0.8 * (x * 1.1).sin() && y < 31.0 && (1.0..18.0).contains(&x));
// the bridge, half raised
p.line(18.0, 27.0, 4.0, 17.0, 4.0, a(WOODM));
for k in 1..4 {
let t = k as f32 / 4.0;
let (x, y) = (18.0 - 14.0 * t, 27.0 - 10.0 * t);
p.line(x - 1.2, y - 1.7, x + 1.2, y + 1.7, 0.8, f(WOODM, 0));
}
p.disc(18.0, 27.0, 1.3, a(IRON));
for k in 0..6 {
let t = k as f32 / 5.0;
let (x, y) = (4.5 + 9.5 * t, 15.0 - 7.0 * t);
p.disc(x, y, 1.1, if k % 2 == 0 { a(STEEL) } else { a(IRON) });
}
}
fn wins(p: &mut Pad) {
// a banner planted in the dungeon heart
p.disc(10.5, 20.0, 6.0, a(RED));
p.disc(21.5, 20.0, 6.0, a(RED));
p.poly(&[(4.7, 21.5), (27.3, 21.5), (16.0, 31.0)], a(RED));
p.oval(9.0, 18.5, 2.0, 1.4, -0.6, f(RED, 3));
p.line(12.0, 2.0, 12.0, 21.0, 1.8, a(DKWOOD));
p.disc(12.0, 2.0, 1.3, a(GOLDM));
p.poly(&[(13.0, 3.5), (27.0, 4.5), (23.5, 8.0), (27.0, 11.5), (13.0, 12.5)], a(GOLDM));
p.line(14.5, 6.0, 21.5, 6.5, 0.8, f(GOLDM, 0));
p.line(14.5, 9.5, 21.5, 9.5, 0.8, f(GOLDM, 0));
}
fn missions(p: &mut Pad) {
// a crystal shard on a rock
p.poly(&[(5.0, 30.0), (8.0, 25.0), (14.0, 24.0), (22.0, 25.0), (27.0, 30.0)], a(STONE));
p.poly(&[(16.0, 1.5), (22.0, 9.0), (19.5, 27.0), (12.5, 27.0), (10.0, 9.0)], f(CRYSTAL, 1));
p.poly(&[(16.0, 1.5), (10.0, 9.0), (12.5, 27.0), (14.5, 27.0), (14.0, 9.0)], f(CRYSTAL, 2));
p.poly(&[(16.0, 1.5), (22.0, 9.0), (19.5, 27.0), (18.5, 27.0), (18.5, 9.0)], f(CRYSTAL, 0));
p.line(12.0, 6.5, 13.0, 18.0, 0.9, f(CRYSTAL, 3));
p.poly(&[(6.0, 14.0), (9.5, 18.0), (10.5, 27.0), (6.5, 27.0), (4.5, 19.0)], f(CRYSTAL, 1));
p.poly(&[(6.0, 14.0), (4.5, 19.0), (6.5, 27.0), (7.5, 27.0), (6.5, 18.0)], f(CRYSTAL, 2));
p.poly(&[(26.0, 16.0), (28.0, 21.0), (26.5, 27.0), (22.0, 27.0), (23.0, 20.0)], f(CRYSTAL, 0));
p.sparkle(24, 5);
p.sparkle(4, 8);
}
fn coop(p: &mut Pad) {
// two claws clasped: an imp's and a demon's
p.line(0.0, 25.0, 11.0, 19.5, 6.0, a(SKIN));
p.line(32.0, 25.0, 21.0, 19.5, 6.0, a(RED));
p.line(3.0, 21.0, 6.0, 27.5, 2.0, a(PURPLE)); // cuffs
p.line(29.0, 21.0, 26.0, 27.5, 2.0, a(IRON));
p.oval(19.5, 18.5, 5.5, 4.5, 0.0, a(RED));
p.oval(12.5, 18.5, 5.0, 4.8, 0.0, a(SKIN));
// the imp's fingers curl over the back of the demon's hand
for k in 0..4 {
let x = 15.0 + k as f32 * 2.4;
p.path(&[(x - 0.5, 15.0), (x + 1.0, 17.5), (x + 0.6, 21.0)], 2.0, a(SKIN));
p.px((x + 0.6) as i32, 22, f(BONE, 3));
}
// the demon's thumb over the imp's hand
p.line(19.0, 14.5, 12.0, 14.0, 2.2, a(RED));
p.px(10, 13, f(BONE, 3));
p.px(11, 13, f(BONE, 2));
p.sparkle(16, 6);
p.sparkle(8, 9);
p.sparkle(25, 10);
}
fn hourglass(p: &mut Pad, cx: f32, top: i32, bottom: i32, half: f32, frame: u8) {
let (t, b) = (top as f32, bottom as f32);
let mid = (t + b) * 0.5 + 0.5;
p.poly(&[(cx - half + 1.0, t + 2.0), (cx + half - 1.0, t + 2.0), (cx + 1.0, mid), (cx + half - 1.0, b - 1.0), (cx - half + 1.0, b - 1.0), (cx - 1.0, mid)], f(GLASS, 1));
p.poly(&[(cx - half + 2.0, t + 2.5), (cx - half + 3.0, t + 2.5), (cx - 0.5, mid - 1.0)], f(GLASS, 3));
// sand: a little left above, a heap below, the stream
p.poly(&[(cx - half * 0.45, mid - 4.0), (cx + half * 0.45, mid - 4.0), (cx, mid - 0.5)], f(SUN, 1));
p.poly(&[(cx - half + 1.5, b - 1.0), (cx, b - 6.0), (cx + half - 1.5, b - 1.0)], f(SUN, 1));
p.poly(&[(cx - 2.0, b - 3.5), (cx, b - 6.0), (cx + 0.5, b - 3.5)], f(SUN, 2));
p.line(cx, mid, cx, b - 5.0, 0.9, f(SUN, 2));
p.rect((cx - half) as i32, top, (cx + half) as i32 - 1, top + 1, a(frame));
p.rect((cx - half) as i32, bottom, (cx + half) as i32 - 1, bottom + 1, a(frame));
p.line(cx - half + 0.5, t + 1.0, cx - half + 0.5, b, 1.3, a(frame));
p.line(cx + half - 0.5, t + 1.0, cx + half - 0.5, b, 1.3, a(frame));
}
fn minutes(p: &mut Pad) {
// an hourglass with a candle beside it
hourglass(p, 13.0, 3, 27, 8.0, WOODM);
p.rect(23, 15, 27, 28, a(BONE));
p.rect(22, 28, 28, 29, a(IRON));
p.line(24.0, 17.0, 24.0, 21.0, 0.9, f(BONE, 3));
p.px(25, 14, f(IRON, 0));
p.poly(&[(23.5, 13.0), (25.0, 7.0), (26.6, 13.0)], f(FIRE, 1));
p.poly(&[(24.4, 13.0), (25.0, 9.5), (25.8, 13.0)], f(FIRE, 3));
}
fn flawless(p: &mut Pad) {
// a gleaming, untouched shield
let rim = [(5.0, 3.0), (27.0, 3.0), (27.0, 15.0), (24.0, 22.0), (16.0, 30.0), (8.0, 22.0), (5.0, 15.0)];
p.poly(&rim, a(GOLDM));
let field = [(7.5, 5.5), (24.5, 5.5), (24.5, 15.0), (22.0, 21.0), (16.0, 27.0), (10.0, 21.0), (7.5, 15.0)];
p.poly(&field, a(BLUE));
p.poly(&[(7.5, 5.5), (16.0, 5.5), (16.0, 27.0), (10.0, 21.0), (7.5, 15.0)], f(BLUE, 2));
p.rect(15, 7, 16, 23, a(GOLDM));
p.rect(10, 12, 21, 13, a(GOLDM));
p.disc(16.0, 12.5, 2.0, a(CRYSTAL));
p.line(9.0, 7.0, 12.0, 7.0, 0.9, f(BLUE, 3));
p.line(9.0, 7.0, 9.0, 10.0, 0.9, f(BLUE, 3));
p.sparkle(26, 4);
p.sparkle(4, 20);
p.px(28, 9, f(SPARK, 2));
}
fn speedrun(p: &mut Pad) {
// a winged hourglass
for side in [-1.0f32, 1.0] {
let x = |dx: f32| 16.0 + side * dx;
p.poly(&[(x(6.0), 10.0), (x(13.0), 4.0), (x(15.5), 6.0), (x(14.5), 10.0), (x(15.0), 13.0), (x(13.0), 17.0), (x(6.0), 19.0)], a(BONE));
for (dx, y) in [(10.0, 9.0), (11.0, 12.0), (10.0, 15.0)] {
p.line(x(6.5), y + 1.5, x(dx + 4.0), y, 0.8, f(BONE, 0));
}
}
hourglass(p, 16.0, 4, 27, 6.0, GOLDM);
p.path(&[(27.0, 20.0), (25.0, 24.0), (28.0, 24.0), (25.5, 29.5)], 1.2, f(SUN, 2));
p.path(&[(6.0, 20.0), (4.0, 24.0), (7.0, 24.0), (4.5, 29.5)], 1.2, f(SUN, 2));
}
fn campaign(p: &mut Pad) {
// the crown with the core made whole at its heart
p.poly(&[(4.0, 22.0), (3.0, 8.0), (10.0, 15.0), (16.0, 5.0), (22.0, 15.0), (29.0, 8.0), (28.0, 22.0)], a(GOLDM));
p.rect(4, 21, 27, 27, a(GOLDM));
p.rect(4, 22, 27, 22, f(GOLDM, 0));
p.rect(4, 26, 27, 26, f(GOLDM, 0));
for (x, y) in [(3.0, 7.0), (16.0, 3.5), (29.0, 7.0)] {
p.disc(x, y, 1.8, a(GOLDM));
}
p.disc(8.5, 24.0, 1.3, a(RED));
p.disc(23.5, 24.0, 1.3, a(RED));
// the core
p.poly(&[(16.0, 11.0), (20.5, 17.5), (16.0, 26.0), (11.5, 17.5)], f(CRYSTAL, 1));
p.poly(&[(16.0, 11.0), (11.5, 17.5), (16.0, 26.0), (15.0, 17.5)], f(CRYSTAL, 2));
p.px(14, 15, f(CRYSTAL, 3));
p.px(14, 16, f(CRYSTAL, 3));
p.sparkle(26, 14);
p.sparkle(7, 13);
p.sparkle(16, 0);
}
fn surface(p: &mut Pad) {
// the sun rising over grassy hills
for k in 0..8 {
let ang = k as f32 * std::f32::consts::PI / 4.0;
p.line(16.0 + 9.0 * ang.cos(), 14.0 + 9.0 * ang.sin(), 16.0 + 13.0 * ang.cos(), 14.0 + 13.0 * ang.sin(), 2.0, f(SUN, 1));
}
p.disc(16.0, 14.0, 7.0, f(SUN, 2));
p.disc(14.5, 12.5, 4.0, f(SUN, 3));
p.fill(a(GREEN), |x, y| y >= 21.0 + 2.5 * (x * 0.22 + 0.4).sin() && y < 31.0 && (1.0..31.0).contains(&x));
for x in [3, 7, 12, 19, 24, 28] {
let base = 21.0 + 2.5 * ((x as f32 + 0.5) * 0.22 + 0.4).sin();
p.line(x as f32 + 0.5, base, x as f32 - 0.5, base - 3.0, 1.0, f(GREEN, 2));
}
p.px(10, 26, f(RED, 2));
p.px(22, 27, f(SUN, 2));
p.px(16, 28, f(SPARK, 1));
}
fn defeat(p: &mut Pad) {
// a cup of tea, to soothe the defeat
p.oval(16.0, 27.0, 14.0, 2.8, 0.0, a(BONE));
p.oval(16.0, 26.5, 9.0, 1.5, 0.0, f(BONE, 0));
p.ring(24.5, 18.5, 2.0, 4.0, a(BONE));
p.poly(&[(6.0, 13.0), (26.0, 13.0), (25.0, 20.0), (21.0, 26.0), (11.0, 26.0), (7.0, 20.0)], a(BONE));
p.rect(7, 16, 25, 17, f(BLUE, 2));
p.px(12, 16, f(BLUE, 0));
p.px(16, 17, f(BLUE, 0));
p.px(20, 16, f(BLUE, 0));
p.oval(16.0, 13.5, 9.5, 1.8, 0.0, f(TEA, 1));
p.oval(14.0, 13.0, 3.0, 0.6, 0.0, f(TEA, 3));
// steam
for x0 in [11.0f32, 16.0, 21.0] {
let mut prev = (x0, 10.0);
for k in 1..6 {
let y = 10.0 - k as f32 * 1.5;
let pt = (x0 + (k as f32 * 1.4).sin() * 1.3, y);
p.line(prev.0, prev.1, pt.0, pt.1, 0.9, f(EMBER, 3));
prev = pt;
}
}
}
fn legend(p: &mut Pad) {
// a star crowned with laurels
for side in [-1.0f32, 1.0] {
let mut prev = (16.0 + side * 3.0, 29.0);
for k in 0..7 {
let ang = std::f32::consts::FRAC_PI_2 + side * (0.35 + k as f32 * 0.33);
let pt = (16.0 + 12.0 * ang.cos(), 16.0 + 12.0 * ang.sin());
p.line(prev.0, prev.1, pt.0, pt.1, 1.0, f(GREEN, 0));
let out = (16.0 + 14.2 * ang.cos(), 16.0 + 14.2 * ang.sin());
let inn = (16.0 + 10.0 * ang.cos(), 16.0 + 10.0 * ang.sin());
p.oval(out.0, out.1, 2.2, 1.1, ang + side * 0.6, a(GREEN));
if k < 6 {
p.oval(inn.0, inn.1, 2.0, 1.0, ang - side * 0.6, a(GREEN));
}
prev = pt;
}
}
p.line(13.0, 29.0, 19.0, 29.0, 1.4, a(RED));
p.star(16.0, 15.5, 4.2, 10.0, a(GOLDM));
p.star(16.0, 15.5, 1.8, 4.0, f(GOLDM, 3));
}
fn wyrm(p: &mut Pad) {
// the ember wyrm's head
p.path(&[(10.0, 9.0), (6.0, 5.0), (2.0, 4.0)], 2.2, a(BONE));
p.path(&[(13.0, 8.0), (11.0, 3.0), (7.0, 0.5)], 2.0, a(BONE));
// neck and spines
p.poly(&[(2.0, 31.0), (4.0, 18.0), (9.0, 10.0), (14.0, 22.0), (12.0, 31.0)], a(RED));
for (x, y) in [(3.0, 22.0), (3.5, 27.0), (5.5, 16.0)] {
p.poly(&[(x, y), (x - 3.0, y + 0.5), (x + 0.5, y + 3.0)], a(FIRE));
}
// the head, snout to the right, jaw open
p.poly(&[(6.0, 12.0), (12.0, 7.0), (21.0, 9.0), (29.0, 12.0), (29.5, 15.0), (19.0, 16.5), (12.0, 20.0)], a(RED));
p.poly(&[(12.0, 19.0), (19.0, 18.0), (27.0, 21.0), (26.0, 23.5), (16.0, 24.0), (10.0, 22.0)], a(RED));
p.poly(&[(19.0, 16.4), (29.0, 15.0), (27.0, 20.5), (19.0, 18.0)], f(IRON, 0));
for x in [21, 24, 27] {
p.px(x, 16, f(BONE, 3));
p.px(x - 1, 19, f(BONE, 3));
}
p.oval(16.0, 11.5, 2.4, 1.4, 0.2, f(SUN, 2));
p.rect(16, 11, 16, 12, f(IRON, 0));
p.line(13.0, 9.6, 19.0, 10.0, 0.9, f(RED, 0));
p.px(27, 11, f(IRON, 0));
p.line(16.0, 22.0, 25.0, 21.5, 0.8, f(RED, 3));
// embers from the jaws
for (x, y) in [(30, 18), (28, 25), (30, 27), (24, 27), (29, 22)] {
p.px(x, y, f(EMBER, 1));
}
}
fn fullhand(p: &mut Pad) {
// the keeper's hand, full of creatures
// horns and ears behind the heads
p.poly(&[(12.5, 7.5), (11.0, 2.5), (14.5, 6.0)], a(BONE));
p.poly(&[(19.5, 7.5), (21.0, 2.5), (17.5, 6.0)], a(BONE));
p.poly(&[(6.0, 14.0), (3.5, 10.0), (8.0, 12.5)], a(GREEN));
p.poly(&[(26.0, 14.0), (28.5, 10.0), (24.0, 12.5)], a(PURPLE));
let heads: [(f32, f32, u8); 6] = [(16.0, 10.0, RED), (9.0, 16.5, GREEN), (23.0, 16.5, PURPLE), (16.0, 16.0, BONE), (10.5, 22.0, STEEL), (21.5, 22.0, BRICK)];
for (x, y, m) in heads {
p.disc(x, y, 4.3, f(IRON, 0));
p.disc(x, y, 3.6, a(m));
let (ex, ey) = (x as i32, y as i32);
p.px(ex - 2, ey, f(SUN, 2));
p.px(ex + 1, ey, f(SUN, 2));
p.px(ex - 1, ey + 2, f(IRON, 0));
p.px(ex, ey + 2, f(IRON, 0));
}
// the cupped hand below
p.poly(&[(2.0, 22.0), (6.0, 25.0), (26.0, 25.0), (30.0, 21.0), (29.0, 27.0), (22.0, 30.5), (9.0, 30.5), (3.0, 27.0)], a(SKIN));
p.line(30.0, 21.0, 29.0, 15.0, 3.0, a(SKIN));
p.px(28, 13, f(BONE, 3));
p.line(2.0, 22.0, 3.0, 17.0, 3.0, a(SKIN));
p.px(3, 15, f(BONE, 3));
p.line(8.0, 27.5, 24.0, 27.5, 0.8, f(SKIN, 0));
}
// every badge in order of its index
pub fn gen_badges() -> Image {
let rows = (COUNT + COLS - 1) / COLS;
let mut img = Image::gen_image_color((COLS * SIZE) as u16, (rows * SIZE) as u16, Color::new(0.0, 0.0, 0.0, 0.0));
let painters: [fn(&mut Pad); COUNT as usize] = [
heroes, bosses, dug, gold, wood, walls, rooms, spells, slaps, traps, towers, drowned, burnt, siege, captured, drawbridge, wins, missions, coop, minutes, flawless, speedrun, campaign, surface, defeat, legend, wyrm, fullhand,
];
for (i, paint) in painters.iter().enumerate() {
let mut p = Pad::new();
paint(&mut p);
p.blit(&mut img, i as u32);
}
img
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_badge_is_drawn() {
let img = gen_badges();
for i in 0..COUNT {
let r = badge_rect(i);
let mut opaque = 0;
for y in 0..SIZE {
for x in 0..SIZE {
if img.get_pixel(r.x as u32 + x, r.y as u32 + y).a > 0.5 {
opaque += 1;
}
}
}
assert!(opaque > 150, "badge {} has only {} pixels", i, opaque);
}
}
#[test]
#[ignore]
fn export_badges_png() {
// look at the art: cargo test export_badges_png -- --ignored
let img = gen_badges();
let mut big = Image::gen_image_color(img.width * 4, img.height * 4, Color::new(0.1, 0.07, 0.07, 1.0));
for y in 0..img.height as u32 * 4 {
for x in 0..img.width as u32 * 4 {
let c = img.get_pixel(x / 4, img.height as u32 - 1 - y / 4); // export_png flips
if c.a > 0.0 {
big.set_pixel(x, y, c);
}
}
}
big.export_png("/tmp/badges.png");
}
}
+249
View File
@@ -0,0 +1,249 @@
// Automated balancing: whole games played by the AI brain on every side, headless and
// deterministic, many seeds each. Run with
// cargo test --release balance_ -- --ignored --nocapture --test-threads=4
// and read the tables (docs/BALANCE.md keeps the findings).
#![cfg(test)]
use crate::game::*;
use crate::rival::{Rival, PARTNER_GIFT_EVERY, RIVAL_CORE_HP};
const STEP: f32 = 0.05;
// the master on side 0 gets the same brain as the rivals
fn pilot(g: &mut Game, difficulty: u8, focus: Option<u8>) {
let core = g.map.core;
// like every AI master: the 9x9 starting area and a portal of its own beside it
g.carve_master_area(0, core);
g.add_master_portal(0, core);
g.rivals.push(Rival { side: 0, core, hp: RIVAL_CORE_HP, alive: true, radius: 5, grow_t: 0.0, spawn_t: 0.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY, think_t: 1.0, attacking: false, army0: 0.0, focus, defending: false });
}
// the other corners of a square map for up to three more masters (centre of the core)
fn corners(w: i32, h: i32) -> [(i32, i32); 3] {
[(w - 29, 13), (w - 29, h - 15), (15, h - 15)]
}
#[derive(Clone, Debug, Default)]
struct Outcome {
out_at: Vec<(u8, f32)>, // side, minute it fell
score: Vec<(u8, f32)>, // side, strength at the end (alive ones)
winner: Option<u8>,
minutes: f32,
}
fn alive(g: &Game, side: u8) -> bool {
g.core_hp_of(side) > 0.0 && g.rivals.iter().any(|r| r.side == side && r.alive)
}
fn score(g: &Game, side: u8) -> f32 {
g.core_hp_of(side) * 2.0 + g.side_army(side) + g.gold_of(side) * 0.2
}
// plays until one side is left or the time is up; side 0 falling does not stop it
fn play(g: &mut Game, sides: &[u8], max_min: f32) -> Outcome {
let mut o = Outcome::default();
let steps = (max_min * 60.0 / STEP) as usize;
for k in 0..steps {
g.update(STEP);
if g.state != 0 {
g.state = 0; // the harness decides the end
}
if k % 20 == 0 {
for &s in sides {
if !o.out_at.iter().any(|x| x.0 == s) && !alive(g, s) {
o.out_at.push((s, g.time / 60.0));
}
}
let left: Vec<u8> = sides.iter().copied().filter(|&s| !o.out_at.iter().any(|x| x.0 == s)).collect();
if left.len() <= 1 {
o.winner = left.first().copied();
break;
}
}
}
o.minutes = g.time / 60.0;
o.score = sides.iter().copied().filter(|&s| !o.out_at.iter().any(|x| x.0 == s)).map(|s| (s, score(g, s))).collect();
if o.winner.is_none() {
o.winner = o.score.iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|x| x.0);
}
o
}
fn arena(seed: u32, size: usize) -> Game {
let mut g = Game::new(seed, size, size);
g.gate_on = false; // no hero waves: masters against each other
g.ai_difficulty = 1;
g
}
fn seeds() -> Vec<u32> {
let n: u32 = std::env::var("BALANCE_SEEDS").ok().and_then(|s| s.parse().ok()).unwrap_or(6);
(1..=n).map(|k| 1000 + k * 37).collect()
}
// ---------------------------------------------------------------- difficulty duels
#[test]
#[ignore]
fn balance_difficulty() {
println!("\n== difficulty duels (2 masters, 112x112, no waves, 45 min) ==");
println!("{:<14} {:>5} {:>5} {:>5} {:>8}", "pair", "A", "B", "draw", "min");
// BALANCE_PAIR=a,b runs one pairing only
let pairs: Vec<(u8, u8)> = match std::env::var("BALANCE_PAIR").ok().and_then(|p| p.split_once(',').map(|(a, b)| (a.parse().ok(), b.parse().ok()))) {
Some((Some(a), Some(b))) => vec![(a, b)],
_ => vec![(0, 1), (1, 2), (0, 2), (1, 1)],
};
for (a, b) in pairs {
let (mut wa, mut wb, mut dr, mut mins) = (0, 0, 0, 0.0);
for (k, seed) in seeds().into_iter().enumerate() {
let mut g = arena(seed, 112);
// swap who plays which side every other seed (side 0 has the map's own start)
let (d0, d2) = if k % 2 == 0 { (a, b) } else { (b, a) };
pilot(&mut g, d0, None);
let (x, y) = corners(112, 112)[k % 3];
g.add_rival(2, x, y, d2);
let o = play(&mut g, &[0, 2], 45.0);
mins += o.minutes;
let a_side = if k % 2 == 0 { 0 } else { 2 };
let decisive = o.out_at.len() == 1;
match o.winner {
Some(w) if decisive && w == a_side => wa += 1,
Some(_) if decisive => wb += 1,
_ => dr += 1,
}
}
let n = seeds().len() as f32;
println!("{:<14} {:>5} {:>5} {:>5} {:>8.1}", format!("{} vs {}", a, b), wa, wb, dr, mins / n);
}
}
// ---------------------------------------------------------------- talent branches
#[test]
#[ignore]
fn balance_talents() {
use crate::talents::*;
println!("\n== talent tournament (4 masters, medium, 128x128, no waves, 50 min) ==");
let names = ["builder", "warlord", "warlock", "merchant"];
let mut wins = [0usize; 4];
let mut place_sum = [0.0f32; 4];
let mut lv_sum = [0.0f32; 4];
let sides = [0u8, 2, 3, 4];
for (k, seed) in seeds().into_iter().enumerate() {
let mut g = arena(seed, 128);
// the branches move round the four starts from seed to seed
let branch_of = |s: usize| ((s + k) % 4) as u8;
pilot(&mut g, 1, Some(branch_of(0)));
for (n, &(x, y)) in corners(128, 128).iter().enumerate() {
g.add_rival(sides[n + 1], x, y, 1);
g.rivals.last_mut().unwrap().focus = Some(branch_of(n + 1));
}
let o = play(&mut g, &sides, 50.0);
// placement: the winner 1st, the survivors by score, the fallen by how long they lasted
let mut order: Vec<u8> = Vec::new();
let mut alive_sorted = o.score.clone();
alive_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
order.extend(alive_sorted.iter().map(|x| x.0));
let mut fallen = o.out_at.clone();
fallen.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
order.extend(fallen.iter().map(|x| x.0));
for (place, &s) in order.iter().enumerate() {
let n = sides.iter().position(|&x| x == s).unwrap();
let b = branch_of(n) as usize;
place_sum[b] += place as f32 + 1.0;
if place == 0 {
wins[b] += 1;
}
lv_sum[b] += g.talents_of(s).lv[b] as f32;
}
let _ = BRANCHES;
}
let n = seeds().len() as f32;
println!("{:<10} {:>5} {:>10} {:>10}", "branch", "wins", "avg place", "avg level");
for b in 0..4 {
println!("{:<10} {:>5} {:>10.2} {:>10.1}", names[b], wins[b], place_sum[b] / n, lv_sum[b] / n);
}
}
// ---------------------------------------------------------------- one master against the waves
#[test]
#[ignore]
fn balance_waves() {
println!("\n== one AI master against the hero waves (96x96, 40 min) ==");
println!("{:<6} {:>8} {:>6} {:>7} {:>7} {:>7} {:>6}", "diff", "fell@min", "wave", "heroes", "lost", "gold", "army");
for d in 0..3u8 {
for seed in seeds() {
let mut g = Game::new(seed, 96, 96);
g.ai_difficulty = d;
pilot(&mut g, d, None);
let mut fell = None;
for k in 0..((40.0 * 60.0 / STEP) as usize) {
g.update(STEP);
if g.state != 0 {
if g.core_hp <= 0.0 {
fell = Some(g.time / 60.0);
break;
}
g.state = 0;
}
if std::env::var("BALANCE_TRACE").is_ok() && k % (120.0 / STEP) as usize == 0 {
let rooms: usize = g.counts.iter().sum();
let portal = (0..g.map.portals.len()).any(|p| g.map.portal_owner(p) == crate::map::Owner::Player);
println!(" seed {} d{} min {:>4.1}: creatures {:>3} imps {:>2} gold {:>6} rooms {:>3} lair {:>3} farm {:>3} wave {:>2} core {:>5} portal {} heroes near {}",
seed, d, g.time / 60.0, g.creature_count(), g.worker_count(), g.gold as i32, rooms, g.counts[1], g.counts[2], g.wave, g.core_hp as i32, portal,
g.ents.iter().filter(|e| e.alive && e.active_hero()).count());
}
}
println!(
"{:<6} {:>8} {:>6} {:>7} {:>7} {:>7} {:>6}",
d,
fell.map_or("-".to_string(), |m| format!("{:.1}", m)),
g.wave,
g.stats.get(crate::stats::HEROES) as i32,
g.stats.get(crate::stats::LOST) as i32,
g.gold as i32,
g.side_army(0) as i32
);
}
}
}
// where the heroes of a wave really break in, against the marked entry points
#[test]
#[ignore]
fn balance_entry_points() {
use crate::map::*;
println!("\n== planned entry vs. where the heroes step onto the master's ground ==");
for seed in seeds() {
let mut g = Game::new(seed, 96, 96);
pilot(&mut g, 1, None);
let mut planned: Vec<(i32, i32)> = Vec::new();
let mut first_in: Option<(i32, i32)> = None;
let mut wave = 0;
for _ in 0..((20.0 * 60.0 / STEP) as usize) {
g.update(STEP);
if g.state != 0 {
break;
}
if !g.planned_entries.is_empty() {
planned = g.planned_entries.clone();
}
if g.wave != wave {
wave = g.wave;
first_in = None;
}
if first_in.is_none() {
if let Some(e) = g.ents.iter().find(|e| e.alive && e.active_hero() && g.map.get(e.x as i32, e.y as i32).map_or(false, |t| t.owner == Owner::Player)) {
first_in = Some((e.x as i32, e.y as i32));
let d = planned.iter().map(|&(px, py)| (((px - e.x as i32).pow(2) + (py - e.y as i32).pow(2)) as f32).sqrt()).fold(f32::MAX, f32::min);
let wp = g.ents.iter().filter(|e| e.alive && e.active_hero()).filter(|e| e.waypoint.is_some()).count();
let heroes = g.ents.iter().filter(|e| e.alive && e.active_hero()).count();
let job = format!("{:?}", e.job);
println!("seed {} wave {}: planned {:?} first in at {:?} -> {:.1} tiles off; heroes with waypoint {}/{}; its job {}", seed, wave, planned, first_in.unwrap(), d, wp, heroes, job);
}
}
}
}
}
+113
View File
@@ -0,0 +1,113 @@
// Wild beasts: cave bears and giant beetles live in the open caverns of random maps. They defend
// their den like the dwarves defend their cave (they never stray far) and count for nobody. A
// master with room in a prison tames a beast he beats: instead of dying it gives in and joins.
use crate::game::*;
use crate::map::*;
pub fn beast(k: EK) -> bool {
matches!(k, EK::CaveBear | EK::Beetle)
}
impl Game {
// a few dens in the caverns of a random map (deterministic, from the seed)
pub fn place_beasts(&mut self, seed: u32) {
let mut rng = Rng(seed.wrapping_mul(0x85eb_ca6b) ^ 0x27d4_eb2f | 1);
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
let area = (mw() * mh()) as f32;
// none on small maps (no room), one per ~95x95 tiles on bigger ones
let dens = (area / 9000.0) as usize;
let open: Vec<usize> = (0..self.map.t.len())
.filter(|&i| {
let (x, y) = xy(i);
let t = &self.map.t[i];
t.kind == Kind::Floor && t.owner == Owner::None && !self.map.sky(i) && (x - cx).abs().max((y - cy).abs()) > 18
&& DIR8.iter().all(|&(dx, dy)| self.map.walk(x + dx, y + dy))
&& !self.dwarf_caves.iter().any(|c| (c.at.0 - x).abs() + (c.at.1 - y).abs() < 12)
})
.collect();
if open.is_empty() {
return;
}
let mut placed: Vec<(i32, i32)> = Vec::new();
for _ in 0..dens * 6 {
if placed.len() >= dens {
break;
}
let i = open[(rng.next() as usize) % open.len()];
let (x, y) = xy(i);
if placed.iter().any(|&(px, py)| (px - x).abs() + (py - y).abs() < 20) {
continue;
}
placed.push((x, y));
let kind = if rng.f() < 0.5 { EK::CaveBear } else { EK::Beetle };
let n = 1 + (rng.next() % 2) as usize;
for k in 0..n {
let level = 2 + rng.next() % 3;
let id = self.spawn(kind, false, x as f32 + 0.5 + k as f32 * 0.6, y as f32 + 0.5, level);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.side = crate::dwarves::DWARF_SIDE;
e.home = Some((x as f32 + 0.5, y as f32 + 0.5));
e.settled = true;
}
}
}
}
// a beaten wild beast: with room in a prison it is tamed instead of killed (true)
pub(crate) fn try_tame(&mut self, j: usize) -> bool {
let e = &self.ents[j];
if !beast(e.kind) || e.side != crate::dwarves::DWARF_SIDE || self.prison_space() == 0 {
return false;
}
let side = self.me_side;
let e = &mut self.ents[j];
e.side = side;
e.home = None;
e.guard = false;
e.hp = e.max_hp * 0.4;
e.happy = 0.6;
e.hunger = 0.5;
e.energy = 0.5;
e.job = Job::None;
e.path.clear();
e.bed = None;
e.settled = false;
let (x, y, kind) = (e.x, e.y, e.kind);
self.fx(7, x, y, 0.0, 0.0, 1.0, 0xff40c0ff);
self.sfx(Sfx::Convert, x, y);
self.msg("msg.beast_tamed", vec![format!("@{}", st(kind).name)]);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn beasts_live_in_caverns_and_can_be_tamed() {
let g0 = Game::new(9, 128, 96);
let beasts: Vec<&Ent> = g0.ents.iter().filter(|e| e.alive && beast(e.kind)).collect();
assert!(!beasts.is_empty(), "a den on a bigger map");
assert!(beasts.iter().all(|e| e.dwarf() && e.home.is_some()), "neutral, at home");
let mut g = g0;
let j = g.ents.iter().position(|e| e.alive && beast(e.kind)).unwrap();
// without a prison the beast dies
assert!(!g.try_tame(j));
// a prison: the beaten beast joins
let (cx, cy) = g.map.core;
g.build_rect(9, cx - 6, cy - 6, cx - 3, cy - 4);
g.gold = 99999.0;
let t = &mut g.map.t[idx(cx - 5, cy - 5)];
if t.kind != Kind::Prison {
t.kind = Kind::Prison;
t.owner = Owner::Player;
}
g.recount();
assert!(g.prison_space() > 0);
g.ents[j].hp = 0.0;
g.kill(j);
assert!(g.ents[j].alive && g.ents[j].side == 0 && !g.ents[j].dwarf(), "tamed");
}
}
+28 -7
View File
@@ -10,13 +10,13 @@ use serde::{Deserialize, Serialize};
pub const SIDES: [i32; 6] = [-1, 0, 1, 2, 3, 4]; pub const SIDES: [i32; 6] = [-1, 0, 1, 2, 3, 4];
// creature and hero kinds a block may name ("" = any) // creature and hero kinds a block may name ("" = any)
pub const KINDS: [&str; 22] = [ pub const KINDS: [&str; 30] = [
"Worker", "Kobold", "Spider", "Mage", "Ogre", "Jailer", "Priestess", "Chainbreaker", "Bat", "Gargoyle", "Owl", "Wyrm", "Cogling", "Revenant", "Knight", "Archer", "Priest", "Dwarf", "Paladin", "Giant", "Fairy", "Worker", "Kobold", "Spider", "Mage", "Ogre", "Jailer", "Priestess", "Chainbreaker", "Bat", "Gargoyle", "Owl", "Wyrm", "Cogling", "Revenant", "Knight", "Archer", "Priest", "Dwarf", "Paladin", "Giant", "Fairy",
"BattleMage", "BattleMage", "Ram", "Catapult", "CaveBear", "Beetle", "Thief", "Monk", "Ranger", "Inquisitor",
]; ];
// ground a Terrain block can make // ground a Terrain block can make
pub const TERRAIN: [&str; 6] = ["floor", "earth", "rock", "water", "lava", "gold"]; pub const TERRAIN: [&str; 7] = ["floor", "earth", "rock", "water", "lava", "gold", "grove"];
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "b")] #[serde(tag = "b")]
@@ -42,6 +42,8 @@ pub enum Block {
Terrain { zone: String, kind: String }, Terrain { zone: String, kind: String },
Kill { zone: String, side: i32 }, Kill { zone: String, side: i32 },
Trigger { name: String, on: bool }, Trigger { name: String, on: bool },
Weather { kind: String, secs: Val }, // the weather above ground (secs 0: until changed again)
TalentXp { amount: Val }, // experience for the player's talents
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -70,6 +72,7 @@ pub enum Val {
Or { a: Box<Val>, b: Box<Val> }, Or { a: Box<Val>, b: Box<Val> },
Not { a: Box<Val> }, Not { a: Box<Val> },
EventKind { kind: String }, // the unit that set off the trigger is of this kind EventKind { kind: String }, // the unit that set off the trigger is of this kind
WeatherIs { kind: String }, // the weather above ground is ...
} }
pub const MATH_OPS: [&str; 4] = ["+", "-", "*", "/"]; pub const MATH_OPS: [&str; 4] = ["+", "-", "*", "/"];
@@ -89,7 +92,7 @@ impl Val {
pub fn ty(&self) -> Ty { pub fn ty(&self) -> Ty {
match self { match self {
Val::Bool { .. } | Val::Cmp { .. } | Val::And { .. } | Val::Or { .. } | Val::Not { .. } | Val::EventKind { .. } => Ty::Bool, Val::Bool { .. } | Val::Cmp { .. } | Val::And { .. } | Val::Or { .. } | Val::Not { .. } | Val::EventKind { .. } | Val::WeatherIs { .. } => Ty::Bool,
_ => Ty::Num, _ => Ty::Num,
} }
} }
@@ -113,6 +116,7 @@ impl Val {
"or" => Val::Or { a: Box::new(Val::Bool { b: true }), b: Box::new(Val::Bool { b: false }) }, "or" => Val::Or { a: Box::new(Val::Bool { b: true }), b: Box::new(Val::Bool { b: false }) },
"not" => Val::Not { a: Box::new(Val::Bool { b: true }) }, "not" => Val::Not { a: Box::new(Val::Bool { b: true }) },
"eventkind" => Val::EventKind { kind: "Knight".into() }, "eventkind" => Val::EventKind { kind: "Knight".into() },
"weatheris" => Val::WeatherIs { kind: "rain".into() },
_ => return None, _ => return None,
}) })
} }
@@ -135,16 +139,20 @@ impl Val {
Val::Or { .. } => "or", Val::Or { .. } => "or",
Val::Not { .. } => "not", Val::Not { .. } => "not",
Val::EventKind { .. } => "eventkind", Val::EventKind { .. } => "eventkind",
Val::WeatherIs { .. } => "weatheris",
} }
} }
} }
// value kinds by type, for the value picker // value kinds by type, for the value picker
pub const NUM_VALUES: [&str; 9] = ["num", "var", "gold", "mana", "time", "creatures", "inzone", "random", "math"]; pub const NUM_VALUES: [&str; 9] = ["num", "var", "gold", "mana", "time", "creatures", "inzone", "random", "math"];
pub const BOOL_VALUES: [&str; 6] = ["cmp", "and", "or", "not", "bool", "eventkind"]; pub const BOOL_VALUES: [&str; 7] = ["cmp", "and", "or", "not", "bool", "eventkind", "weatheris"];
// the weather a block can set or ask for (weather.rs: FAIR, RAIN, FOG, STORM)
pub const WEATHER: [&str; 4] = ["fair", "rain", "fog", "storm"];
// statement blocks in the palette: (category, kind) // statement blocks in the palette: (category, kind)
pub const PALETTE: [(&str, &str); 21] = [ pub const PALETTE: [(&str, &str); 23] = [
("say", "message"), ("say", "message"),
("say", "objective"), ("say", "objective"),
("say", "hint"), ("say", "hint"),
@@ -156,6 +164,8 @@ pub const PALETTE: [(&str, &str); 21] = [
("world", "camera"), ("world", "camera"),
("world", "gold"), ("world", "gold"),
("world", "mana"), ("world", "mana"),
("world", "weather"),
("world", "talentxp"),
("logic", "if"), ("logic", "if"),
("logic", "repeat"), ("logic", "repeat"),
("logic", "until"), ("logic", "until"),
@@ -185,6 +195,8 @@ impl Block {
"camera" => Block::Camera { zone: String::new() }, "camera" => Block::Camera { zone: String::new() },
"gold" => Block::Gold { amount: n(500.0) }, "gold" => Block::Gold { amount: n(500.0) },
"mana" => Block::Mana { amount: n(100.0) }, "mana" => Block::Mana { amount: n(100.0) },
"weather" => Block::Weather { kind: "storm".into(), secs: n(0.0) },
"talentxp" => Block::TalentXp { amount: n(300.0) },
"if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() }, "if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() },
"repeat" => Block::Repeat { times: n(3.0), body: Vec::new() }, "repeat" => Block::Repeat { times: n(3.0), body: Vec::new() },
"until" => Block::Until { cond: Val::template("cmp").unwrap(), body: Vec::new() }, "until" => Block::Until { cond: Val::template("cmp").unwrap(), body: Vec::new() },
@@ -212,6 +224,8 @@ impl Block {
Block::Camera { .. } => "camera", Block::Camera { .. } => "camera",
Block::Gold { .. } => "gold", Block::Gold { .. } => "gold",
Block::Mana { .. } => "mana", Block::Mana { .. } => "mana",
Block::Weather { .. } => "weather",
Block::TalentXp { .. } => "talentxp",
Block::If { .. } => "if", Block::If { .. } => "if",
Block::Repeat { .. } => "repeat", Block::Repeat { .. } => "repeat",
Block::Until { .. } => "until", Block::Until { .. } => "until",
@@ -258,6 +272,7 @@ fn check_val(v: &Val, want: Ty, n: &Names, path: &[usize], out: &mut Vec<Problem
Val::Creatures { kind, .. } if !kind.is_empty() && !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")), Val::Creatures { kind, .. } if !kind.is_empty() && !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")),
Val::InZone { zone, .. } if !n.zones.iter().any(|z| z == zone) => out.push(p("blk.err_zone")), Val::InZone { zone, .. } if !n.zones.iter().any(|z| z == zone) => out.push(p("blk.err_zone")),
Val::EventKind { kind } if !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")), Val::EventKind { kind } if !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")),
Val::WeatherIs { kind } if !WEATHER.contains(&kind.as_str()) => out.push(p("blk.err_weather")),
Val::Random { a, b } | Val::Math { a, b, .. } => { Val::Random { a, b } | Val::Math { a, b, .. } => {
check_val(a, Ty::Num, n, path, out); check_val(a, Ty::Num, n, path, out);
check_val(b, Ty::Num, n, path, out); check_val(b, Ty::Num, n, path, out);
@@ -295,7 +310,13 @@ fn check_list(list: &[Block], n: &Names, path: &mut Vec<usize>, out: &mut Vec<Pr
} }
} }
Block::Wave { count } => check_val(count, Ty::Num, n, path, out), Block::Wave { count } => check_val(count, Ty::Num, n, path, out),
Block::Gold { amount } | Block::Mana { amount } => check_val(amount, Ty::Num, n, path, out), Block::Gold { amount } | Block::Mana { amount } | Block::TalentXp { amount } => check_val(amount, Ty::Num, n, path, out),
Block::Weather { kind, secs } => {
if !WEATHER.contains(&kind.as_str()) {
out.push(p("blk.err_weather", path));
}
check_val(secs, Ty::Num, n, path, out);
}
Block::SetVar { name, value } | Block::AddVar { name, value } => { Block::SetVar { name, value } | Block::AddVar { name, value } => {
if !valid_var(name) { if !valid_var(name) {
out.push(p("blk.err_var", path)); out.push(p("blk.err_var", path));
+169
View File
@@ -0,0 +1,169 @@
// Bosses: every fifth wave is led by one of the Order's champions - a paladin with a name, more
// than twice the health, an ability of his own and loot for whoever brings him down (gold and a
// free rune forge tower). His health bar stands at the top of the screen while he lives.
use crate::game::*;
use crate::particles::*;
// the champions (Ent::boss = index + 1): ability cooldowns in seconds
pub const BOSSES: [(&str, f32); 4] = [("aldric", 12.0), ("seraphine", 10.0), ("halvard", 18.0), ("rothgar", 9.0)];
pub const BOSS_HP: f32 = 2.5; // health factor
pub const BOSS_GOLD: f32 = 600.0;
pub fn boss_name(b: u8) -> String {
format!("@boss.{}", BOSSES[(b.max(1) as usize - 1) % BOSSES.len()].0)
}
impl Game {
// the unit at index j leads the wave as champion number `b` (1..=4)
pub(crate) fn make_boss(&mut self, j: usize, b: u8) {
let e = &mut self.ents[j];
e.boss = b;
e.max_hp *= BOSS_HP;
e.hp = e.max_hp;
e.ab_cd = 4.0;
let name = boss_name(b);
self.msg("msg.boss_comes", vec![name]);
}
// the champion's ability, used by itself whenever it is ready
pub(crate) fn boss_ability(&mut self, e: &mut Ent, dt: f32) {
e.ab_cd -= dt;
if e.ab_cd > 0.0 || e.captive != 0 {
return;
}
let (bx, by) = (e.x, e.y);
let idx_b = (e.boss.max(1) as usize - 1) % BOSSES.len();
let foe = |o: &Ent| o.alive && !o.held && !o.hero && o.captive == 0 && !o.chained;
let near = |o: &Ent, r: f32| (o.x - bx).powi(2) + (o.y - by).powi(2) < r * r;
let used = match idx_b {
// Sir Aldric: shield wall - heroes around are healed and shielded
0 => {
for o in self.ents.iter_mut().filter(|o| o.alive && o.hero && (o.x - bx).powi(2) + (o.y - by).powi(2) < 16.0) {
o.hp = (o.hp + 60.0).min(o.max_hp);
o.shield = o.shield.max(6.0);
}
self.part(RING, (bx, by, 0.1), (0.0, 0.0, 0.0), 0.7, abgr(255, 230, 140), 4.0);
true
}
// Dame Seraphine: radiant burst - the dungeon's units around are burnt by light
1 => {
let hit: Vec<usize> = (0..self.ents.len()).filter(|&k| foe(&self.ents[k]) && near(&self.ents[k], 3.0)).collect();
if hit.is_empty() {
false
} else {
for k in hit {
self.damage_unit(k, 45.0);
}
self.part(FLASH, (bx, by, 0.5), (0.0, 0.0, 0.0), 0.5, abgr(255, 250, 200), 3.0);
true
}
}
// Brother Halvard: call to arms - two knights join him
2 => {
let level = e.level;
for k in 0..2 {
let a = k as f32 * 3.1;
let id = self.spawn(EK::Knight, true, bx + a.cos(), by + a.sin(), level.max(2) - 1);
if let Some(o) = self.ents.iter_mut().find(|o| o.id == id) {
o.target = e.target;
}
}
self.fx(4, bx, by, 0.0, 0.0, 0.8, 0xffffe0a0);
true
}
// Captain Rothgar: charge - he rushes at an enemy, hits hard and stuns
_ => {
let target = (0..self.ents.len()).filter(|&k| foe(&self.ents[k]) && near(&self.ents[k], 6.0)).min_by(|&a, &b| {
let da = (self.ents[a].x - bx).powi(2) + (self.ents[a].y - by).powi(2);
let db = (self.ents[b].x - bx).powi(2) + (self.ents[b].y - by).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
});
match target {
Some(k) => {
let (tx, ty) = (self.ents[k].x, self.ents[k].y);
let d = ((tx - bx).powi(2) + (ty - by).powi(2)).sqrt().max(0.01);
if self.map.walk((tx - (tx - bx) / d * 0.8) as i32, (ty - (ty - by) / d * 0.8) as i32) {
e.x = tx - (tx - bx) / d * 0.8;
e.y = ty - (ty - by) / d * 0.8;
e.path.clear();
}
self.ents[k].stun = self.ents[k].stun.max(1.5);
self.damage_unit(k, 50.0);
for _ in 0..10 {
let v = (self.vrng.f() * 2.0 - 1.0, self.vrng.f() * 2.0 - 1.0, 0.5);
self.parts.push(Part { kind: DEBRIS, x: bx, y: by, h: 0.1, vx: v.0, vy: v.1, vh: v.2, t: 0.0, max: 0.8, col: abgr(150, 130, 110), size: 0.05 });
}
true
}
None => false,
}
}
};
e.ab_cd = if used { BOSSES[idx_b].1 } else { 1.0 };
}
// a champion fell (or was captured): his loot
pub(crate) fn boss_loot(&mut self, j: usize) {
let b = self.ents[j].boss;
if b == 0 {
return;
}
self.ents[j].boss = 0;
self.count_local(crate::stats::BOSSES);
let got = self.add_gold(BOSS_GOLD);
self.rune_free += 1;
let (x, y) = (self.ents[j].x, self.ents[j].y);
self.fx(7, x, y, 0.0, 0.0, 1.2, 0xff40e0ff);
self.sfx_ui(Sfx::Victory);
self.msg("msg.boss_slain", vec![boss_name(b), (got as i32).to_string()]);
}
// the living champion with the most health (for the bar at the top)
pub fn boss_alive(&self) -> Option<&Ent> {
self.ents.iter().filter(|e| e.alive && e.boss > 0 && e.captive == 0).max_by(|a, b| a.hp.partial_cmp(&b.hp).unwrap_or(std::cmp::Ordering::Equal))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_fifth_wave_brings_a_champion_with_loot() {
let mut g = Game::new(8, 80, 64);
g.wave = 4; // the next wave is the fifth
let before = g.ents.len();
g.spawn_wave();
let boss = g.ents[before..].iter().position(|e| e.boss > 0).map(|k| k + before).expect("a champion");
assert!(g.ents[boss].max_hp > st(g.ents[boss].kind).hp * 2.0);
assert!(g.boss_alive().is_some());
let (gold, runes) = (g.gold, g.rune_free);
g.boss_loot(boss);
assert!(g.gold > gold && g.rune_free == runes + 1);
assert!(g.boss_alive().is_none());
}
#[test]
fn champions_use_their_abilities() {
for b in 1..=4u8 {
let mut g = Game::new(8, 80, 64);
let (cx, cy) = g.map.core;
let (x, y) = (cx as f32 + 1.5, cy as f32 + 4.5);
let id = g.spawn(EK::Paladin, true, x, y, 5);
let j = g.ents.iter().position(|e| e.id == id).unwrap();
g.make_boss(j, b);
// an ogre of the dungeon close by
g.spawn(EK::Ogre, false, x + 1.5, y, 3);
let n0 = g.ents.len();
let mut e = g.ents[j].clone();
e.ab_cd = 0.0;
e.hp = e.max_hp * 0.5;
g.boss_ability(&mut e, 0.1);
assert!(e.ab_cd > 1.5, "champion {} used his ability", b);
if b == 3 {
assert_eq!(g.ents.len(), n0 + 2, "two knights joined");
}
}
}
}
+483
View File
@@ -0,0 +1,483 @@
// Bridges under strain: wooden bridges catch fire (fire traps, fire breath, fireballs, lightning),
// the fire spreads along the planks and the bridge collapses into the river; knights wear bridges
// down step by step, pit imps mend them again. Stone bridges over lava never burn.
// Drawbridges are raised and lowered by hand or rise by themselves when enemies come near.
// Frost (the frost glow trap, the frost spell) cools lava into walkable obsidian for a while.
use crate::game::*;
use crate::map::*;
use crate::particles::*;
use serde::{Deserialize, Serialize};
// seconds a burning plank holds before it falls into the river
pub const BURN_TIME: f32 = 6.0;
// a burning plank sets a neighbouring wooden plank alight with this chance per second
pub const BURN_SPREAD: f32 = 0.35;
// fire damage per second to whoever stands on a burning plank
pub const BURN_DPS: f32 = 6.0;
// wear a knight does to a bridge tile per second (1.0 = it falls)
pub const TEAR_RATE: f32 = 0.12;
// wear an imp mends per second
pub const MEND_RATE: f32 = 0.25;
// an automatic drawbridge rises when an enemy comes this close (tiles)
pub const DRAWBRIDGE_WATCH: f32 = 6.0;
// seconds the frost spell keeps lava cooled; the last seconds it steams
pub const FREEZE_TIME: f32 = 40.0;
pub const MELT_WARN: f32 = 5.0;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct BridgeWork {
pub burning: Vec<(usize, f32)>, // tile, seconds it burns already
pub wear: Vec<(usize, f32)>, // tile, damage 0..1
#[serde(default)]
pub cooled: Vec<(usize, f32)>, // obsidian tile, seconds until it melts
#[serde(default)]
pub watch_t: f32, // automatic drawbridges look around twice a second
}
impl Game {
// a wooden bridge (or wooden drawbridge) that can burn
pub fn wooden_bridge(&self, i: usize) -> bool {
self.map.t.get(i).map_or(false, |t| t.kind == Kind::Bridge || (t.kind == Kind::Drawbridge && t.res & DB_LAVA == 0))
}
pub fn burning(&self, i: usize) -> bool {
self.bridgework.burning.iter().any(|b| b.0 == i)
}
pub fn wear_of(&self, i: usize) -> f32 {
self.bridgework.wear.iter().find(|w| w.0 == i).map_or(0.0, |w| w.1)
}
// fire reaches tile (x, y): a wooden bridge there starts to burn
pub fn ignite_at(&mut self, x: f32, y: f32) {
let (tx, ty) = (x.floor() as i32, y.floor() as i32);
if !inb(tx, ty) {
return;
}
let i = idx(tx, ty);
// nothing catches fire in the rain
if !self.wooden_bridge(i) || self.burning(i) || self.rain_at(i) {
return;
}
self.bridgework.burning.push((i, 0.0));
self.fx(3, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.5, 0xff2080ff);
if self.map.t[i].owner == self.me {
self.msg("msg.bridge_fire", vec![]);
self.hint(32, "hint.bridge_fire");
}
}
// fire along a jet from (x, y) in direction (dx, dy) over `len` tiles
pub fn ignite_line(&mut self, x: f32, y: f32, dx: f32, dy: f32, len: f32) {
let steps = (len * 3.0) as usize;
for s in 1..=steps {
let f = s as f32 / 3.0;
self.ignite_at(x + dx * f, y + dy * f);
}
}
// the shared part of every step: fire, collapse, drawbridges, melting obsidian
pub(crate) fn update_bridges(&mut self, dt: f32) {
self.update_drawbridges(dt);
self.update_obsidian(dt);
if self.bridgework.burning.is_empty() {
return;
}
let mut k = 0;
while k < self.bridgework.burning.len() {
let (i, t0) = self.bridgework.burning[k];
// the plank is gone already (torn down, sold, rebuilt)
if !self.wooden_bridge(i) {
self.bridgework.burning.swap_remove(k);
continue;
}
let t = t0 + dt;
self.bridgework.burning[k].1 = t;
let (x, y) = xy(i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
// flames and smoke (looks only)
if self.vrng.f() < dt * 14.0 {
let (ox, oy) = (self.vrng.f() - 0.5, self.vrng.f() - 0.5);
let v = (self.vrng.f() * 0.4 - 0.2, self.vrng.f() * 0.4 - 0.2, 0.9 + self.vrng.f() * 0.8);
self.parts.push(Part { kind: EMBER, x: fx + ox * 0.9, y: fy + oy * 0.9, h: 0.15, vx: v.0, vy: v.1, vh: v.2, t: 0.0, max: 0.7, col: abgr(255, 120 + (self.vrng.f() * 80.0) as u8, 30), size: 0.08 });
}
if self.vrng.f() < dt * 3.0 {
self.parts.push(Part { kind: SMOKE, x: fx + self.vrng.f() - 0.5, y: fy + self.vrng.f() - 0.5, h: 0.5, vx: 0.0, vy: 0.0, vh: 0.6, t: 0.0, max: 2.2, col: abgr(40, 34, 30), size: 0.18 });
}
// whoever stands in the flames gets burnt
for j in 0..self.ents.len() {
let e = &self.ents[j];
if e.alive && !e.held && !flies(e.kind) && e.tile() == i {
self.ents[j].hp -= BURN_DPS * dt;
if self.ents[j].hp <= 0.0 {
self.kill(j);
}
}
}
// the fire spreads along the planks (deterministic: the shared random numbers)
if t > 1.5 && (t / 1.0).floor() != ((t - dt) / 1.0).floor() {
for &(dx, dy) in DIR4.iter() {
if !inb(x + dx, y + dy) {
continue;
}
let n = idx(x + dx, y + dy);
if self.wooden_bridge(n) && !self.burning(n) && self.rng.f() < BURN_SPREAD {
self.bridgework.burning.push((n, 0.0));
}
}
}
if t >= BURN_TIME {
self.bridgework.burning.swap_remove(k);
self.count_local(crate::stats::BURNT);
self.bridge_falls(i);
continue;
}
k += 1;
}
}
// a bridge tile falls into its river: whoever stands there goes into the water (or lava)
pub(crate) fn bridge_falls(&mut self, i: usize) {
let t = self.map.t[i];
if !t.bridge() {
return;
}
let mine = t.owner == self.me;
let tl = &mut self.map.t[i];
tl.kind = if t.river() == Some(Kind::Lava) { Kind::Lava } else { Kind::Water };
tl.owner = Owner::None;
tl.reserved = 0;
tl.res = 0;
self.bridgework.wear.retain(|w| w.0 != i);
self.bridgework.burning.retain(|w| w.0 != i);
self.ground_lost(i);
let (x, y) = xy(i);
self.fx(6, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff3a5a7a);
for _ in 0..6 {
let v = (self.vrng.f() * 2.0 - 1.0, self.vrng.f() * 2.0 - 1.0, 0.6 + self.vrng.f());
self.parts.push(Part { kind: DEBRIS, x: x as f32 + 0.5, y: y as f32 + 0.5, h: 0.2, vx: v.0, vy: v.1, vh: v.2, t: 0.0, max: 1.2, col: abgr(90, 62, 34), size: 0.08 });
}
self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5);
if mine {
self.msg("msg.bridge_torn", vec![]);
}
}
// a knight works on a bridge tile: true while it still stands
pub(crate) fn wear_bridge(&mut self, i: usize, dt: f32) -> bool {
let w = self.wear_of(i) + TEAR_RATE * dt;
if w >= 1.0 {
self.bridge_falls(i);
return false;
}
match self.bridgework.wear.iter_mut().find(|x| x.0 == i) {
Some(x) => x.1 = w,
None => self.bridgework.wear.push((i, w)),
}
true
}
// an imp mends a worn bridge tile: true while there is more to do
pub(crate) fn mend_bridge(&mut self, i: usize, dt: f32) -> bool {
let Some(k) = self.bridgework.wear.iter().position(|x| x.0 == i) else { return false };
self.bridgework.wear[k].1 -= MEND_RATE * dt;
if self.bridgework.wear[k].1 <= 0.0 {
self.bridgework.wear.swap_remove(k);
return false;
}
true
}
// a worn (not burning) bridge tile of the master being processed next to or at tile i
pub(crate) fn worn_bridge_near(&self, i: usize) -> Option<usize> {
if self.bridgework.wear.is_empty() {
return None;
}
let (x, y) = xy(i);
std::iter::once((0, 0)).chain(DIR4.iter().copied()).find_map(|(dx, dy)| {
let n = idx(x + dx, y + dy);
let ok = inb(x + dx, y + dy) && self.map.t[n].bridge() && self.map.t[n].owner == self.me && self.wear_of(n) > 0.0 && !self.burning(n);
ok.then_some(n)
})
}
}
// ------------------------------------------------------------------ drawbridges and frost
impl Game {
// the drawbridge tiles connected to tile i (same owner)
pub fn drawbridge_group(&self, i: usize) -> Vec<usize> {
let owner = self.map.t[i].owner;
let mut group = vec![i];
let mut k = 0;
while k < group.len() {
let (x, y) = xy(group[k]);
for &(dx, dy) in DIR4.iter() {
if !inb(x + dx, y + dy) {
continue;
}
let n = idx(x + dx, y + dy);
let t = &self.map.t[n];
if t.kind == Kind::Drawbridge && t.owner == owner && !group.contains(&n) {
group.push(n);
}
}
k += 1;
}
group
}
// the drawbridge tool: a click on an own drawbridge switches it (down -> up -> automatic -> down),
// anything else builds drawbridges like bridges
pub fn drawbridge_order(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
if x0 == x1 && y0 == y1 && inb(x0, y0) {
let i = idx(x0, y0);
let t = self.map.t[i];
if t.kind == Kind::Drawbridge && t.owner == self.me {
let state = t.res & (DB_RAISED | DB_AUTO);
let next = match state {
0 => DB_RAISED,
s if s & DB_AUTO != 0 => 0,
_ => DB_AUTO,
};
self.set_drawbridge(i, next);
let key = match next {
DB_RAISED => "msg.drawbridge_up",
DB_AUTO => "msg.drawbridge_auto",
_ => "msg.drawbridge_down",
};
self.msg(key, vec![]);
self.sfx_ui(Sfx::Click);
return;
}
}
self.build_bridges_of(x0, y0, x1, y1, true);
}
// sets the state bits (DB_RAISED, DB_AUTO) of the whole drawbridge at tile i
pub fn set_drawbridge(&mut self, i: usize, state: u8) {
let group = self.drawbridge_group(i);
let raise = state & DB_RAISED != 0 && !self.map.t[i].raised();
let lower = state & DB_RAISED == 0 && self.map.t[i].raised();
for &g in &group {
let t = &mut self.map.t[g];
t.res = (t.res & DB_LAVA) | state;
}
if raise {
self.count(crate::stats::DRAWBRIDGE, 1.0);
// whoever stands on it jumps to the bank (or falls)
for &g in &group {
self.ground_lost(g);
}
}
if raise || lower {
let (x, y) = xy(i);
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
for &g in &group {
let (gx, gy) = xy(g);
self.fx(6, gx as f32 + 0.5, gy as f32 + 0.5, 0.0, 0.0, 0.3, 0xff506070);
}
}
}
// automatic drawbridges rise while an enemy of their master is near, and come down again
fn update_drawbridges(&mut self, dt: f32) {
self.bridgework.watch_t -= dt;
if self.bridgework.watch_t > 0.0 {
return;
}
self.bridgework.watch_t = 0.5;
let mut seen: Vec<usize> = Vec::new();
for i in 0..self.map.t.len() {
let t = self.map.t[i];
if t.kind != Kind::Drawbridge || t.res & DB_AUTO == 0 || seen.contains(&i) {
continue;
}
let group = self.drawbridge_group(i);
seen.extend(group.iter().copied());
let side = t.owner.side();
let r2 = DRAWBRIDGE_WATCH * DRAWBRIDGE_WATCH;
let enemy = self.ents.iter().any(|e| {
e.alive
&& !e.held
&& e.captive == 0
&& !e.chained
&& e.side() != side
&& !self.allied(side, e.side())
&& group.iter().any(|&g| {
let (gx, gy) = xy(g);
(e.x - gx as f32 - 0.5).powi(2) + (e.y - gy as f32 - 0.5).powi(2) < r2
})
});
if enemy != t.raised() {
self.set_drawbridge(i, DB_AUTO | if enemy { DB_RAISED } else { 0 });
if enemy && t.owner == self.me {
self.msg("msg.drawbridge_rises", vec![]);
}
}
}
}
// frost on tile i: lava turns to obsidian (or stays it) for at least `secs`
pub fn cool_lava(&mut self, i: usize, secs: f32) {
let t = &mut self.map.t[i];
match t.kind {
Kind::Lava => {
t.kind = Kind::Obsidian;
t.owner = Owner::None;
self.bridgework.cooled.push((i, secs));
let (x, y) = xy(i);
for _ in 0..3 {
self.parts.push(Part { kind: SMOKE, x: x as f32 + self.vrng.f(), y: y as f32 + self.vrng.f(), h: 0.1, vx: 0.0, vy: 0.0, vh: 0.5, t: 0.0, max: 1.6, col: abgr(200, 210, 220), size: 0.16 });
}
}
Kind::Obsidian => {
if let Some(c) = self.bridgework.cooled.iter_mut().find(|c| c.0 == i) {
c.1 = c.1.max(secs);
}
}
_ => {}
}
}
// frost in a circle around (x, y)
pub fn cool_lava_around(&mut self, x: f32, y: f32, r: f32, secs: f32) {
let ri = r.ceil() as i32;
let (cx, cy) = (x.floor() as i32, y.floor() as i32);
for ty in cy - ri..=cy + ri {
for tx in cx - ri..=cx + ri {
if inb(tx, ty) && (tx as f32 + 0.5 - x).powi(2) + (ty as f32 + 0.5 - y).powi(2) <= r * r {
self.cool_lava(idx(tx, ty), secs);
}
}
}
}
// obsidian melts again: it steams before, and whoever still stands there is burnt
fn update_obsidian(&mut self, dt: f32) {
let mut k = 0;
while k < self.bridgework.cooled.len() {
let (i, left) = self.bridgework.cooled[k];
if self.map.t[i].kind != Kind::Obsidian {
self.bridgework.cooled.swap_remove(k);
continue;
}
let left = left - dt;
self.bridgework.cooled[k].1 = left;
let (x, y) = xy(i);
if left < MELT_WARN && self.vrng.f() < dt * 4.0 {
self.parts.push(Part { kind: SMOKE, x: x as f32 + self.vrng.f(), y: y as f32 + self.vrng.f(), h: 0.05, vx: 0.0, vy: 0.0, vh: 0.4, t: 0.0, max: 1.4, col: abgr(150, 150, 160), size: 0.14 });
}
if left <= 0.0 {
self.bridgework.cooled.swap_remove(k);
self.map.t[i].kind = Kind::Lava;
self.ground_lost(i);
continue;
}
k += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// a wooden bridge of the player across a strip of water, 6 tiles long
fn bridge_game() -> (Game, Vec<usize>) {
let mut g = Game::new(5, 64, 64);
let (cx, cy) = g.map.core;
let y = cy + 6;
let mut tiles = Vec::new();
for x in cx - 3..cx + 3 {
let i = idx(x, y);
g.map.t[i].kind = Kind::Bridge;
g.map.t[i].owner = Owner::Player;
for dy in [-1, 1] {
let t = &mut g.map.t[idx(x, y + dy)];
t.kind = Kind::Water;
t.owner = Owner::None;
}
tiles.push(i);
}
(g, tiles)
}
#[test]
fn a_burning_bridge_spreads_and_falls() {
let (mut g, tiles) = bridge_game();
let (x, y) = xy(tiles[2]);
g.ignite_at(x as f32 + 0.5, y as f32 + 0.5);
assert!(g.burning(tiles[2]));
for _ in 0..(20 * 20) {
g.update(0.05);
}
assert_eq!(g.map.t[tiles[2]].kind, Kind::Water, "the burnt plank fell");
assert!(tiles.iter().filter(|&&i| g.map.t[i].kind == Kind::Water).count() >= 2, "the fire spread");
assert!(g.bridgework.burning.iter().all(|b| g.wooden_bridge(b.0)));
}
#[test]
fn stone_bridges_do_not_burn() {
let (mut g, tiles) = bridge_game();
g.map.t[tiles[0]].kind = Kind::LavaBridge;
let (x, y) = xy(tiles[0]);
g.ignite_at(x as f32 + 0.5, y as f32 + 0.5);
assert!(!g.burning(tiles[0]));
}
#[test]
fn worn_bridges_fall_or_are_mended() {
let (mut g, tiles) = bridge_game();
assert!(g.wear_bridge(tiles[1], 2.0));
assert!(g.wear_of(tiles[1]) > 0.2);
assert_eq!(g.worn_bridge_near(tiles[1]), Some(tiles[1]));
while g.mend_bridge(tiles[1], 0.5) {}
assert_eq!(g.wear_of(tiles[1]), 0.0);
while g.wear_bridge(tiles[1], 1.0) {}
assert_eq!(g.map.t[tiles[1]].kind, Kind::Water);
}
#[test]
fn drawbridges_switch_and_rise_by_themselves() {
let (mut g, tiles) = bridge_game();
for &i in &tiles {
g.map.t[i].kind = Kind::Drawbridge;
g.map.t[i].res = 0;
}
let (x, y) = xy(tiles[2]);
g.drawbridge_order(x, y, x, y);
assert!(tiles.iter().all(|&i| g.map.t[i].raised()), "the whole bridge goes up");
assert!(!g.map.walk(x, y));
g.drawbridge_order(x, y, x, y);
assert!(tiles.iter().all(|&i| g.map.t[i].res & DB_AUTO != 0 && !g.map.t[i].raised()), "automatic");
// a knight comes near: it rises
let id = g.spawn(EK::Knight, true, x as f32 + 0.5, y as f32 + 3.5, 1);
for _ in 0..20 {
g.update(0.05);
}
assert!(g.map.t[tiles[2]].raised(), "risen for the knight");
g.ents.retain(|e| e.id != id);
for _ in 0..20 {
g.update(0.05);
}
assert!(!g.map.t[tiles[2]].raised(), "down again");
g.drawbridge_order(x, y, x, y);
assert_eq!(g.map.t[tiles[2]].res & (DB_RAISED | DB_AUTO), 0, "back to manual, down");
}
#[test]
fn frost_cools_lava_for_a_while() {
let mut g = Game::new(5, 64, 64);
let (cx, cy) = g.map.core;
let i = idx(cx + 6, cy + 6);
g.map.t[i].kind = Kind::Lava;
g.cool_lava(i, 2.0);
assert_eq!(g.map.t[i].kind, Kind::Obsidian);
assert!(g.map.t[i].walkable());
for _ in 0..(3 * 20) {
g.update(0.05);
}
assert_eq!(g.map.t[i].kind, Kind::Lava, "melted again");
}
}
+22 -7
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
// From which mission (0-based) on each tool is available (same order as ui::TOOLS): // From which mission (0-based) on each tool is available (same order as ui::TOOLS):
// dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp, // dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp,
// torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie, steel // torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie, steel
pub const TOOL_UNLOCK: [usize; 47] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16, 9, 13, 6, 10, 14, 17, 19, 22, 9, 13, 6, 11, 18, 0, 0]; pub const TOOL_UNLOCK: [usize; crate::ui::TOOLS.len()] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16, 9, 13, 6, 10, 14, 17, 19, 22, 9, 13, 6, 11, 18, 0, 0, 17, 16, 7, 13, 17];
pub struct FortDef { pub struct FortDef {
pub at: (f32, f32), // centre as a fraction of the map size pub at: (f32, f32), // centre as a fraction of the map size
@@ -48,6 +48,7 @@ pub struct MissionDef {
pub rivals: Vec<(f32, f32, u8, u8)>, // rival masters: position (fraction of the map), difficulty, side (2 Mossgrim, 3 Lady Nightwell) pub rivals: Vec<(f32, f32, u8, u8)>, // rival masters: position (fraction of the map), difficulty, side (2 Mossgrim, 3 Lady Nightwell)
pub islands: Vec<IslandDef>, pub islands: Vec<IslandDef>,
pub caves: Vec<CaveDef>, pub caves: Vec<CaveDef>,
pub surface: f32, // share of the width from where the map lies above ground (0: none)
} }
// an island in a lake of water or lava, with treasure and/or a mana spring // an island in a lake of water or lava, with treasure and/or a mana spring
@@ -83,6 +84,8 @@ pub struct Carry {
pub rune_free: u32, // unbuilt rune forge towers from chests pub rune_free: u32, // unbuilt rune forge towers from chests
#[serde(default)] #[serde(default)]
pub rune_final: u32, // unbuilt or sold rune forge towers that were already given back once pub rune_final: u32, // unbuilt or sold rune forge towers that were already given back once
#[serde(default)]
pub talents: crate::talents::Talents, // the master's talents go along from mission to mission
} }
pub const CARRY_CREATURES: usize = 5; pub const CARRY_CREATURES: usize = 5;
@@ -166,6 +169,7 @@ fn base(key: &str, size: (usize, usize), seed: u32, goals: Vec<Goal>) -> Mission
rivals: Vec::new(), rivals: Vec::new(),
islands: Vec::new(), islands: Vec::new(),
caves: Vec::new(), caves: Vec::new(),
surface: 0.0,
} }
} }
@@ -181,6 +185,7 @@ pub fn mission_def_from_doc(d: &crate::campaign_doc::MissionDoc) -> MissionDef {
out.gate = d.waves; out.gate = d.waves;
out.gate_defenders = d.waves; out.gate_defenders = d.waves;
out.time_limit = d.time_limit; out.time_limit = d.time_limit;
out.surface = if d.gen.surface { 0.5 } else { 0.0 };
out out
} }
@@ -355,16 +360,19 @@ pub fn missions() -> Vec<MissionDef> {
.boss(Paladin, 10)], .boss(Paladin, 10)],
..base("m20", (160, 160), 2020, vec![Goal::Heart, Goal::Shards]) ..base("m20", (160, 160), 2020, vec![Goal::Heart, Goal::Shards])
}); });
// the finale above ground: the dungeon in the rock, the eastern half a forest with the High
// Marshal's fortress (the gate of the order) and both rival masters
v.push(MissionDef { v.push(MissionDef {
portals: 3, portals: 3,
gold: 6000.0, gold: 6000.0,
gate: true,
waves: true, waves: true,
first_wave: 420.0, first_wave: 420.0,
gate_defenders: true, gate_defenders: true,
rivers: vec![river(false, true, 0.45, 4, 2), river(true, false, 0.5, 4, 2)], rivers: vec![river(false, true, 0.3, 4, 2)],
islands: vec![island((0.3, 0.2), false, true, 3000)], islands: vec![island((0.2, 0.2), false, true, 3000)],
rivals: vec![(0.85, 0.22, 2, 2), (0.85, 0.78, 2, 3)], forts: vec![fort((0.8, 0.5), (27, 25), &[3], &[Arrow, Cannon, Magic, Arrow, Cannon, Magic, Arrow, Cannon], 3, 14, 4, 4, 3, 3000).heart().boss(Paladin, 10)],
rivals: vec![(0.86, 0.15, 2, 2), (0.86, 0.85, 2, 3)],
surface: 0.48,
..base("m30", (176, 176), 3030, vec![Goal::Rivals, Goal::Heart]) ..base("m30", (176, 176), 3030, vec![Goal::Rivals, Goal::Heart])
}); });
v v
@@ -537,6 +545,9 @@ fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool
} }
g.place_dwarf_caves(d.seed); g.place_dwarf_caves(d.seed);
g.add_wilds(d.seed, false); g.add_wilds(d.seed, false);
if d.surface > 0.0 {
g.add_surface(d.seed, d.surface, 2 + (h / 64) as u32);
}
g.init_research(Some(i)); g.init_research(Some(i));
g.init_fog(); g.init_fog();
let heart = d.gate || d.forts.iter().any(|f| f.heart); let heart = d.gate || d.forts.iter().any(|f| f.heart);
@@ -571,6 +582,7 @@ fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool
g.gold += c.gold; g.gold += c.gold;
g.rune_free += c.rune_free; g.rune_free += c.rune_free;
g.rune_final += c.rune_final; g.rune_final += c.rune_final;
g.talents = c.talents.clone();
} }
for (k, &(kind, level, xp)) in joiners.iter().enumerate() { for (k, &(kind, level, xp)) in joiners.iter().enumerate() {
let a = k as f32 * 0.9; let a = k as f32 * 0.9;
@@ -654,6 +666,8 @@ fn remove_rivals(g: &mut Game) {
return; return;
} }
g.rivals.clear(); g.rivals.clear();
// their masters go too (the seats belong to the players)
g.masters.retain(|m| !sides.contains(&m.side));
g.ents.retain(|e| e.hero || !sides.contains(&e.side)); g.ents.retain(|e| e.hero || !sides.contains(&e.side));
let owners: Vec<Owner> = sides.iter().map(|&s| Owner::of_side(s)).collect(); let owners: Vec<Owner> = sides.iter().map(|&s| Owner::of_side(s)).collect();
for t in g.map.t.iter_mut() { for t in g.map.t.iter_mut() {
@@ -785,7 +799,7 @@ pub fn carry_from(g: &Game) -> Carry {
let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect(); let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect();
let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval; let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval;
c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal)); c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal));
Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor(), rune_free: g.rune_free, rune_final: g.rune_final + g.rune_next } Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor(), rune_free: g.rune_free, rune_final: g.rune_final + g.rune_next, talents: g.talents.clone() }
} }
// a co-op match of an editor campaign: what every master takes into the next mission - his // a co-op match of an editor campaign: what every master takes into the next mission - his
@@ -799,7 +813,7 @@ pub fn coop_carry_from(g: &Game) -> Vec<(u8, Carry)> {
let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && !e.hero && e.side == side && e.kind != EK::Worker && !e.chained && e.captive == 0).collect(); let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && !e.hero && e.side == side && e.kind != EK::Worker && !e.chained && e.captive == 0).collect();
c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal).then(a.id.cmp(&b.id))); c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal).then(a.id.cmp(&b.id)));
let creatures = c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(); let creatures = c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect();
(side, Carry { creatures, gold: (g.gold_of(side).max(0.0) * CARRY_GOLD).floor(), rune_free: 0, rune_final: 0 }) (side, Carry { creatures, gold: (g.gold_of(side).max(0.0) * CARRY_GOLD).floor(), rune_free: 0, rune_final: 0, talents: g.talents_of(side) })
}) })
.collect() .collect()
} }
@@ -813,6 +827,7 @@ pub fn apply_coop_carry(g: &mut Game, carry: &[(u8, Carry)]) {
} }
g.switch_to(*side); g.switch_to(*side);
g.gold += c.gold.max(0.0); g.gold += c.gold.max(0.0);
g.talents = c.talents.clone();
let (cx, cy) = g.map.core; let (cx, cy) = g.map.core;
for (k, &(kind, level, xp)) in c.creatures.iter().take(CARRY_CREATURES).enumerate() { for (k, &(kind, level, xp)) in c.creatures.iter().take(CARRY_CREATURES).enumerate() {
let a = k as f32 * 1.1; let a = k as f32 * 1.1;
+11 -1
View File
@@ -18,6 +18,12 @@ pub struct CampaignProgress {
// random maps are rolled once per run of the campaign: this seed is fixed for the run // random maps are rolled once per run of the campaign: this seed is fixed for the run
#[serde(default)] #[serde(default)]
pub run_seed: u32, pub run_seed: u32,
// co-op runs (kept by the host): the mission the run goes on with and what every master carries
// into it; None once the run is over
#[serde(default)]
pub next: Option<usize>,
#[serde(default)]
pub carry: Vec<(u8, crate::campaign::Carry)>,
} }
impl CampaignProgress { impl CampaignProgress {
@@ -27,6 +33,8 @@ impl CampaignProgress {
done: vec![false; n], done: vec![false; n],
completed_choice: vec![None; n], completed_choice: vec![None; n],
run_seed: (macroquad::miniquad::date::now() * 1000.0) as u64 as u32 | 1, run_seed: (macroquad::miniquad::date::now() * 1000.0) as u64 as u32 | 1,
next: None,
carry: Vec::new(),
} }
} }
@@ -137,11 +145,13 @@ pub struct GenDoc {
pub rivals: u8, // 0..2 rival masters pub rivals: u8, // 0..2 rival masters
#[serde(default = "default_difficulty")] #[serde(default = "default_difficulty")]
pub difficulty: u8, // 0 easy, 1 medium, 2 hard pub difficulty: u8, // 0 easy, 1 medium, 2 hard
#[serde(default)]
pub surface: bool, // the eastern half lies above ground (forest)
} }
impl Default for GenDoc { impl Default for GenDoc {
fn default() -> Self { fn default() -> Self {
GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 1 } GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 1, surface: false }
} }
} }
+20
View File
@@ -80,6 +80,8 @@ enum Req {
CampaignPublish, CampaignPublish,
PublicList, PublicList,
PublicLoad, PublicLoad,
ProfileLoad,
ProfileSave,
} }
// the logged-in account // the logged-in account
@@ -124,6 +126,7 @@ pub struct Cloud {
pub public_listed: bool, pub public_listed: bool,
pub campaign_error: Option<&'static str>, // language key of the last failed campaign request pub campaign_error: Option<&'static str>, // language key of the last failed campaign request
loaded_campaign: Option<String>, loaded_campaign: Option<String>,
pub profile_loaded: Option<String>, // the profile from the server, waiting to be merged
saved_campaign: Option<CampaignInfo>, saved_campaign: Option<CampaignInfo>,
pending: Option<(u32, Req)>, pending: Option<(u32, Req)>,
save_name: String, save_name: String,
@@ -176,6 +179,7 @@ impl Cloud {
public_listed: false, public_listed: false,
campaign_error: None, campaign_error: None,
loaded_campaign: None, loaded_campaign: None,
profile_loaded: None,
saved_campaign: None, saved_campaign: None,
pending: Some((id, Req::Health)), pending: Some((id, Req::Health)),
save_name: String::new(), save_name: String::new(),
@@ -330,6 +334,13 @@ impl Cloud {
CloudEvent::Error(error_key(res.status)) CloudEvent::Error(error_key(res.status))
} }
} }
Req::ProfileLoad => {
if ok {
self.profile_loaded = Some(String::from_utf8_lossy(&res.body).into_owned());
}
CloudEvent::None
}
Req::ProfileSave => CloudEvent::None,
Req::CreateUser | Req::DeleteUser => { Req::CreateUser | Req::DeleteUser => {
if ok { if ok {
self.load_users(); self.load_users();
@@ -470,6 +481,15 @@ impl Cloud {
} }
// JSON text of a campaign loaded by load_campaign() or load_public_campaign() // JSON text of a campaign loaded by load_campaign() or load_public_campaign()
// the profile (statistics and achievements) of the logged-in player
pub fn load_profile(&mut self) -> bool {
self.session.is_some() && self.start("GET", "profile", &[], Req::ProfileLoad)
}
pub fn save_profile(&mut self, json: &str) -> bool {
self.session.is_some() && self.start("PUT", "profile", json.as_bytes(), Req::ProfileSave)
}
pub fn take_loaded_campaign(&mut self) -> Option<String> { pub fn take_loaded_campaign(&mut self) -> Option<String> {
self.loaded_campaign.take() self.loaded_campaign.take()
} }
+32 -8
View File
@@ -34,6 +34,13 @@ pub enum Act {
Sell(Rect), Sell(Rect),
Bridge(Rect), Bridge(Rect),
Steel(Rect), Steel(Rect),
Siege {
which: u8, // 0 ram, 1 catapult
x: i32,
y: i32,
},
Wall(Rect), // plan walls on own floor (or cancel planned ones)
Drawbridge(Rect), // build drawbridges, or (one tile on an own drawbridge) switch it: down, up, automatic
Tower { Tower {
kind: TowerKind, kind: TowerKind,
x: i32, x: i32,
@@ -85,11 +92,19 @@ pub enum Act {
y: i32, y: i32,
}, },
CancelOrders, CancelOrders,
Unmark {
x: f32,
y: f32,
}, // right click with an order tool: removes the marker there (or cancels the guard orders)
// gold for an allied master of a match (`crate::netplay::GOLD_ALL`: everything) // gold for an allied master of a match (`crate::netplay::GOLD_ALL`: everything)
SendGold { SendGold {
to: u8, to: u8,
amount: u32, amount: u32,
}, },
// spend a talent point on a branch (talents.rs)
Talent {
branch: u8,
},
} }
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
@@ -137,13 +152,13 @@ impl Game {
fn mark_command(&mut self, act: Act, side: u8) { fn mark_command(&mut self, act: Act, side: u8) {
let ((x, y), kind, dur) = match act { let ((x, y), kind, dur) = match act {
Act::SendGold { .. } => return, // nothing to point at on the map Act::SendGold { .. } | Act::Talent { .. } => return, // nothing to point at on the map
Act::Dig(r) => (rect_center(r), MARK_DIG, 4.0), Act::Dig(r) => (rect_center(r), MARK_DIG, 4.0),
Act::Room { r, .. } => (rect_center(r), MARK_ROOM, 5.0), Act::Room { r, .. } => (rect_center(r), MARK_ROOM, 5.0),
Act::Sell(r) => (rect_center(r), MARK_SELL, 4.0), Act::Sell(r) => (rect_center(r), MARK_SELL, 4.0),
Act::Bridge(r) => (rect_center(r), MARK_BRIDGE, 4.0), Act::Bridge(r) | Act::Drawbridge(r) | Act::Wall(r) => (rect_center(r), MARK_BRIDGE, 4.0),
Act::Steel(r) => (rect_center(r), MARK_STEEL, 4.0), Act::Steel(r) => (rect_center(r), MARK_STEEL, 4.0),
Act::Tower { x, y, .. } => (point(x as f32, y as f32), MARK_TOWER, 6.0), Act::Tower { x, y, .. } | Act::Siege { x, y, .. } => (point(x as f32, y as f32), MARK_TOWER, 6.0),
Act::Trap { x, y, .. } => (point(x as f32, y as f32), MARK_TRAP, 6.0), Act::Trap { x, y, .. } => (point(x as f32, y as f32), MARK_TRAP, 6.0),
Act::Cast { x, y, .. } => (point(x, y), MARK_SPELL, 5.0), Act::Cast { x, y, .. } => (point(x, y), MARK_SPELL, 5.0),
Act::Lightning { x, y } => (point(x, y), MARK_LIGHTNING, 5.0), Act::Lightning { x, y } => (point(x, y), MARK_LIGHTNING, 5.0),
@@ -165,6 +180,7 @@ impl Game {
}, },
Act::Guard { x, y } => (point(x as f32, y as f32), MARK_GUARD, 10.0), Act::Guard { x, y } => (point(x as f32, y as f32), MARK_GUARD, 10.0),
Act::CancelOrders => (point(self.map.core.0 as f32, self.map.core.1 as f32), MARK_CANCEL, 4.0), Act::CancelOrders => (point(self.map.core.0 as f32, self.map.core.1 as f32), MARK_CANCEL, 4.0),
Act::Unmark { x, y } => (point(x, y), MARK_CANCEL, 3.0),
}; };
let fixed = matches!(act, Act::Guard { .. } | Act::Attack { .. } | Act::Flag { .. }); let fixed = matches!(act, Act::Guard { .. } | Act::Attack { .. } | Act::Flag { .. });
let dur = match act { let dur = match act {
@@ -185,6 +201,9 @@ impl Game {
Act::Room { room, r } => self.build_rect(room as usize, r.x0, r.y0, r.x1, r.y1), Act::Room { room, r } => self.build_rect(room as usize, r.x0, r.y0, r.x1, r.y1),
Act::Sell(r) => self.sell_rect(r.x0, r.y0, r.x1, r.y1), Act::Sell(r) => self.sell_rect(r.x0, r.y0, r.x1, r.y1),
Act::Bridge(r) => self.build_bridges(r.x0, r.y0, r.x1, r.y1), Act::Bridge(r) => self.build_bridges(r.x0, r.y0, r.x1, r.y1),
Act::Drawbridge(r) => self.drawbridge_order(r.x0, r.y0, r.x1, r.y1),
Act::Wall(r) => self.plan_walls(r.x0, r.y0, r.x1, r.y1),
Act::Siege { which, x, y } => self.build_siege(which, x, y),
Act::Steel(r) => self.build_steel(r.x0, r.y0, r.x1, r.y1), Act::Steel(r) => self.build_steel(r.x0, r.y0, r.x1, r.y1),
Act::Tower { kind, x, y } => self.build_tower(kind, x, y), Act::Tower { kind, x, y } => self.build_tower(kind, x, y),
Act::Trap { kind, x, y } => self.place_trap(kind, x, y), Act::Trap { kind, x, y } => self.place_trap(kind, x, y),
@@ -210,7 +229,9 @@ impl Game {
} }
Act::Guard { x, y } => self.order_guard(x, y), Act::Guard { x, y } => self.order_guard(x, y),
Act::CancelOrders => self.cancel_orders(), Act::CancelOrders => self.cancel_orders(),
Act::Unmark { x, y } => self.unmark(x, y),
Act::SendGold { to, amount } => self.send_gold(to, amount), Act::SendGold { to, amount } => self.send_gold(to, amount),
Act::Talent { branch } => self.learn_talent(branch as usize),
} }
} }
} }
@@ -267,7 +288,7 @@ mod tests {
} }
#[test] #[test]
fn fixed_order_flags_do_not_need_the_debug_marker_setting() { fn command_markers_only_show_with_the_debug_setting() {
let mut g = game(13); let mut g = game(13);
g.debug_markers = false; g.debug_markers = false;
let (cx, cy) = g.map.core; let (cx, cy) = g.map.core;
@@ -275,10 +296,13 @@ mod tests {
g.queue(Act::Guard { x: cx, y: cy }); g.queue(Act::Guard { x: cx, y: cy });
g.queue(Act::Attack { ent: None, x: (cx + 20) as f32, y: (cy + 20) as f32 }); g.queue(Act::Attack { ent: None, x: (cx + 20) as f32, y: (cy + 20) as f32 });
g.queue(Act::Flag { x: cx, y: cy }); g.queue(Act::Flag { x: cx, y: cy });
assert!(!g.marks.iter().any(|m| m.kind == MARK_DIG), "dig stays hidden without debug markers"); assert!(g.marks.is_empty(), "the real shields, swords and flags show the orders");
assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.fixed), "guard is fixed"); // with the setting on they show, and removing a marker takes them away
assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.fixed), "attack is fixed"); g.debug_markers = true;
assert!(g.marks.iter().any(|m| m.kind == MARK_FLAG && m.fixed), "gather flag is fixed"); g.queue(Act::Guard { x: cx + 2, y: cy + 2 });
assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD));
g.unmark(cx as f32 + 2.5, cy as f32 + 2.5);
assert!(!g.marks.iter().any(|m| m.kind == MARK_GUARD), "gone with the marker");
} }
#[test] #[test]
+58 -5
View File
@@ -2,7 +2,8 @@
// the knights fight on a tongue of rock between a lava river (north) and an underground river // the knights fight on a tongue of rock between a lava river (north) and an underground river
// (south). The dungeon side has a wall with a wooden, an iron and a magic door, rooms behind it // (south). The dungeon side has a wall with a wooden, an iron and a magic door, rooms behind it
// (treasury, forge, library, lair, ...), a rune forge tower, a magic tower and a mana spring; all // (treasury, forge, library, lair, ...), a rune forge tower, a magic tower and a mana spring; all
// seven traps lie on the knights' way; the knights have their towers and the hero gate. Both sides // seven traps lie on the knights' way; the knights have their towers, and their gate stands above
// ground in a sunny forest east of a cliff, from where they march in through two cave mouths. Both sides
// get balanced reinforcements, destroyed towers, doors and traps come back after a while, and now // get balanced reinforcements, destroyed towers, doors and traps come back after a while, and now
// and then the dungeon casts a spell. Nothing here is ever saved. // and then the dungeon casts a spell. Nothing here is ever saved.
@@ -31,6 +32,7 @@ pub struct Demo {
} }
const WALL_X: i32 = RIVER_X - 8; // the dungeon's wall with its three doors const WALL_X: i32 = RIVER_X - 8; // the dungeon's wall with its three doors
const SURF_X: i32 = 55; // from here east the land lies above ground (the knights come from there)
const CREATURES: [(EK, u32); 12] = [(EK::Kobold, 4), (EK::Spider, 3), (EK::Mage, 2), (EK::Ogre, 2), (EK::Jailer, 1), (EK::Priestess, 1), (EK::Bat, 2), (EK::Gargoyle, 1), (EK::Owl, 1), (EK::Wyrm, 1), (EK::Cogling, 1), (EK::Revenant, 1)]; const CREATURES: [(EK, u32); 12] = [(EK::Kobold, 4), (EK::Spider, 3), (EK::Mage, 2), (EK::Ogre, 2), (EK::Jailer, 1), (EK::Priestess, 1), (EK::Bat, 2), (EK::Gargoyle, 1), (EK::Owl, 1), (EK::Wyrm, 1), (EK::Cogling, 1), (EK::Revenant, 1)];
const KNIGHTS: [(EK, u32); 8] = [(EK::Knight, 5), (EK::Archer, 3), (EK::Priest, 2), (EK::Dwarf, 2), (EK::Paladin, 1), (EK::Giant, 1), (EK::Fairy, 1), (EK::BattleMage, 1)]; const KNIGHTS: [(EK, u32); 8] = [(EK::Knight, 5), (EK::Archer, 3), (EK::Priest, 2), (EK::Dwarf, 2), (EK::Paladin, 1), (EK::Giant, 1), (EK::Fairy, 1), (EK::BattleMage, 1)];
@@ -183,8 +185,58 @@ impl Game {
let spring = idx(WALL_X - 3, BRIDGE_Y + 1); let spring = idx(WALL_X - 3, BRIDGE_Y + 1);
g.map.t[spring].kind = Kind::ManaSpring; g.map.t[spring].kind = Kind::ManaSpring;
g.map.t[spring].owner = Owner::Player; g.map.t[spring].owner = Owner::Player;
// the hero gate on the knights' side // above ground in the east: grass, a forest at the edges, a cliff with two cave mouths
let (gx, gy) = (RIVER_X + 11, BRIDGE_Y); let mouth = |y: i32| (BRIDGE_Y - 4..=BRIDGE_Y - 1).contains(&y) || (BRIDGE_Y + 2..=BRIDGE_Y + 6).contains(&y);
for y in 1..H as i32 - 1 {
for x in SURF_X - 1..W as i32 - 1 {
let i = idx(x, y);
let border = y < 2 || y >= H as i32 - 2 || x >= W as i32 - 2;
if x < SURF_X + 1 {
// the cliff (or a cave mouth: knight ground)
if !border {
let t = &mut g.map.t[i];
t.kind = if mouth(y) { Kind::Floor } else { Kind::Rock };
t.owner = if mouth(y) { Owner::Hero } else { Owner::None };
t.reinforced = false;
t.gold = 0;
}
continue;
}
g.map.set_sky(i, true);
if border {
continue;
}
let t = &mut g.map.t[i];
t.owner = Owner::None;
t.gold = 0;
t.reinforced = false;
if t.kind == Kind::Rock && rng.f() < 0.6 {
continue; // a few rocks stay as outcrops
}
// trees along the north and south edges and in the far east; the march route stays open
let dy = (y - (BRIDGE_Y + 1)).abs();
let h = ((x * 73 + y * 151) as u32).wrapping_mul(2654435761) >> 24;
let wood = dy > 9 || x > W as i32 - 7;
t.kind = if wood && h % 3 != 0 { Kind::Tree } else { Kind::Floor };
t.hp = if t.kind == Kind::Tree { crate::wood::TREE_HP } else { 0.0 };
}
}
// the knight cave runs on to the cave mouths
for y in BRIDGE_Y - 4..=BRIDGE_Y + 6 {
if !mouth(y) {
continue;
}
let mut x = SURF_X - 1;
while x > RIVER_X + 3 && !g.map.t[idx(x, y)].walkable() {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Floor;
t.owner = Owner::Hero;
t.reinforced = false;
x -= 1;
}
}
// the hero gate on the knights' side, in the forest
let (gx, gy) = (SURF_X + 6, BRIDGE_Y);
for y in gy..gy + 3 { for y in gy..gy + 3 {
for x in gx..gx + 3 { for x in gx..gx + 3 {
let t = &mut g.map.t[idx(x, y)]; let t = &mut g.map.t[idx(x, y)];
@@ -220,8 +272,9 @@ impl Game {
g.demo_spawn(true); g.demo_spawn(true);
g.demo_spawn(false); g.demo_spawn(false);
} }
let mut cam = Cam::new(RIVER_X as f32 + 0.5, BRIDGE_Y as f32 + 1.5); // the camera between the battle on the tongue and the forest the knights come from
cam.zoom = 16.0; let mut cam = Cam::new(RIVER_X as f32 + 11.5, BRIDGE_Y as f32 + 1.5);
cam.zoom = 21.0;
(g, cam) (g, cam)
} }
+1 -1
View File
@@ -7,7 +7,7 @@ use crate::game::*;
use crate::map::*; use crate::map::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const DWARF_SIDE: u8 = 4; pub const DWARF_SIDE: u8 = 6; // its own side (4 is the fourth master)
const LEASH: f32 = 7.0; // dwarves do not chase enemies further from the cave centre const LEASH: f32 = 7.0; // dwarves do not chase enemies further from the cave centre
const CHEST_CHANCE: f32 = 0.3; const CHEST_CHANCE: f32 = 0.3;
+21 -1
View File
@@ -543,7 +543,13 @@ impl BlockEdit {
let r = lay.take(CW() * 10.0); let r = lay.take(CW() * 10.0);
im.text_field(gfx, fid(path, 6), r, name, 16); im.text_field(gfx, fid(path, 6), r, name, 16);
} }
Block::Wave { count } | Block::Gold { amount: count } | Block::Mana { amount: count } => self.value(im, gfx, count, Ty::Num, path, 1, lay, ctx), Block::Wave { count } | Block::Gold { amount: count } | Block::Mana { amount: count } | Block::TalentXp { amount: count } => self.value(im, gfx, count, Ty::Num, path, 1, lay, ctx),
Block::Weather { kind, secs } => {
weather_dd(im, gfx, kind, fid(path, 2), lay);
lay.word(gfx, &tr("blk.w_for"));
self.value(im, gfx, secs, Ty::Num, path, 1, lay, ctx);
lay.word(gfx, &tr("blk.w_seconds"));
}
Block::SetVar { name, value } | Block::AddVar { name, value } => { Block::SetVar { name, value } | Block::AddVar { name, value } => {
let r = lay.take(CW() * 12.0); let r = lay.take(CW() * 12.0);
im.text_field(gfx, fid(path, 0), r, name, 24); im.text_field(gfx, fid(path, 0), r, name, 24);
@@ -713,10 +719,24 @@ impl BlockEdit {
lay.word(gfx, &tr("blk.v.eventkind")); lay.word(gfx, &tr("blk.v.eventkind"));
self.kind_dd(im, gfx, kind, false, path, sub(0), lay); self.kind_dd(im, gfx, kind, false, path, sub(0), lay);
} }
Val::WeatherIs { kind } => {
lay.word(gfx, &tr("blk.v.weatheris"));
weather_dd(im, gfx, kind, fid(path, sub(0)), lay);
}
} }
} }
} }
// a dropdown with the kinds of weather
fn weather_dd(im: &mut Im, gfx: &Gfx, kind: &mut String, id: u64, lay: &mut Lay) {
let opts: Vec<String> = WEATHER.iter().map(|w| tr(&format!("weather.{}", w))).collect();
let cur = tr(&format!("weather.{}", kind));
let r = lay.take(CW() * 12.0);
if let Some(c) = im.dropdown(gfx, id, r, &cur, &opts) {
*kind = WEATHER[c].to_string();
}
}
// where the next field of a block goes: left to right, into a new row at the block's edge // where the next field of a block goes: left to right, into a new row at the block's edge
struct Lay { struct Lay {
x: f32, x: f32,
+220
View File
@@ -0,0 +1,220 @@
// The built-in co-op campaign "League against the Lightwatch": 20 missions for two to four masters,
// made as an editor campaign (painted maps, goals, branches, triggers). Crookfinger brings young
// masters together: the Lightwatch has allied with Mossgrim to smoke out every dungeon. The masters
// take fortresses, free creatures, and finally bring down the traitor Mossgrim and the High Marshal.
// In co-op the players hold the master seats, so Mossgrim's creatures fight on the heroes' side.
//
// Texts: coop.* keys in the language files; the campaign is built in the player's language.
use crate::blocks::{Block, Place, Val};
use crate::campaign_doc::*;
use crate::game::EK;
use crate::i18n::tr;
struct Spec {
size: (usize, usize),
seed: u32,
gold: f32,
gen: (u8, u8, u8, u8), // rivers, lava rivers, forts, difficulty
goals: &'static [&'static str],
waves: (bool, u32, f32), // hero waves, how many, first after
islands: &'static [((f32, f32), bool, bool, i32)], // at, lava, mana spring, treasure
caves: &'static [((f32, f32), Option<(EK, u32)>, i32)], // at, chained creature, treasure
raids: &'static [(f64, bool, &'static str, u32, u32, usize)], // after/every secs, repeat, kind, count, level, zone
boss: Option<(&'static str, u32, &'static str, usize)>, // kind, level, name, zone
branch: Option<(&'static str, usize)>, // goal, mission number it leads to
}
// zones where Mossgrim's creatures and the bosses appear (map fractions)
const ZONES: [(&str, (f32, f32)); 3] = [("east", (0.86, 0.5)), ("north", (0.7, 0.16)), ("south", (0.7, 0.84))];
const fn s(size: (usize, usize), seed: u32, gold: f32, gen: (u8, u8, u8, u8), goals: &'static [&'static str]) -> Spec {
Spec { size, seed, gold, gen, goals, waves: (false, 0, 0.0), islands: &[], caves: &[], raids: &[], boss: None, branch: None }
}
fn specs() -> Vec<Spec> {
use EK::*;
vec![
// ---- Act I: the league
Spec { raids: &[(150.0, false, "Knight", 3, 1, 0)], ..s((80, 80), 9101, 3000.0, (0, 0, 0, 0), &["KillAll"]) },
s((80, 80), 9102, 3000.0, (1, 0, 1, 0), &["Towers"]),
Spec { waves: (true, 3, 240.0), raids: &[(120.0, false, "Ogre", 2, 2, 1)], ..s((80, 80), 9103, 3500.0, (1, 0, 0, 0), &["Survive(3)"]) },
Spec { islands: &[((0.5, 0.2), false, true, 0), ((0.5, 0.8), false, true, 1500)], branch: Some(("Loot(2000)", 6)), ..s((88, 80), 9104, 3000.0, (1, 0, 1, 0), &["Springs(2)"]) },
Spec { caves: &[((0.3, 0.2), Some((Ogre, 5)), 500), ((0.3, 0.8), Some((Mage, 5)), 500)], raids: &[(200.0, false, "Spider", 4, 3, 0)], ..s((88, 80), 9105, 3500.0, (0, 1, 1, 0), &["Freed(2)"]) },
// ---- Act II: against the Lightwatch
s((88, 88), 9106, 4000.0, (2, 0, 1, 1), &["Towers"]),
Spec { raids: &[(180.0, true, "Knight", 2, 3, 0)], ..s((96, 80), 9107, 4000.0, (0, 2, 1, 1), &["Loot(4000)"]) },
Spec { raids: &[(60.0, true, "Priest", 1, 3, 0)], ..s((88, 88), 9108, 4000.0, (1, 0, 1, 1), &["KillAll"]) },
Spec { boss: Some(("Paladin", 7, "ambassador", 0)), raids: &[(150.0, true, "Ogre", 2, 4, 1)], ..s((96, 88), 9109, 4500.0, (1, 1, 1, 1), &[]) },
s((96, 96), 9110, 5000.0, (1, 0, 2, 1), &["Towers"]),
Spec { waves: (true, 5, 240.0), raids: &[(300.0, true, "Gargoyle", 2, 4, 2)], ..s((96, 88), 9111, 5000.0, (1, 0, 0, 1), &["Survive(5)"]) },
Spec { waves: (true, 0, 300.0), ..s((96, 96), 9112, 5000.0, (2, 0, 1, 1), &["Heart"]) },
// ---- Act III: the traitor
Spec { caves: &[((0.5, 0.15), None, 2500), ((0.5, 0.85), None, 2500)], raids: &[(120.0, true, "Spider", 3, 4, 0)], ..s((96, 96), 9113, 5000.0, (0, 1, 1, 2), &["Loot(5000)"]) },
Spec { raids: &[(90.0, true, "Revenant", 2, 5, 1), (90.0, true, "Revenant", 2, 5, 2)], ..s((104, 96), 9114, 5500.0, (2, 0, 1, 2), &["KillAll"]) },
Spec { caves: &[((0.35, 0.2), Some((Chainbreaker, 6)), 1000)], branch: Some(("Loot(5000)", 17)), ..s((104, 96), 9115, 5500.0, (1, 1, 1, 2), &["Freed(1)", "Towers"]) },
Spec { islands: &[((0.5, 0.5), true, true, 3000)], raids: &[(150.0, true, "Mage", 2, 5, 0)], ..s((104, 104), 9116, 6000.0, (0, 2, 1, 2), &["Springs(1)", "KillAll"]) },
Spec { raids: &[(60.0, true, "Ogre", 2, 6, 0), (120.0, true, "Wyrm", 1, 5, 1)], ..s((104, 104), 9117, 6000.0, (1, 1, 1, 2), &["KillAll"]) },
s((112, 104), 9118, 6500.0, (2, 1, 3, 2), &["Towers"]),
Spec { boss: Some(("Paladin", 10, "marshal", 0)), raids: &[(120.0, true, "Knight", 3, 6, 1), (120.0, true, "Archer", 3, 6, 2)], ..s((112, 112), 9119, 7000.0, (2, 0, 2, 2), &[]) },
Spec { boss: Some(("Wyrm", 10, "mossgrim", 0)), raids: &[(90.0, true, "Ogre", 3, 7, 1), (90.0, true, "Gargoyle", 2, 7, 2)], ..s((112, 112), 9120, 8000.0, (1, 2, 2, 2), &["KillAll"]) },
]
}
fn n(x: f64) -> Val {
Val::num(x)
}
fn key(i: usize, part: &str) -> String {
format!("coop.m{:02}.{}", i + 1, part)
}
fn say(k: String) -> Block {
Block::Message { text: tr(&k) }
}
// the co-op campaign, built in the player's language
pub fn coop_campaign() -> CampaignDoc {
let mut doc = CampaignDoc { name: tr("coop.name"), author: "DungeonCraft".into(), language: crate::i18n::language(), ..CampaignDoc::default() };
let saved = (crate::map::mw(), crate::map::mh());
let specs = specs();
for (i, sp) in specs.iter().enumerate() {
let mut m = MissionDoc {
key: format!("c{:02}", i + 1),
title: tr(&key(i, "title")),
story: tr(&key(i, "story")).replace("\\n", "\n"),
size: sp.size,
seed: sp.seed,
gold: sp.gold,
portals: 2,
goals: sp.goals.iter().map(|g| g.to_string()).collect(),
waves: sp.waves.0,
wave_limit: sp.waves.1,
first_wave: if sp.waves.0 { sp.waves.2 } else { 300.0 },
gen: GenDoc { rivers: sp.gen.0, lava: sp.gen.1, forts: sp.gen.2, rivals: 0, difficulty: sp.gen.3, surface: i + 1 == specs.len() },
next: (i + 1 < specs.len()).then(|| format!("c{:02}", i + 2)),
pos: (40.0 + (i % 5) as f32 * 220.0, 40.0 + (i / 5) as f32 * 140.0),
..MissionDoc::default()
};
if let Some((goal, to)) = sp.branch {
m.branches.push(BranchDoc { id: "detour".into(), goal: goal.into(), title: tr(&key(i, "branch")), next: format!("c{:02}", to), priority: Some(1) });
}
// the map: generated, then fixed with the islands, caves and the places where enemies appear
let mut g = crate::campaign::start_editor_mission(&m, i, sp.seed);
for (k, &(at, lava, spring, treasure)) in sp.islands.iter().enumerate() {
crate::campaign::editor_island(&mut g, at, lava, spring, treasure, sp.seed + k as u32);
}
for &(at, chained, treasure) in sp.caves.iter() {
crate::campaign::editor_cave(&mut g, at, chained, treasure);
}
let (w, h) = (g.map.w as i32, g.map.h as i32);
for (name, (fx, fy)) in ZONES {
let (cx, cy) = (((fx * w as f32) as i32).clamp(4, w - 5), ((fy * h as f32) as i32).clamp(4, h - 5));
for y in cy - 2..=cy + 2 {
for x in cx - 2..=cx + 2 {
let t = &mut g.map.t[crate::map::idx(x, y)];
if !matches!(t.kind, crate::map::Kind::Core | crate::map::Kind::Portal | crate::map::Kind::Gate | crate::map::Kind::Tower) {
t.kind = crate::map::Kind::Floor;
t.owner = crate::map::Owner::Hero;
}
}
}
m.zones.push(ZoneDoc { name: name.into(), shape: "rect".into(), at: None, radius: None, tiles: None, from: Some(((cx - 2) as f32, (cy - 2) as f32)), to: Some(((cx + 2) as f32, (cy + 2) as f32)), width: None });
}
g.recount();
g.place_all_torches();
g.init_fog();
m.map = MapDoc::Fixed { game: serde_json::to_string(&g).unwrap_or_default() };
// the scripts: Crookfinger's start, the objective, story lines, raids and the boss
let sc = &mut m.scripts;
sc.start = vec![say(key(i, "start")), Block::Objective { text: tr(&key(i, "goal")) }];
sc.triggers.push(TriggerDoc { name: "story1".into(), kind: TriggerKind::Timer { secs: 90.0, every: false }, once: true, enabled: true, script: vec![Block::Hint { text: tr(&key(i, "t1")) }] });
sc.triggers.push(TriggerDoc { name: "story2".into(), kind: TriggerKind::Timer { secs: 360.0, every: false }, once: true, enabled: true, script: vec![Block::Hint { text: tr(&key(i, "t2")) }] });
for (k, &(secs, every, kind, count, level, zone)) in sp.raids.iter().enumerate() {
sc.triggers.push(TriggerDoc {
name: format!("raid{}", k + 1),
kind: TriggerKind::Timer { secs, every },
once: !every,
enabled: true,
script: vec![
Block::Message { text: tr("coop.raid") },
Block::Spawn { side: 1, kind: kind.into(), count: n(count as f64), level: n(level as f64), place: Place::Zone(ZONES[zone].0.into()), name: String::new() },
],
});
}
// the rogues of the Order in the later missions: thieves from mission 7, an inquisitor
// from mission 15; the last mission (above ground) brews a storm
let lv = n((1 + i / 3) as f64);
if i >= 6 {
sc.triggers.push(TriggerDoc {
name: "thieves".into(),
kind: TriggerKind::Timer { secs: 420.0, every: false },
once: true,
enabled: true,
script: vec![Block::Message { text: tr("coop.raid") }, Block::Spawn { side: 1, kind: "Thief".into(), count: n(2.0), level: lv.clone(), place: Place::Zone(ZONES[i % 3].0.into()), name: String::new() }],
});
}
if i >= 14 {
sc.triggers.push(TriggerDoc {
name: "inquisitor".into(),
kind: TriggerKind::Timer { secs: 600.0, every: false },
once: true,
enabled: true,
script: vec![
Block::Message { text: tr("coop.raid") },
Block::Spawn { side: 1, kind: "Inquisitor".into(), count: n(1.0), level: lv.clone(), place: Place::Zone(ZONES[(i + 1) % 3].0.into()), name: String::new() },
Block::Spawn { side: 1, kind: "Monk".into(), count: n(2.0), level: lv.clone(), place: Place::Zone(ZONES[(i + 1) % 3].0.into()), name: String::new() },
],
});
}
if i + 1 == specs.len() {
sc.triggers.push(TriggerDoc {
name: "storm".into(),
kind: TriggerKind::Timer { secs: 480.0, every: false },
once: true,
enabled: true,
script: vec![Block::Weather { kind: "storm".into(), secs: n(120.0) }],
});
}
if let Some((kind, level, name, zone)) = sp.boss {
sc.start.push(Block::Spawn { side: 1, kind: kind.into(), count: n(1.0), level: n(level as f64), place: Place::Zone(ZONES[zone].0.into()), name: name.into() });
// the boss falls: the mission is won once the goals (if any) are done too
let win = if sp.goals.is_empty() { vec![say(key(i, "boss")), Block::Win] } else { vec![say(key(i, "boss")), Block::SetVar { name: "boss".into(), value: n(1.0) }] };
sc.triggers.push(TriggerDoc { name: "boss".into(), kind: TriggerKind::UnitDies { side: 1, kind: String::new(), name: name.into() }, once: true, enabled: true, script: win });
if !sp.goals.is_empty() {
// KillAll covers the boss as well: nothing more to do
sc.victory = vec![];
}
}
doc.missions.push(m);
}
crate::map::set_dims(saved.0, saved.1);
doc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_coop_campaign_is_complete_and_valid() {
let doc = coop_campaign();
assert_eq!(doc.missions.len(), 20);
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
for m in &doc.missions {
assert!(m.size.0 >= 72 && m.size.1 >= 72, "{} fits the co-op master areas", m.key);
assert!(!m.goals.is_empty() || m.scripts.triggers.iter().any(|t| t.name == "boss"), "{} can be won", m.key);
assert!(matches!(m.map, MapDoc::Fixed { .. }));
}
// every mission starts and its scripts run
for (i, m) in doc.missions.iter().enumerate().step_by(5) {
let mut g = crate::campaign::start_editor_mission(m, i, 3);
for _ in 0..(20 * 5) {
g.update(0.05);
}
assert!(g.script.as_ref().unwrap().error.is_none());
assert_eq!(g.state, 0, "{} is not decided at once", m.key);
}
}
}
+92 -4
View File
@@ -35,7 +35,7 @@ enum Cat {
const CATS: [Cat; 9] = [Cat::Terrain, Cat::Rooms, Cat::Places, Cat::Units, Cat::Towers, Cat::Traps, Cat::Forts, Cat::Zones, Cat::Erase]; const CATS: [Cat; 9] = [Cat::Terrain, Cat::Rooms, Cat::Places, Cat::Units, Cat::Towers, Cat::Traps, Cat::Forts, Cat::Zones, Cat::Erase];
const TERRAIN: [&str; 13] = ["floor", "earth", "reinforced", "rock", "water", "lava", "bridge", "lavabridge", "gold", "silver", "ruby", "diamond", "steel"]; const TERRAIN: [&str; 17] = ["floor", "earth", "reinforced", "rock", "water", "lava", "bridge", "lavabridge", "gold", "silver", "ruby", "diamond", "steel", "mushroom", "grove", "grass", "tree"];
const PLACES: [&str; 7] = ["core", "portal", "gate", "spring", "shard", "chest", "chained"]; const PLACES: [&str; 7] = ["core", "portal", "gate", "spring", "shard", "chest", "chained"];
const OWNERS: [Owner; 6] = [Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3, Owner::None]; const OWNERS: [Owner; 6] = [Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3, Owner::None];
const ZONE_SHAPES: [&str; 4] = ["rect", "circle", "tiles", "line"]; const ZONE_SHAPES: [&str; 4] = ["rect", "circle", "tiles", "line"];
@@ -61,6 +61,8 @@ pub struct MapEdit {
zone: usize, // selected zone zone: usize, // selected zone
zone_shape: usize, // shape for a new zone zone_shape: usize, // shape for a new zone
zone_name: String, zone_name: String,
zone_erase: bool, // painting a tiles zone takes tiles out of it again
zone_scroll: usize, // first zone shown in the list (usize::MAX: scroll to the end)
last_mouse: Option<Vec2>, last_mouse: Option<Vec2>,
} }
@@ -73,6 +75,10 @@ fn kind_of_terrain(t: &str) -> (Kind, bool) {
"lava" => (Kind::Lava, false), "lava" => (Kind::Lava, false),
"bridge" => (Kind::Bridge, false), "bridge" => (Kind::Bridge, false),
"lavabridge" => (Kind::LavaBridge, false), "lavabridge" => (Kind::LavaBridge, false),
"mushroom" => (Kind::Mushroom, false),
"grove" => (Kind::Grove, false),
"grass" => (Kind::Floor, false),
"tree" => (Kind::Tree, false),
"gold" => (Kind::Gold, false), "gold" => (Kind::Gold, false),
"silver" => (Kind::Silver, false), "silver" => (Kind::Silver, false),
"ruby" => (Kind::Ruby, false), "ruby" => (Kind::Ruby, false),
@@ -117,6 +123,8 @@ impl MapEdit {
zone: 0, zone: 0,
zone_shape: 0, zone_shape: 0,
zone_name: String::new(), zone_name: String::new(),
zone_erase: false,
zone_scroll: 0,
last_mouse: None, last_mouse: None,
} }
} }
@@ -204,14 +212,27 @@ impl MapEdit {
} }
match self.cat { match self.cat {
Cat::Terrain => { Cat::Terrain => {
let (k, reinforced) = kind_of_terrain(TERRAIN[self.item.min(TERRAIN.len() - 1)]); let name = TERRAIN[self.item.min(TERRAIN.len() - 1)];
let (k, reinforced) = kind_of_terrain(name);
// grass and trees lie above ground; anything else is painted underground
self.game.map.set_sky(i, matches!(name, "grass" | "tree"));
self.game.torches.retain(|tr| tr.wall != i); self.game.torches.retain(|tr| tr.wall != i);
let owner = if k == Kind::Floor || reinforced || matches!(k, Kind::Bridge | Kind::LavaBridge) { OWNERS[self.owner] } else { Owner::None }; let owner = if k == Kind::Floor || reinforced || matches!(k, Kind::Bridge | Kind::LavaBridge) { OWNERS[self.owner] } else { Owner::None };
let tl = &mut self.game.map.t[i]; let tl = &mut self.game.map.t[i];
// a mushroom painted into a grove belongs to it (it leaves moss when felled); fully grown
if k == Kind::Mushroom {
tl.res = if tl.kind == Kind::Grove || tl.res & crate::map::MUSH_GROVE != 0 { crate::map::MUSH_GROVE } else { 0 };
tl.food = 0.0;
} else if tl.kind == Kind::Mushroom {
tl.res = 0;
}
tl.kind = k; tl.kind = k;
tl.reinforced = reinforced; tl.reinforced = reinforced;
tl.owner = if reinforced && owner == Owner::None { Owner::Player } else { owner }; tl.owner = if reinforced && owner == Owner::None { Owner::Player } else { owner };
tl.marked = false; tl.marked = false;
if tl.plant() {
tl.hp = if k == Kind::Tree { crate::wood::TREE_HP } else { crate::wood::MUSHROOM_HP };
}
tl.gold = match k { tl.gold = match k {
Kind::Gold => 1200, Kind::Gold => 1200,
Kind::Silver => 900, Kind::Silver => 900,
@@ -460,9 +481,18 @@ impl MapEdit {
} }
m.zones.push(z); m.zones.push(z);
self.zone = m.zones.len() - 1; self.zone = m.zones.len() - 1;
self.zone_scroll = usize::MAX;
self.zone_name.clear(); self.zone_name.clear();
} }
// the brush takes tiles out of the selected tiles zone
fn erase_zone_tiles(&mut self, m: &mut MissionDoc, x: i32, y: i32) {
let targets = self.targets(x, y);
if let Some(t) = m.zones.get_mut(self.zone).filter(|z| z.shape == "tiles").and_then(|z| z.tiles.as_mut()) {
t.retain(|p| !targets.contains(p));
}
}
pub fn frame(&mut self, im: &mut Im, gfx: &Gfx, r3d: &mut R3d, dt: f32, now: f64, m: &mut MissionDoc) -> MapResult { pub fn frame(&mut self, im: &mut Im, gfx: &Gfx, r3d: &mut R3d, dt: f32, now: f64, m: &mut MissionDoc) -> MapResult {
let (cssx, cssy) = mouse_position(); let (cssx, cssy) = mouse_position();
let bar_w = CW() * 30.0; let bar_w = CW() * 30.0;
@@ -547,6 +577,15 @@ impl MapEdit {
} }
im.pressed = false; im.pressed = false;
} }
// a tiles zone is painted (or erased) all along the drag
let tiles_zone = self.cat == Cat::Zones && ZONE_SHAPES[self.zone_shape] == "tiles";
if tiles_zone && im.down && self.drag.is_some() {
if self.zone_erase {
self.erase_zone_tiles(m, x, y);
} else {
self.draw_zone(m, x, y, x, y);
}
}
if self.stroke && im.down { if self.stroke && im.down {
for (tx, ty) in self.targets(x, y) { for (tx, ty) in self.targets(x, y) {
self.paint_tile(tx, ty); self.paint_tile(tx, ty);
@@ -555,7 +594,11 @@ impl MapEdit {
if im.released { if im.released {
if let Some((sx, sy)) = self.drag { if let Some((sx, sy)) = self.drag {
if self.cat == Cat::Zones { if self.cat == Cat::Zones {
self.draw_zone(m, sx, sy, x, y); if ZONE_SHAPES[self.zone_shape] == "tiles" && self.zone_erase {
self.erase_zone_tiles(m, x, y);
} else {
self.draw_zone(m, sx, sy, x, y);
}
} else { } else {
for (tx, ty) in self.targets(x, y) { for (tx, ty) in self.targets(x, y) {
self.paint_tile(tx, ty); self.paint_tile(tx, ty);
@@ -738,7 +781,24 @@ impl MapEdit {
label(gfx, x, y, &tr("ed.zones"), FRAME_HI); label(gfx, x, y, &tr("ed.zones"), FRAME_HI);
y += CH() * 1.4; y += CH() * 1.4;
let mut remove = None; let mut remove = None;
for k in 0..m.zones.len() { // the list scrolls (wheel over it) when there are more zones than room
let row = CH() * 1.7;
let room = (((h - CH() * 3.0 - y) / row).floor() as usize).max(1);
let over_list = im.mx < bar_w && im.my >= y && im.my < y + room as f32 * row;
if over_list && im.wheel != 0.0 {
self.zone_scroll = if im.wheel > 0.0 { self.zone_scroll.min(m.zones.len()).saturating_sub(1) } else { self.zone_scroll.saturating_add(1) };
}
let max_scroll = m.zones.len().saturating_sub(room);
if self.zone_scroll == usize::MAX {
// a new zone: show it
self.zone_scroll = self.zone.saturating_sub(room - 1);
}
self.zone_scroll = self.zone_scroll.min(max_scroll);
if self.zone_scroll > 0 || max_scroll > 0 {
let more = format!("{}-{} / {}", self.zone_scroll + 1, (self.zone_scroll + room).min(m.zones.len()), m.zones.len());
gfx.text(&more, x + fw - gfx.measure(&more, SMALL()), y - CH() * 1.3, SMALL(), DIM);
}
for k in self.zone_scroll..m.zones.len() {
if y > h - CH() * 3.0 { if y > h - CH() * 3.0 {
break; break;
} }
@@ -778,6 +838,10 @@ impl MapEdit {
} }
bx += gfx.measure(&tr("ed.fill"), S()) + CW() * 4.0; bx += gfx.measure(&tr("ed.fill"), S()) + CW() * 4.0;
} }
if self.cat == Cat::Zones && ZONE_SHAPES[self.zone_shape] == "tiles" {
im.toggle(gfx, bx, by + CH() * 0.15, &tr("ed.zone_erase"), &mut self.zone_erase);
bx += gfx.measure(&tr("ed.zone_erase"), S()) + CW() * 4.0;
}
let mirrors: Vec<String> = (0..4).map(|k| tr(&format!("ed.mirror{}", k))).collect(); let mirrors: Vec<String> = (0..4).map(|k| tr(&format!("ed.mirror{}", k))).collect();
if let Some(c) = im.dropdown(gfx, id("mirror", 0), Rect::new(bx, by, CW() * 23.0, CH() * 1.7), &mirrors[self.mirror as usize], &mirrors) { if let Some(c) = im.dropdown(gfx, id("mirror", 0), Rect::new(bx, by, CW() * 23.0, CH() * 1.7), &mirrors[self.mirror as usize], &mirrors) {
self.mirror = c as u8; self.mirror = c as u8;
@@ -799,3 +863,27 @@ impl MapEdit {
done done
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tiles_of_a_painted_zone_can_be_taken_out_again() {
let mut ed = MapEdit::new(Game::new(3, 64, 64));
let mut m = MissionDoc::default();
ed.zone_shape = ZONE_SHAPES.iter().position(|s| *s == "tiles").unwrap();
ed.brush = 2;
ed.draw_zone(&mut m, 10, 10, 10, 10);
ed.draw_zone(&mut m, 14, 10, 14, 10);
assert_eq!(m.zones.len(), 1, "painting goes on in the selected tiles zone");
let n = m.zones[0].tiles.as_ref().unwrap().len();
assert!(n >= 2);
ed.erase_zone_tiles(&mut m, 10, 10);
let t = m.zones[0].tiles.as_ref().unwrap();
assert!(!t.contains(&(10, 10)) && t.contains(&(14, 10)));
assert!(t.len() < n);
// a new zone scrolls the list to it
assert_eq!(ed.zone_scroll, usize::MAX);
}
}
+10
View File
@@ -6,6 +6,7 @@
// handles what it returns (test play, files, the savegame service). // handles what it returns (test play, files, the savegame service).
pub mod blocks_ui; pub mod blocks_ui;
pub mod coop_campaign;
pub mod map; pub mod map;
pub mod templates; pub mod templates;
pub mod widgets; pub mod widgets;
@@ -533,6 +534,13 @@ impl Editor {
self.open_dialog = false; self.open_dialog = false;
return None; return None;
} }
y += CH() * 2.2;
// the built-in co-op campaign, as a copy to change or to learn from
if im.button(gfx, Rect::new(x, y, bw * 2.0 + CW(), CH() * 1.7), &tr("net.coop_league")) {
self.load(coop_campaign::coop_campaign(), None);
self.open_dialog = false;
return None;
}
y += CH() * 2.6; y += CH() * 2.6;
// a mission of the built-in campaign as a copy // a mission of the built-in campaign as a copy
label(gfx, x, y, &tr("ed.template"), FRAME_HI); label(gfx, x, y, &tr("ed.template"), FRAME_HI);
@@ -769,6 +777,7 @@ impl Editor {
(tr("ed.rivals"), m.gen.rivals.to_string(), 6), (tr("ed.rivals"), m.gen.rivals.to_string(), 6),
(tr("ed.difficulty"), tr(["diff.0", "diff.1", "diff.2"][m.gen.difficulty.min(2) as usize]), 7), (tr("ed.difficulty"), tr(["diff.0", "diff.1", "diff.2"][m.gen.difficulty.min(2) as usize]), 7),
(tr("ed.portals"), m.portals.to_string(), 8), (tr("ed.portals"), m.portals.to_string(), 8),
(tr("ed.surface"), tr(if m.gen.surface { "val.on" } else { "val.off" }), 9),
]; ];
if fixed { if fixed {
rows.truncate(0); rows.truncate(0);
@@ -786,6 +795,7 @@ impl Editor {
5 => m.gen.forts = (m.gen.forts as i32 + d.signum()).clamp(0, 3) as u8, 5 => m.gen.forts = (m.gen.forts as i32 + d.signum()).clamp(0, 3) as u8,
6 => m.gen.rivals = (m.gen.rivals as i32 + d.signum()).clamp(0, 2) as u8, 6 => m.gen.rivals = (m.gen.rivals as i32 + d.signum()).clamp(0, 2) as u8,
7 => m.gen.difficulty = (m.gen.difficulty as i32 + d.signum()).clamp(0, 2) as u8, 7 => m.gen.difficulty = (m.gen.difficulty as i32 + d.signum()).clamp(0, 2) as u8,
9 => m.gen.surface = !m.gen.surface,
_ => m.portals = (m.portals as i32 + d.signum()).clamp(0, 6) as usize, _ => m.portals = (m.portals as i32 + d.signum()).clamp(0, 6) as usize,
} }
} }
+103 -2
View File
@@ -93,7 +93,7 @@ pub fn demo_campaign() -> CampaignDoc {
seed: 4242, seed: 4242,
gold: 4000.0, gold: 4000.0,
goals: vec!["Towers".into()], goals: vec!["Towers".into()],
gen: GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 0 }, gen: GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 0, surface: false },
next: Some("demo2".into()), next: Some("demo2".into()),
branches: vec![BranchDoc { id: "rich".into(), goal: "Loot(2000)".into(), title: tr("demo.branch"), next: "demo2".into(), priority: Some(1) }], branches: vec![BranchDoc { id: "rich".into(), goal: "Loot(2000)".into(), title: tr("demo.branch"), next: "demo2".into(), priority: Some(1) }],
pos: (40.0, 60.0), pos: (40.0, 60.0),
@@ -226,7 +226,7 @@ pub fn demo_campaign() -> CampaignDoc {
seed: 77, seed: 77,
gold: 3000.0, gold: 3000.0,
goals: vec!["KillAll".into()], goals: vec!["KillAll".into()],
gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 0, difficulty: 0 }, gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 0, difficulty: 0, surface: false },
pos: (300.0, 60.0), pos: (300.0, 60.0),
..MissionDoc::default() ..MissionDoc::default()
}; };
@@ -238,6 +238,16 @@ pub fn demo_campaign() -> CampaignDoc {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn the_tutorial_is_a_valid_campaign() {
let doc = super::tutorial_campaign();
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert_eq!(doc.missions.len(), 1);
let g = crate::campaign::start_editor_mission(&doc.missions[0], 0, 1);
assert!(g.script.is_some());
assert!(!g.ents.iter().any(|e| e.alive && e.active_hero()), "no knight before the last step");
}
use super::*; use super::*;
#[test] #[test]
@@ -278,3 +288,94 @@ mod tests {
assert!(g.mission.as_ref().unwrap().goals.contains(&Goal::Shards), "the shard of the outpost is still there"); assert!(g.mission.as_ref().unwrap().goals.contains(&Goal::Shards), "the shard of the outpost is still there");
} }
} }
// The tutorial: Crookfinger leads a new master step by step - dig the tunnel, claim the hall,
// build a lair and a mushroom garden, wait for the first creatures, slap one, and beat the first
// two knights. Each step switches on the trigger of the next one. Played like an editor campaign
// (title menu: Tutorial); texts come from the language files (tut.*).
pub fn tutorial_campaign() -> CampaignDoc {
let mut doc = CampaignDoc { name: tr("tut.name"), author: "DungeonCraft".into(), language: crate::i18n::language(), ..CampaignDoc::default() };
let mut m = MissionDoc {
key: "tutorial".into(),
title: tr("tut.title"),
story: tr("tut.story").replace("\\n", "\n"),
size: (64, 64),
seed: 777,
gold: 3000.0,
goals: Vec::new(),
gen: GenDoc { rivers: 0, lava: 0, forts: 0, rivals: 0, difficulty: 0, surface: false },
pos: (40.0, 60.0),
..MissionDoc::default()
};
let saved = (crate::map::mw(), crate::map::mh());
let mut g = crate::campaign::start_editor_mission(&m, 0, 777);
let (cx, cy) = g.map.core;
let (w, h) = (g.map.w as i32, g.map.h as i32);
let cl = |x: i32, y: i32| (x.clamp(3, w - 4), y.clamp(3, h - 4));
// east of the heart: a tunnel of earth to dig, then a hall of open ground behind it
let tunnel: Vec<(i32, i32)> = (cx + 4..cx + 8).flat_map(|x| [(x, cy + 1), (x, cy + 2)]).map(|(x, y)| cl(x, y)).collect();
let (hx0, hy0) = cl(cx + 8, cy - 2);
let (hx1, hy1) = cl(cx + 15, cy + 5);
for y in hy0 - 1..=hy1 + 1 {
for x in hx0..=hx1 + 1 {
let t = &mut g.map.t[crate::map::idx(x, y)];
let edge = y == hy0 - 1 || y == hy1 + 1 || x == hx1 + 1;
t.kind = if edge { crate::map::Kind::Earth } else { crate::map::Kind::Floor };
t.owner = crate::map::Owner::None;
t.gold = 0;
t.reinforced = false;
}
}
for &(x, y) in &tunnel {
let t = &mut g.map.t[crate::map::idx(x, y)];
t.kind = crate::map::Kind::Earth;
t.owner = crate::map::Owner::None;
t.hp = 3.0;
}
// the knights come from a cave further east
let (kx, ky) = cl(cx + 22, cy + 1);
for y in ky - 2..=ky + 2 {
for x in kx - 2..=kx + 2 {
let t = &mut g.map.t[crate::map::idx(x, y)];
t.kind = crate::map::Kind::Floor;
t.owner = crate::map::Owner::None;
}
}
for x in hx1 + 1..kx - 2 {
let t = &mut g.map.t[crate::map::idx(x, cy + 1)];
t.kind = crate::map::Kind::Earth;
t.owner = crate::map::Owner::None;
}
g.ents.retain(|e| !e.hero);
g.recount();
g.init_fog();
m.zones = vec![
ZoneDoc { name: "tunnel".into(), shape: "tiles".into(), at: None, radius: None, tiles: Some(tunnel), from: None, to: None, width: None },
zone_rect("hall", hx0, hy0, hx1, hy1),
ZoneDoc { name: "knights".into(), shape: "circle".into(), at: Some((kx as f32, ky as f32)), radius: Some(1.5), tiles: None, from: None, to: None, width: None },
];
m.map = MapDoc::Fixed { game: serde_json::to_string(&g).unwrap_or_default() };
crate::map::set_dims(saved.0, saved.1);
let s = &mut m.scripts;
let obj = |k: &str| Block::Objective { text: tr(k) };
let on = |t: &str| Block::Trigger { name: t.into(), on: true };
s.start = vec![say("tut.start"), obj("tut.obj1"), Block::SetVar { name: "knights".into(), value: n(0.0) }, Block::Camera { zone: "tunnel".into() }];
s.victory = vec![Block::If { cond: cmp(">=", var("knights"), n(2.0)), then: vec![say("tut.win"), Block::Win], els: vec![] }];
let off = |t: TriggerDoc| TriggerDoc { enabled: false, ..t };
s.triggers = vec![
trig("dug", TriggerKind::ZoneDug { zone: "tunnel".into() }, true, vec![say("tut.dug"), obj("tut.obj2"), on("lair")]),
off(trig("lair", TriggerKind::RoomBuilt { room: "Lair".into() }, true, vec![say("tut.lair"), obj("tut.obj3"), on("farm")])),
off(trig("farm", TriggerKind::RoomBuilt { room: "Farm".into() }, true, vec![say("tut.farm"), obj("tut.obj4"), on("creatures")])),
off(trig("creatures", TriggerKind::Value { what: "creatures".into(), var: String::new(), op: ">=".into(), n: 2.0 }, true, vec![say("tut.creatures"), obj("tut.obj5"), on("slap")])),
off(trig(
"slap",
TriggerKind::Slap,
true,
vec![say("tut.slap"), obj("tut.obj6"), Block::Wait { secs: n(20.0) }, Block::Spawn { side: 1, kind: "Knight".into(), count: n(2.0), level: n(1.0), place: Place::Zone("knights".into()), name: String::new() }, say("tut.knights"), Block::Camera { zone: "knights".into() }, on("knight")],
)),
off(trig("knight", TriggerKind::UnitDies { side: 1, kind: String::new(), name: String::new() }, false, vec![Block::AddVar { name: "knights".into(), value: n(1.0) }])),
];
doc.missions.push(m);
doc
}
+190
View File
@@ -0,0 +1,190 @@
// Random events in free games and versus matches (rare; can be switched off): an earthquake
// brings down tunnels near the dungeon, lava or water overflows its banks, or the imps find a
// new gold vein. Shared state and the shared random numbers: every client of a match sees the
// same. Campaigns have none (their stories are told by the missions).
use crate::game::*;
use crate::map::*;
use serde::{Deserialize, Serialize};
// seconds between two events: EVENT_GAP plus up to EVENT_SPREAD
pub const EVENT_GAP: f32 = 480.0;
pub const EVENT_SPREAD: f32 = 240.0;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Events {
pub t: f32, // seconds until the next one
}
impl Game {
// switches random events on (new free game or match) or off
pub fn set_events(&mut self, on: bool) {
self.happenings = if on && self.mission.is_none() { Some(Events { t: EVENT_GAP + self.rng.f() * EVENT_SPREAD }) } else { None };
}
pub(crate) fn update_events(&mut self, dt: f32) {
let Some(ev) = self.happenings.as_mut() else { return };
ev.t -= dt;
if ev.t > 0.0 {
return;
}
ev.t = EVENT_GAP + self.rng.f() * EVENT_SPREAD;
let kind = self.rng.next() % 4;
let happened = match kind {
0 => self.event_quake(),
1 => self.event_overflow(Kind::Lava),
2 => self.event_overflow(Kind::Water),
_ => self.event_vein(),
};
// nothing of that kind possible here: a gold vein instead (there is always earth)
if !happened && kind != 3 {
self.event_vein();
}
}
// own walkable ground within `r` of tile i?
fn near_own(&self, i: usize, r: i32) -> bool {
let (x, y) = xy(i);
(-r..=r).any(|dy| (-r..=r).any(|dx| self.map.get(x + dx, y + dy).map_or(false, |t| t.owner != Owner::None && t.owner != Owner::Hero && t.walkable())))
}
fn announce(&mut self, key: &'static str, i: usize) {
let (x, y) = xy(i);
let dir = format!("@{}", self.direction_key((x, y)));
// every master hears of it
let me = self.me_side;
for s in self.master_sides() {
self.switch_to(s);
self.msg(key, vec![dir.clone()]);
self.sfx_ui(Sfx::CoreHit);
}
self.switch_to(me);
self.add_mark(x as f32 + 0.5, y as f32 + 0.5, crate::game::MARK_BRIDGE, 255, 8.0, true);
}
// an earthquake: plain tunnels (unowned, not rooms) near a dungeon cave in
fn event_quake(&mut self) -> bool {
let n = self.map.t.len();
let cands: Vec<usize> = (0..n).filter(|&i| {
let t = &self.map.t[i];
let (x, y) = xy(i);
t.kind == Kind::Floor && t.owner == Owner::None && x > 2 && y > 2 && x < mw() as i32 - 3 && y < mh() as i32 - 3 && !self.map.sky(i) && self.near_own(i, 6)
}).collect();
if cands.len() < 4 {
return false;
}
let centre = cands[(self.rng.next() as usize) % cands.len()];
let (cx, cy) = xy(centre);
let mut fell = 0;
for &i in &cands {
let (x, y) = xy(i);
if (x - cx).abs() + (y - cy).abs() > 3 || self.rng.f() < 0.35 {
continue;
}
if self.ents.iter().any(|e| e.alive && e.tile() == i) {
continue; // nobody is buried alive (the quake is not that mean)
}
let t = &mut self.map.t[i];
t.kind = Kind::Earth;
t.hp = 6.0;
t.marked = false;
fell += 1;
}
if fell == 0 {
return false;
}
self.announce("msg.event_quake", centre);
true
}
// a river overflows its banks: unowned open ground next to it fills
fn event_overflow(&mut self, river: Kind) -> bool {
let n = self.map.t.len();
let bank: Vec<usize> = (0..n).filter(|&i| {
let t = &self.map.t[i];
let (x, y) = xy(i);
// no lava above ground
t.kind == Kind::Floor && t.owner == Owner::None && !(river == Kind::Lava && self.map.sky(i))
&& DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |o| o.kind == river))
}).collect();
if bank.is_empty() {
return false;
}
let start = bank[(self.rng.next() as usize) % bank.len()];
let (sx, sy) = xy(start);
let mut done = 0;
for &i in &bank {
let (x, y) = xy(i);
if (x - sx).abs() + (y - sy).abs() > 4 || done >= 8 {
continue;
}
self.map.t[i].kind = river;
self.ground_lost(i);
done += 1;
}
self.announce(if river == Kind::Lava { "msg.event_lava" } else { "msg.event_flood" }, start);
true
}
// the imps find a gold vein: earth near a dungeon turns to gold
fn event_vein(&mut self) -> bool {
let n = self.map.t.len();
let cands: Vec<usize> = (0..n).filter(|&i| self.map.t[i].kind == Kind::Earth && !self.map.t[i].reinforced && self.near_own(i, 5)).collect();
if cands.is_empty() {
return false;
}
let centre = cands[(self.rng.next() as usize) % cands.len()];
let (cx, cy) = xy(centre);
for &i in &cands {
let (x, y) = xy(i);
if (x - cx).abs() + (y - cy).abs() <= 2 {
let t = &mut self.map.t[i];
t.kind = Kind::Gold;
t.gold = 900;
}
}
self.announce("msg.event_vein", centre);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_games_have_rare_events() {
let mut g = Game::new(12, 80, 64);
g.set_events(true);
assert!(g.happenings.is_some());
let golds = |g: &Game| g.map.t.iter().filter(|t| t.kind == Kind::Gold).count();
let before = golds(&g);
g.happenings.as_mut().unwrap().t = 0.0;
g.rng = Rng(3); // a gold vein (or another event) happens now
g.update_events(0.1);
assert!(g.happenings.as_ref().unwrap().t >= EVENT_GAP);
assert!(g.msgs.iter().any(|m| m.key.starts_with("msg.event_")), "an event was told");
let _ = before;
// campaigns have none
let mut m = crate::campaign::start_mission(3, None);
m.set_events(true);
assert!(m.happenings.is_none());
}
#[test]
fn every_event_can_happen() {
let mut g = Game::new(12, 80, 64);
let (cx, cy) = g.map.core;
// a little lava and water next to unowned ground near the dungeon
for (x, k) in [(cx + 6, Kind::Lava), (cx + 9, Kind::Water)] {
for y in cy - 2..cy + 3 {
g.map.t[idx(x, y)].kind = k;
g.map.t[idx(x + 1, y)].kind = Kind::Floor;
g.map.t[idx(x + 1, y)].owner = Owner::None;
}
}
assert!(g.event_vein());
assert!(g.event_overflow(Kind::Lava));
assert!(g.event_overflow(Kind::Water));
}
}
+2 -2
View File
@@ -137,7 +137,7 @@ impl Game {
} }
} }
// what the creatures and towers see along open ground (walls stop the view) // what the creatures and towers see along open ground (walls stop the view)
let mut eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && (e.side == self.me_side || self.allied(self.me_side, e.side)) && !e.chained).map(|e| (e.tile(), SIGHT)).collect(); let mut eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && (e.side == self.me_side || self.allied(self.me_side, e.side)) && !e.chained).map(|e| (e.tile(), self.sight_at(e.tile(), SIGHT))).collect();
let solo = self.masters.is_empty(); let solo = self.masters.is_empty();
eyes.extend(self.towers.iter().filter(|t| !t.hero && (solo || allies.contains(&self.map.t[t.tile].owner))).map(|t| (t.tile, t.range() as i32 + 1))); eyes.extend(self.towers.iter().filter(|t| !t.hero && (solo || allies.contains(&self.map.t[t.tile].owner))).map(|t| (t.tile, t.range() as i32 + 1)));
let mut stamp = vec![0u32; n]; let mut stamp = vec![0u32; n];
@@ -228,7 +228,7 @@ mod tests {
assert!(g.map.shown(out), "own ground beyond the lifted start is known"); assert!(g.map.shown(out), "own ground beyond the lifted start is known");
assert!(g.map.explored.iter().filter(|&&b| b).count() > explored); assert!(g.map.explored.iter().filter(|&&b| b).count() > explored);
// savegame round trip // savegame round trip
let json = crate::save::to_json(&g, &crate::render3d::Cam::new(0.0, 0.0), &crate::settings::Settings::default()); let json = crate::save::to_json(&g);
let l = crate::save::from_bytes(json.as_bytes()).unwrap(); let l = crate::save::from_bytes(json.as_bytes()).unwrap();
assert_eq!(l.game.map.explored, g.map.explored); assert_eq!(l.game.map.explored, g.map.explored);
// the seer's eye opens a far area for a while // the seer's eye opens a far area for a while
+695 -117
View File
File diff suppressed because it is too large. Load diff
+337 -29
View File
@@ -80,11 +80,26 @@ pub const T_ARENA: u32 = 178;
pub const T_ALTAR: u32 = 182; pub const T_ALTAR: u32 = 182;
pub const I_ORDERS: u32 = 186; // attack, guard pub const I_ORDERS: u32 = 186; // attack, guard
pub const T_RIVAL3: u32 = 189; // 4 variants: floor of the yellow master pub const T_RIVAL3: u32 = 189; // 4 variants: floor of the yellow master
pub const I_DRAWBRIDGE: u32 = 193; // drawbridge tool: a raised flap on chains
pub const I_FREEZE: u32 = 194; // frost spell: a snowflake
pub const T_OBSIDIAN: u32 = 195; // 4 variants: cooled lava, dark glass with glowing seams
pub const T_STEM: u32 = 199; // giant mushroom stem
pub const T_CAP: u32 = 200; // giant mushroom cap, glowing spots
pub const T_BARK: u32 = 201; // tree trunk
pub const T_LEAVES: u32 = 202; // 2 variants: tree crown
pub const T_GRASS: u32 = 204; // 4 variants: grass above ground
pub const I_WALL: u32 = 208; // wall tool: bricks rising from the floor
pub const I_RAM: u32 = 209; // battering ram tool
pub const I_CATAPULT: u32 = 210; // catapult tool
pub const T_SNOW: u32 = 211; // 4 variants: snow on the land above ground (winter)
pub const T_PUMPKIN: u32 = 215; // a carved pumpkin (Halloween)
pub const T_MOSS: u32 = 216; // 4 variants: mushroom grove ground, moss with glowing spores
pub struct Gfx { pub struct Gfx {
pub atlas: Texture2D, pub atlas: Texture2D,
pub sprites: Texture2D, pub sprites: Texture2D,
pub cursors: Texture2D, // mouse pointer frames, 16x16 each (see CURSOR_*) pub cursors: Texture2D, // mouse pointer frames, 16x16 each (see CURSOR_*)
pub badges: Texture2D, // achievement badges, 32x32 each (see badges.rs)
pub font: Texture2D, // Dinowyde (glyphs of 6x8 px ordered by Unicode, see font_slot) and DINOBYTE pub font: Texture2D, // Dinowyde (glyphs of 6x8 px ordered by Unicode, see font_slot) and DINOBYTE
pub font_map: FontMap, // which glyphs the two fonts hold pub font_map: FontMap, // which glyphs the two fonts hold
} }
@@ -803,6 +818,164 @@ fn gen_atlas() -> Image {
None None
} }
}); });
// drawbridge tool icon: a raised flap with its chains above a river
cv.cell(I_DRAWBRIDGE, |x, y| {
if (3..=5).contains(&x) && (2..=12).contains(&y) {
Some(if y % 3 == 0 { c(70, 46, 26) } else { c(150, 106, 60) })
} else if y == 12 && (5..=13).contains(&x) {
Some(c(110, 76, 44))
} else if (x - 5) * 2 == (y - 2) * 1 + 2 && (6..=12).contains(&x) {
Some(c(170, 170, 180)) // chain
} else if x == 13 && (3..=12).contains(&y) {
Some(c(90, 90, 100)) // post
} else if y >= 13 && (1..=14).contains(&x) {
Some(c(60, 110, 170))
} else {
None
}
});
// wall tool icon: a block of bricks with an arrow up
cv.cell(I_WALL, |x, y| {
if (3..=12).contains(&x) && (6..=14).contains(&y) {
let row = (y - 6) / 3;
let joint = (y - 6) % 3 == 2 || (x + row * 3) % 6 == 0;
Some(if joint { c(60, 40, 30) } else { c(150, 100, 70) })
} else if y <= 4 && (x - 8).abs() <= 4 - y.max(1) + 1 && y >= 1 {
Some(c(200, 140, 255))
} else if y == 5 && (7..=9).contains(&x) {
Some(c(200, 140, 255))
} else {
None
}
});
// siege icons: a ram (a trunk with an iron cap under a roof), a catapult (arm and stone)
cv.cell(I_RAM, |x, y| {
if (3..=12).contains(&x) && y == 4 + (x - 3).min(12 - x).min(3) * 0 && false {
None
} else if y >= 3 && y <= 5 && (x - 7).abs() <= 6 - (y - 3) * 0 && (x + y) % 2 == 0 {
Some(c(92, 58, 32))
} else if (7..=9).contains(&y) && (1..=13).contains(&x) {
Some(if x <= 3 { c(170, 175, 190) } else { c(140, 92, 52) })
} else if y >= 11 && y <= 13 && ((x - 4).abs() <= 1 || (x - 11).abs() <= 1) {
Some(c(30, 24, 26))
} else if y == 10 && (3..=12).contains(&x) {
Some(c(92, 58, 32))
} else {
None
}
});
cv.cell(I_CATAPULT, |x, y| {
let arm = x + y == 14 && (3..=11).contains(&x);
if (x - 12).pow(2) + (y - 3).pow(2) <= 3 {
Some(c(150, 152, 140))
} else if arm {
Some(c(140, 92, 52))
} else if y == 11 && (2..=13).contains(&x) {
Some(c(92, 58, 32))
} else if y >= 12 && ((x - 4).abs() <= 1 || (x - 11).abs() <= 1) {
Some(c(30, 24, 26))
} else {
None
}
});
// frost spell icon: a snowflake
cv.cell(I_FREEZE, |x, y| {
let (dx, dy) = (x - 8, y - 8);
let arm = dx == 0 || dy == 0 || dx == dy || dx == -dy;
let r = dx.abs().max(dy.abs());
if arm && r <= 6 {
Some(if r <= 1 { c(240, 250, 255) } else { c(150, 210, 255) })
} else if r == 4 && (dx.abs() == 1 || dy.abs() == 1) && (dx == 0 || dy == 0 || dx.abs() == dy.abs() + 1 || dy.abs() == dx.abs() + 1) {
Some(c(200, 235, 255))
} else {
None
}
});
// giant mushroom: pale ribbed stem, a violet cap with glowing spots
cv.cell(T_STEM, |x, y| {
let r = hash(x, y, 410);
Some(if x % 4 == 0 { c(150, 140, 130) } else if r % 9 == 0 { c(170, 160, 150) } else { c(200, 192, 178) })
});
cv.cell(T_CAP, |x, y| {
let spot = |cx: i32, cy: i32| (x - cx).pow(2) + (y - cy).pow(2) <= 2;
if spot(4, 4) || spot(11, 6) || spot(6, 11) || spot(12, 12) {
Some(c(150, 255, 230))
} else if (x + y) % 5 == 0 {
Some(c(90, 50, 130))
} else {
Some(c(120, 66, 170))
}
});
// tree: bark and two kinds of crown
cv.cell(T_BARK, |x, y| {
let r = hash(x, y, 420);
Some(if x % 5 == 0 || r % 7 == 0 { c(60, 40, 24) } else { c(96, 66, 40) })
});
for v in 0..2u32 {
cv.cell(T_LEAVES + v, |x, y| {
let r = hash(x, y, 430 + v);
// dark, bluish green: clearly apart from the light meadow
Some(if r % 5 == 0 { c(14, 40, 30) } else if r % 7 == 0 { c(52, 96, 62) } else if v == 0 { c(26, 64, 44) } else { c(32, 72, 40) })
});
}
// snow above ground in winter
for v in 0..4u32 {
cv.cell(T_SNOW + v, |x, y| {
let r = hash(x, y, 450 + v);
Some(if r % 9 == 0 { c(214, 222, 236) } else if r % 23 == 0 { c(150, 170, 120) } else { c(236, 240, 248) })
});
}
// a carved pumpkin: orange ribs, a grinning face
cv.cell(T_PUMPKIN, |x, y| {
let eye = (y == 5 || y == 6) && (x == 4 || x == 5 || x == 10 || x == 11);
let mouth = (y == 10 && (3..=12).contains(&x)) || (y == 11 && (5..=10).contains(&x) && x % 2 == 0);
if eye || mouth {
Some(c(255, 230, 90))
} else if x % 4 == 0 {
Some(c(190, 90, 20))
} else {
Some(c(240, 128, 30))
}
});
// grass above ground
for v in 0..4u32 {
cv.cell(T_GRASS + v, |x, y| {
let r = hash(x, y, 440 + v);
// a light, yellowish meadow with flowers
Some(if r % 11 == 0 { c(176, 200, 96) } else if r % 6 == 0 { c(112, 150, 62) } else if r % 23 == 0 { c(236, 220, 110) } else if r % 37 == 0 { c(230, 236, 240) } else { c(140, 176, 78) })
});
}
// mushroom grove: dark stones overgrown with teal moss, a few glowing spores
for v in 0..4u32 {
cv.cell(T_MOSS + v, |x, y| {
let r = hash(x, y, 520 + v);
let moss = (hash(x / 3, y / 3, 530 + v) % 5) < 3;
Some(if r % 41 == 0 {
c(150, 255, 220) // a spore glows
} else if r % 29 == 0 {
c(200, 150, 255)
} else if moss {
if r % 4 == 0 { c(46, 112, 96) } else if r % 3 == 0 { c(30, 84, 74) } else { c(38, 98, 84) }
} else {
stones(x, y, v, (52, 58, 56), (26, 30, 30), 8)
})
});
}
// obsidian: cooled lava, dark glass with a few glowing seams
for v in 0..4u32 {
cv.cell(T_OBSIDIAN + v, |x, y| {
let r = hash(x, y, v + 400);
if (x * 5 + y * 3 + v as i32 * 7) % 29 == 0 || ((x + 2 * y + v as i32) % 23 == 0 && r % 2 == 0) {
Some(c(230, 90, 30))
} else if r % 11 == 0 {
Some(c(70, 60, 90))
} else if (x + y) % 7 == 0 {
Some(c(34, 26, 40))
} else {
Some(c(22, 16, 26))
}
});
}
// ---- more treasures: veins (earth with embedded pieces) and treasury piles // ---- more treasures: veins (earth with embedded pieces) and treasury piles
let vein = |x: i32, y: i32, v: u32, seed: u32, hi: Color, mid: Color, lo: Color, size: f32| -> Color { let vein = |x: i32, y: i32, v: u32, seed: u32, hi: Color, mid: Color, lo: Color, size: f32| -> Color {
let mut best = 99.0f32; let mut best = 99.0f32;
@@ -1282,6 +1455,58 @@ fn sprite_rows(k: EK) -> [&'static str; 10] {
"..kBkkBk..", "..kBkkBk..",
"..kk..kk..", "..kk..kk..",
], ],
// thief: a dark hood and mask, a dagger, a sack of gold on the back
EK::Thief => [
"...kkkk...",
"..kddddk..",
"..kdfkfk.Y",
"..kddddkYy",
".kdddddkYy",
"skdddddk..",
".kdkdddk..",
"..kddddk..",
"..kdkkdk..",
"..kk..kk..",
],
// monk: shaven head, an orange robe tied with a rope, bare fists
EK::Monk => [
"...kkkk...",
"..kffffk..",
"..kfkfkk..",
"..kffffk..",
".kooooook.",
"kfoooooofk",
".kooyyook.",
".kOooooOk.",
".kOooooOk.",
"..kkkkkk..",
],
// ranger: a green hood and cloak, a long bow, leather boots
EK::Ranger => [
"...kkkk.b.",
"..kgGGgkkb",
"..kGfkfk.b",
"..kGfffk.b",
".kGgggggkb",
".kGgbbggkb",
".kGggggk.b",
"..kBBBBk.b",
"..kBkkBkb.",
"..kk..kk..",
],
// inquisitor: a tall red hat, white and red robes, a glowing holy book
EK::Inquisitor => [
"...kRRk...",
"..kRrrRk..",
"..kRrrRk..",
"..kffffk..",
"..kfkfkk..",
".kwwrrwwk.",
"kwkwrrwkek",
".kwwrrwwkk",
".kRwwwwRk.",
"..kkkkkk..",
],
EK::Priest => [ EK::Priest => [
"....yy....", "....yy....",
"...kkkk...", "...kkkk...",
@@ -1419,6 +1644,71 @@ fn sprite_rows(k: EK) -> [&'static str; 10] {
"...y..y...", "...y..y...",
"..........", "..........",
], ],
// trader: a hooded merchant with a big pack and a lantern
EK::Trader => [
"...kkk....",
"..kPPPk.BB",
".kPfefPkBb",
".kPffffkBb",
"..kPPPPkBB",
".kPPyPPPk.",
"ykPPPPPPk.",
"o.kPPPPk..",
"..kPk.kPk.",
"..kk...kk.",
],
// cave bear: shaggy brown fur, small yellow eyes
EK::CaveBear => [
".kk....kk.",
"kBBk..kBBk",
"kBBBBBBBBk",
"kBeBBBBeBk",
"kBBBkkBBBk",
".kBBffBBk.",
"kBBBBBBBBk",
"kBbBBBBbBk",
".kBBkkBBk.",
".kkk..kkk.",
],
// giant beetle: a shiny dark-green shell, mandibles, six legs
EK::Beetle => [
"..k....k..",
"...k..k...",
"..kGGGGk..",
".kGeGGeGk.",
"kGGgGGgGGk",
"kGgGGGGgGk",
"kGGgGGgGGk",
".kGGGGGGk.",
"k.kGGGGk.k",
".k.k..k.k.",
],
// battering ram: a roofed frame on wheels, an iron-capped trunk
EK::Ram => [
"..........",
"...BBBB...",
"..BbbbbB..",
".BbbbbbbB.",
"SsbbbbbbB.",
"SsBBBBBBB.",
"..b.b.b.b.",
".kBk..kBk.",
".kck..kck.",
"..k....k..",
],
// catapult: a throwing arm with a stone in its cup
EK::Catapult => [
"......cc..",
".....kCCk.",
".....kbk..",
"....kb....",
"...kb.....",
"..kb......",
".BBBBBBBB.",
".bkbbbbkb.",
".kck..kck.",
"..k....k..",
],
// battle mage: robes of the order, a burning staff // battle mage: robes of the order, a burning staff
EK::BattleMage => [ EK::BattleMage => [
"....kk...o", "....kk...o",
@@ -1501,7 +1791,7 @@ fn pal(ch: char) -> Option<Color> {
}) })
} }
pub const SPRITE_KINDS: [EK; 22] = [ pub const SPRITE_KINDS: [EK; 31] = [
EK::Worker, EK::Worker,
EK::Kobold, EK::Kobold,
EK::Spider, EK::Spider,
@@ -1524,6 +1814,15 @@ pub const SPRITE_KINDS: [EK; 22] = [
EK::Giant, EK::Giant,
EK::Fairy, EK::Fairy,
EK::BattleMage, EK::BattleMage,
EK::Ram,
EK::Catapult,
EK::CaveBear,
EK::Beetle,
EK::Trader,
EK::Thief,
EK::Monk,
EK::Ranger,
EK::Inquisitor,
]; ];
// width of the sprite texture in pixels // width of the sprite texture in pixels
pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) as f32; pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) as f32;
@@ -1541,7 +1840,7 @@ pub fn gear_tier(level: u32) -> u32 {
} }
fn is_hero_kind(k: EK) -> bool { fn is_hero_kind(k: EK) -> bool {
matches!(k, EK::Knight | EK::Archer | EK::Priest | EK::Dwarf | EK::Paladin | EK::Giant | EK::Fairy | EK::BattleMage) matches!(k, EK::Knight | EK::Archer | EK::Priest | EK::Dwarf | EK::Paladin | EK::Giant | EK::Fairy | EK::BattleMage | EK::Thief | EK::Monk | EK::Ranger | EK::Inquisitor)
} }
// Views of a sprite for eight directions (the others are mirrored): 0 front, 1 front // Views of a sprite for eight directions (the others are mirrored): 0 front, 1 front
@@ -1773,39 +2072,41 @@ const CUR_POINT: [&str; 16] = [
"...kkkkkkkk.....", "...kkkkkkkk.....",
"................", "................",
]; ];
// the hand tool: an open goblin hand (four fingers, the thumb at the side), lit fingertips
const CUR_OPEN: [&str; 16] = [ const CUR_OPEN: [&str; 16] = [
"...kk.kk.kk.....",
"..klkklkklk.kk..",
"..kgkkgkkgk.klk.",
"..kgkkgkkgkkgk..",
"..kgkkgkkgkkgk..",
"..kggggggggggk..",
"kk.kgggggggggk..",
"klkkggggggggGk..",
"kggkggggggggGk..",
".kgggggggggGGk..",
"..kggggggggGk...",
"...kgggggggGk...",
"....kggggGGk....",
".....kkkkkk.....",
"................", "................",
".kw.kw.kw.......",
".kgkkgkkgk..kw..",
".kgkkgkkgk.kgk..",
".kgkkgkkgkkggk..",
".kgggggggggggk..",
"kkgggggggggggk..",
"kgkggggggggggk..",
"kggkgggggggGk...",
".kggggggggggk...",
"..kgggggggGk....",
"...kggggggGk....",
"...kgggggGk.....",
"....kGGGGk......",
"....kkkkkk......",
"................", "................",
]; ];
// holding units: a fist, knuckles on top, the thumb across
const CUR_FIST: [&str; 16] = [ const CUR_FIST: [&str; 16] = [
"................", "................",
"................", "................",
"................", "....kkkkkkk.....",
"...kkkkkkkk.....", "...klklklklk....",
"..kgkgkgkgGk....", "..kgkgkgkgGk....",
"..kgkgkgkgGk....", "..kgkgkgkgGGk...",
".kkggggggggGk...", ".kkkkkkkggGGk...",
"kggkgggggggGk...", "kllllllkgGGk....",
"kgggggggggGGk...", "kggggggkgGGk....",
".kggggggggGk....", ".kkkkkkggGGk....",
"..kgggggggGk....", "..kgggggggGk....",
"...kggggggGk....", "...kggggggGk....",
"...kGGGGGGk.....", "....kGGGGGk.....",
"....kkkkkk......", ".....kkkkk......",
"................", "................",
"................", "................",
]; ];
@@ -1815,6 +2116,7 @@ fn cursor_pal(ch: char) -> Option<Color> {
'k' => c(20, 16, 18), 'k' => c(20, 16, 18),
'g' => c(112, 152, 84), 'g' => c(112, 152, 84),
'G' => c(70, 100, 56), 'G' => c(70, 100, 56),
'l' => c(168, 206, 124),
'w' => c(236, 230, 208), 'w' => c(236, 230, 208),
'y' => c(255, 220, 80), 'y' => c(255, 220, 80),
's' => c(255, 255, 255), 's' => c(255, 255, 255),
@@ -1861,7 +2163,9 @@ impl Gfx {
font.set_filter(FilterMode::Nearest); font.set_filter(FilterMode::Nearest);
let cursors = Texture2D::from_image(&gen_cursors()); let cursors = Texture2D::from_image(&gen_cursors());
cursors.set_filter(FilterMode::Nearest); cursors.set_filter(FilterMode::Nearest);
Gfx { atlas, sprites, cursors, font, font_map } let badges = Texture2D::from_image(&crate::badges::gen_badges());
badges.set_filter(FilterMode::Nearest);
Gfx { atlas, sprites, cursors, badges, font, font_map }
} }
pub fn cell(id: u32) -> Rect { pub fn cell(id: u32) -> Rect {
@@ -1927,7 +2231,11 @@ impl Gfx {
Kind::Tower => T_CLAIM + v, Kind::Tower => T_CLAIM + v,
Kind::Water => T_WATER + v, Kind::Water => T_WATER + v,
Kind::Lava => T_LAVA + v, Kind::Lava => T_LAVA + v,
Kind::Bridge => T_BRIDGE + (v & 1), Kind::Bridge | Kind::Drawbridge => T_BRIDGE + (v & 1),
Kind::Obsidian => T_OBSIDIAN + v,
Kind::Mushroom => T_FLOOR + v,
Kind::Tree => T_GRASS + v,
Kind::Grove => T_MOSS + v,
Kind::LavaBridge => T_STONEBRIDGE + (v & 1), Kind::LavaBridge => T_STONEBRIDGE + (v & 1),
Kind::Shard => T_HEROFLOOR + v, Kind::Shard => T_HEROFLOOR + v,
Kind::Aerie => T_AERIE + v, Kind::Aerie => T_AERIE + v,
@@ -2137,8 +2445,8 @@ mod doc_images {
for (k, t) in crate::traps::TRAP_KINDS.iter().enumerate() { for (k, t) in crate::traps::TRAP_KINDS.iter().enumerate() {
save(&atlas, Gfx::cell(I_TRAPS + k as u32), 4, &format!("docs/img/icons/{}.png", t.id())); save(&atlas, Gfx::cell(I_TRAPS + k as u32), 4, &format!("docs/img/icons/{}.png", t.id()));
} }
for (k, s) in crate::spells::SPELLS.iter().enumerate() { for s in crate::spells::SPELLS.iter() {
save(&atlas, Gfx::cell(I_SPELLS + k as u32), 4, &format!("docs/img/icons/{}.png", s.id())); save(&atlas, Gfx::cell(crate::spells::spell_icon(*s)), 4, &format!("docs/img/icons/{}.png", s.id()));
} }
for (id, cell) in [("lightning", I_BOLT), ("heal", I_HEAL), ("summon", I_SUMMON), ("arrow", I_ARROW), ("cannon", I_CANNON), ("magic", I_MAGIC), ("rune", I_RUNE)] { for (id, cell) in [("lightning", I_BOLT), ("heal", I_HEAL), ("summon", I_SUMMON), ("arrow", I_ARROW), ("cannon", I_CANNON), ("magic", I_MAGIC), ("rune", I_RUNE)] {
save(&atlas, Gfx::cell(cell), 4, &format!("docs/img/icons/{}.png", id)); save(&atlas, Gfx::cell(cell), 4, &format!("docs/img/icons/{}.png", id));
+20 -1
View File
@@ -12,6 +12,19 @@ const SOURCES: &[(&str, &str)] = &[
("es", include_str!("../lang/es.lang")), ("es", include_str!("../lang/es.lang")),
("ru", include_str!("../lang/ru.lang")), ("ru", include_str!("../lang/ru.lang")),
("it", include_str!("../lang/it.lang")), ("it", include_str!("../lang/it.lang")),
("pt", include_str!("../lang/pt.lang")),
("nl", include_str!("../lang/nl.lang")),
("da", include_str!("../lang/da.lang")),
("no", include_str!("../lang/no.lang")),
("sv", include_str!("../lang/sv.lang")),
("fi", include_str!("../lang/fi.lang")),
("hr", include_str!("../lang/hr.lang")),
("hu", include_str!("../lang/hu.lang")),
("sr", include_str!("../lang/sr.lang")),
("ro", include_str!("../lang/ro.lang")),
("sk", include_str!("../lang/sk.lang")),
("cs", include_str!("../lang/cs.lang")),
("el", include_str!("../lang/el.lang")),
]; ];
// order of the languages in the language dropdown // order of the languages in the language dropdown
@@ -84,7 +97,13 @@ pub fn tr(key: &str) -> String {
pub fn trf(key: &str, args: &[String]) -> String { pub fn trf(key: &str, args: &[String]) -> String {
let mut s = tr(key); let mut s = tr(key);
for (n, a) in args.iter().enumerate() { for (n, a) in args.iter().enumerate() {
let v = if let Some(k) = a.strip_prefix('@') { tr(k) } else { a.clone() }; let v = if let Some(k) = a.strip_prefix('@') {
tr(k)
} else if let Some(n) = a.strip_prefix('%') {
crate::names::from_arg(n) // a creature's name (names.rs)
} else {
a.clone()
};
s = s.replace(&format!("{{{}}}", n), &v); s = s.replace(&format!("{{{}}}", n), &v);
} }
s s
+71 -3
View File
@@ -90,6 +90,8 @@ pub struct StartInfo {
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match pub teams: Vec<(u8, u8)>, // (side, team) of a versus match
pub next: bool, // the next mission of a running editor campaign co-op match pub next: bool, // the next mission of a running editor campaign co-op match
pub carry: Vec<(u8, crate::campaign::Carry)>, // what every side brings into that mission pub carry: Vec<(u8, crate::campaign::Carry)>, // what every side brings into that mission
pub surface: bool, // a free match whose eastern half lies above ground
pub events: bool, // random events in a free match
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -223,6 +225,19 @@ impl MatchClient {
self.send_create(sides, map_w, map_h, difficulty, None); self.send_create(sides, map_w, map_h, difficulty, None);
} }
// a free match; `surface`: its eastern half lies above ground
pub fn create_land(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, surface: bool, events: bool) {
let n = self.outbox.len();
self.send_create(sides, map_w, map_h, difficulty, None);
if let (true, Some(text)) = (surface || events, self.outbox.get_mut(n)) {
if let Ok(mut msg) = serde_json::from_str::<Value>(text) {
msg["surface"] = json!(surface);
msg["events"] = json!(events);
*text = msg.to_string();
}
}
}
pub fn create_coop(&mut self, mission: usize, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) { pub fn create_coop(&mut self, mission: usize, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) {
self.send_create(sides, map_w, map_h, difficulty, Some(mission)); self.send_create(sides, map_w, map_h, difficulty, Some(mission));
} }
@@ -230,10 +245,23 @@ impl MatchClient {
// a co-op match of an editor campaign: the whole campaign file goes to the relay, which hands // a co-op match of an editor campaign: the whole campaign file goes to the relay, which hands
// it to every client with the start message // it to every client with the start message
pub fn create_coop_doc(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8) { pub fn create_coop_doc(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8) {
self.create_coop_doc_resumed(doc, mission, sides, difficulty, None);
}
// the same, going on with an interrupted run: its seed and what every master carries along
pub fn create_coop_doc_resumed(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8, resume: Option<(u32, &[(u8, crate::campaign::Carry)])>) {
let Some(m) = doc.missions.get(mission) else { return }; let Some(m) = doc.missions.get(mission) else { return };
let (map_w, map_h) = m.size; let (map_w, map_h) = m.size;
let doc = serde_json::to_value(doc).unwrap_or(Value::Null); let doc = serde_json::to_value(doc).unwrap_or(Value::Null);
let n = self.outbox.len();
self.send_create_with(sides, map_w, map_h, difficulty, Some(mission), Some(doc)); self.send_create_with(sides, map_w, map_h, difficulty, Some(mission), Some(doc));
if let (Some((seed, carry)), Some(text)) = (resume, self.outbox.get_mut(n)) {
if let Ok(mut msg) = serde_json::from_str::<Value>(text) {
msg["seed"] = json!(seed);
msg["carry"] = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
*text = msg.to_string();
}
}
} }
fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>) { fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>) {
@@ -534,7 +562,12 @@ impl MatchClient {
} }
r.ai = ai.clone(); r.ai = ai.clone();
} }
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: false, carry: Vec::new() }); // a resumed editor campaign run goes on like the next mission of a running one
let resume = editor && v.get("resume").and_then(|x| x.as_bool()).unwrap_or(false);
let carry: Vec<(u8, crate::campaign::Carry)> = if resume { v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default() } else { Vec::new() };
let surface = v.get("surface").and_then(|x| x.as_bool()).unwrap_or(false);
let events = v.get("events").and_then(|x| x.as_bool()).unwrap_or(false);
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: resume, carry, surface, events });
self.error = None; self.error = None;
self.status = None; self.status = None;
} }
@@ -565,7 +598,7 @@ impl MatchClient {
} }
r.ai = ai.clone(); r.ai = ai.clone();
} }
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission: Some(mission), ai, doc: Some(doc), editor: true, teams, next: true, carry }); self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission: Some(mission), ai, doc: Some(doc), editor: true, teams, next: true, carry, surface: false, events: false });
self.error = None; self.error = None;
} }
@@ -640,6 +673,17 @@ fn teams_from(sides: &[u8], v: Option<&Value>) -> Vec<(u8, u8)> {
sides.iter().copied().zip(teams).collect() sides.iter().copied().zip(teams).collect()
} }
// versus map sizes and how many master seats each one has
pub const MATCH_SIZES: [usize; 7] = [80, 96, 128, 160, 192, 224, 256];
pub fn seats_for(size: usize) -> &'static [u8] {
match size {
0..=80 => &[0, 2],
81..=96 => &[0, 2, 3],
_ => &[0, 2, 3, 4],
}
}
fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> { fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
v.and_then(|x| x.as_array()) v.and_then(|x| x.as_array())
.map(|arr| { .map(|arr| {
@@ -772,7 +816,7 @@ mod tests {
use crate::blocks::{Block, Val}; use crate::blocks::{Block, Val};
use crate::campaign_doc::*; use crate::campaign_doc::*;
let mut doc = CampaignDoc { name: "Coop test".into(), ..CampaignDoc::default() }; let mut doc = CampaignDoc { name: "Coop test".into(), ..CampaignDoc::default() };
let mut m = MissionDoc { key: "c1".into(), title: "Random".into(), size: (80, 80), seed: 5, goals: vec!["KillAll".into(), "Rivals".into()], waves: true, first_wave: 3.0, gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 1, difficulty: 1 }, ..MissionDoc::default() }; let mut m = MissionDoc { key: "c1".into(), title: "Random".into(), size: (80, 80), seed: 5, goals: vec!["KillAll".into(), "Rivals".into()], waves: true, first_wave: 3.0, gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 1, difficulty: 1, surface: false }, ..MissionDoc::default() };
m.scripts.start = vec![Block::Gold { amount: Val::num(250.0) }]; m.scripts.start = vec![Block::Gold { amount: Val::num(250.0) }];
m.scripts.triggers = vec![TriggerDoc { name: "wave".into(), kind: TriggerKind::Timer { secs: 2.0, every: true }, once: false, enabled: true, script: vec![Block::Wave { count: Val::num(1.0) }] }]; m.scripts.triggers = vec![TriggerDoc { name: "wave".into(), kind: TriggerKind::Timer { secs: 2.0, every: true }, once: false, enabled: true, script: vec![Block::Wave { count: Val::num(1.0) }] }];
doc.missions.push(m.clone()); doc.missions.push(m.clone());
@@ -898,6 +942,30 @@ mod tests {
assert_eq!(start.teams, vec![(0, 1), (2, 1)]); assert_eq!(start.teams, vec![(0, 1), (2, 1)]);
} }
#[test]
fn an_interrupted_coop_run_goes_on_with_its_seed_and_carry() {
let doc = coop_doc();
let mut host = MatchClient::new();
host.on_connect("red");
pop(&mut host); // rooms
let carry = vec![(0u8, crate::campaign::Carry { gold: 250.0, ..Default::default() })];
host.create_coop_doc_resumed(&doc, 1, &[0, 2, 3, 4], 1, Some((4242, &carry)));
let create = pop(&mut host);
assert_eq!(create["seed"].as_u64(), Some(4242));
assert_eq!(create["carry"][0][0].as_u64(), Some(0));
assert_eq!(create["mission"].as_u64(), Some(1));
let start = json!({"t":"start","seed":4242,"map_w":96,"map_h":96,"difficulty":1,"sides":[0,2],"mission":1,"ai":[],"doc":serde_json::to_value(&doc).unwrap(),"carry":create["carry"].clone(),"resume":true}).to_string();
host.feed(&start);
let info = host.take_start().unwrap();
assert!(info.next, "a resumed run keeps its progress");
assert_eq!(info.seed, 4242);
assert_eq!(info.carry.len(), 1);
assert_eq!(info.carry[0].1.gold, 250.0);
// old stored progress without the run position still reads
let p: crate::campaign_doc::CampaignProgress = serde_json::from_str(r#"{"done":[true],"completed_choice":[null],"run_seed":5}"#).unwrap();
assert_eq!((p.next, p.carry.len()), (None, 0));
}
#[test] #[test]
fn two_clients_play_the_same_editor_campaign_mission_in_coop() { fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
let doc = coop_doc(); let doc = coop_doc();
+373 -36
View File
@@ -1,12 +1,19 @@
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). // DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
// Windows release builds are a window program: no console opens beside the game
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
mod achievements;
mod ai;
mod armory;
mod audio; mod audio;
mod badges;
mod campaign; mod campaign;
mod campaign_doc; mod campaign_doc;
mod cloud; mod cloud;
mod commands; mod commands;
mod demo; mod demo;
mod editor; mod editor;
mod events;
mod dwarves; mod dwarves;
mod fog; mod fog;
mod fortress; mod fortress;
@@ -24,23 +31,37 @@ mod particles;
mod props; mod props;
mod research; mod research;
mod rival; mod rival;
mod rogues;
mod balance;
mod talents;
mod weather;
mod names;
mod render3d; mod render3d;
mod rooms; mod rooms;
mod save; mod save;
#[cfg(test)] #[cfg(test)]
mod scenes; mod scenes;
mod beasts;
mod blocks; mod blocks;
mod bosses;
mod bridges;
mod mscript; mod mscript;
mod settings; mod settings;
mod siege;
mod sfx; mod sfx;
mod tracks; mod tracks;
mod traders;
mod traps; mod traps;
mod sound; mod sound;
mod surface;
mod spells; mod spells;
mod stats;
mod touch; mod touch;
mod ui; mod ui;
mod voice; mod voice;
mod walls;
mod wilds; mod wilds;
mod wood;
mod xm; mod xm;
use cloud::{Cloud, CloudEvent}; use cloud::{Cloud, CloudEvent};
@@ -83,6 +104,7 @@ struct Input {
rlast: (f32, f32), rlast: (f32, f32),
rdrag: bool, rdrag: bool,
mlast: Option<(f32, f32)>, // middle button drag mlast: Option<(f32, f32)>, // middle button drag
mclick: f64, // time of the last middle click (a double click resets the view)
minimap_drag: bool, minimap_drag: bool,
// touch // touch
tlast: Option<Vec2>, // last position of a one-finger pan (device pixels) tlast: Option<Vec2>, // last position of a one-finger pan (device pixels)
@@ -106,7 +128,7 @@ fn tool_tile(cam: &Cam, map: &map::Map, tool: Tool, mx: f32, my: f32) -> Option<
} }
fn area_tool(t: Tool) -> bool { fn area_tool(t: Tool) -> bool {
matches!(t, Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel) matches!(t, Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel | Tool::Drawbridge | Tool::Wall)
} }
// Use the active tool on the world at (mx, my) (CSS pixels): a click, or a tap on touch screens // Use the active tool on the world at (mx, my) (CSS pixels): a click, or a tap on touch screens
@@ -119,10 +141,19 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32,
let ground = app.cam.ground_point(mx, my); let ground = app.cam.ground_point(mx, my);
let (wx, wy) = (ground.x, ground.y); let (wx, wy) = (ground.x, ground.y);
match (ui.tool, tile) { match (ui.tool, tile) {
(Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel, Some(t)) => inp.ldown = Some(t), (Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel | Tool::Drawbridge | Tool::Wall, Some(t)) => inp.ldown = Some(t),
(Tool::Hand, _) => { (Tool::Hand, _) => {
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false); let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
let Some(j) = j else { return }; let Some(j) = j else {
// a memorial stone: the hall of the fallen opens
let (gx, gy) = (wx.floor() as i32, wy.floor() as i32);
if map::inb(gx, gy) && app.game.memorial_at(map::idx(gx, gy)).is_some() {
ui.show_overview = true;
ui.ov_fallen = true;
ui.ov_scroll = 0;
}
return;
};
let (id, hero, dwarf) = (app.game.ents[j].id, app.game.ents[j].hero, app.game.ents[j].dwarf()); let (id, hero, dwarf) = (app.game.ents[j].id, app.game.ents[j].hero, app.game.ents[j].dwarf());
if dwarf { if dwarf {
return; return;
@@ -142,6 +173,7 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32,
(Tool::Tower(k), Some((x, z))) => app.game.queue(Act::Tower { kind: k, x, y: z }), (Tool::Tower(k), Some((x, z))) => app.game.queue(Act::Tower { kind: k, x, y: z }),
(Tool::Trap(k), Some((x, z))) => app.game.queue(Act::Trap { kind: k, x, y: z }), (Tool::Trap(k), Some((x, z))) => app.game.queue(Act::Trap { kind: k, x, y: z }),
(Tool::Spell(sp), _) => app.game.queue(Act::Cast { spell: sp, x: wx, y: wy }), (Tool::Spell(sp), _) => app.game.queue(Act::Cast { spell: sp, x: wx, y: wy }),
(Tool::Siege(which), Some((tx, ty))) => app.game.queue(Act::Siege { which, x: tx, y: ty }),
(Tool::Order(0), _) => { (Tool::Order(0), _) => {
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false); let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
let ent = j.filter(|&j| j < app.game.ents.len()).map(|j| app.game.ents[j].id); let ent = j.filter(|&j| j < app.game.ents.len()).map(|j| app.game.ents[j].id);
@@ -162,13 +194,21 @@ fn finish_rect(app: &mut App, tool: Tool, start: (i32, i32), mx: f32, my: f32) {
Tool::Room(room) => app.game.queue(Act::Room { room: room as u8, r }), Tool::Room(room) => app.game.queue(Act::Room { room: room as u8, r }),
Tool::Sell => app.game.queue(Act::Sell(r)), Tool::Sell => app.game.queue(Act::Sell(r)),
Tool::Bridge => app.game.queue(Act::Bridge(r)), Tool::Bridge => app.game.queue(Act::Bridge(r)),
Tool::Drawbridge => app.game.queue(Act::Drawbridge(r)),
Tool::Wall => app.game.queue(Act::Wall(r)),
Tool::Steel => app.game.queue(Act::Steel(r)), Tool::Steel => app.game.queue(Act::Steel(r)),
_ => {} _ => {}
} }
} }
fn new_game(seed: u32, s: &Settings) -> (Game, Cam) { fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
let g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty); let mut g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty);
if s.surface {
// the eastern half above ground: forest, paths, cave mouths
g.add_surface(seed, 0.5, 2 + (s.map_h / 64) as u32);
g.init_fog();
}
g.set_events(s.events);
let (cx, cy) = g.map.core; let (cx, cy) = g.map.core;
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
(g, cam) (g, cam)
@@ -225,10 +265,17 @@ fn store_progress(p: &campaign::Progress) {
// progress of editor campaigns played in co-op, kept by the host: campaign name -> progress // progress of editor campaigns played in co-op, kept by the host: campaign name -> progress
const COOP_PROGRESS_KEY: &str = "dc.coop_editor_progress"; const COOP_PROGRESS_KEY: &str = "dc.coop_editor_progress";
fn coop_progress_all() -> std::collections::BTreeMap<String, campaign_doc::CampaignProgress> { pub(crate) fn coop_progress_all() -> std::collections::BTreeMap<String, campaign_doc::CampaignProgress> {
platform::storage_get(COOP_PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() platform::storage_get(COOP_PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
} }
// where an interrupted co-op run of this campaign goes on: mission, run seed, carry
pub(crate) fn coop_resume_of(doc: &campaign_doc::CampaignDoc) -> Option<(usize, u32, Vec<(u8, campaign::Carry)>)> {
let p = coop_progress_all().remove(&doc.name)?;
let next = p.next.filter(|n| doc.missions.get(*n).map_or(false, campaign::is_editor_coop_mission))?;
Some((next, p.run_seed, p.carry))
}
fn store_coop_progress(name: &str, p: &campaign_doc::CampaignProgress) { fn store_coop_progress(name: &str, p: &campaign_doc::CampaignProgress) {
let mut all = coop_progress_all(); let mut all = coop_progress_all();
all.insert(name.to_string(), p.clone()); all.insert(name.to_string(), p.clone());
@@ -260,6 +307,9 @@ impl CoopRun {
let mut p = coop_progress_all().remove(&self.doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&self.doc)); let mut p = coop_progress_all().remove(&self.doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&self.doc));
p.run_seed = self.run_seed; p.run_seed = self.run_seed;
p.mark_done(self.mission, self.doc.missions.len(), choice); p.mark_done(self.mission, self.doc.missions.len(), choice);
// the run goes on with the next mission (or is over)
p.next = self.next;
p.carry = self.carry_out.clone();
store_coop_progress(&self.doc.name, &p); store_coop_progress(&self.doc.name, &p);
} }
} else { } else {
@@ -326,7 +376,11 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
} }
} }
MenuAction::MatchJoin(code) => app.client.join(&code), MenuAction::MatchJoin(code) => app.client.join(&code),
MenuAction::MatchCreate => app.client.create(&[0, 2, 3, 4], app.settings.map_w, app.settings.map_h, app.settings.difficulty), MenuAction::MatchCreate => {
// the map size decides the number of master seats
let n = app.settings.match_size;
app.client.create_land(lobby::seats_for(n), n, n, app.settings.difficulty, app.settings.surface, app.settings.events)
}
MenuAction::MatchCoopCreate(mission) => { MenuAction::MatchCoopCreate(mission) => {
let all = campaign::missions(); let all = campaign::missions();
if let Some(d) = all.get(mission) { if let Some(d) = all.get(mission) {
@@ -336,12 +390,41 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
} }
} }
MenuAction::MatchCoopEditorCreate(mission) => { MenuAction::MatchCoopEditorCreate(mission) => {
if menu.campaign_doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) { let doc = menu.coop_doc().clone();
app.client.create_coop_doc(&menu.campaign_doc, mission, &[0, 2, 3, 4], app.settings.difficulty); if doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) {
app.client.create_coop_doc(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty);
} else { } else {
menu.error = Some(i18n::tr("net.coop_editor_none")); menu.error = Some(i18n::tr("net.coop_editor_none"));
} }
} }
MenuAction::MatchCoopSolo(doc, mission) => {
// the open lobby connection is not needed: the game runs on this computer alone
if app.start_offline_coop(doc, mission, None, Vec::new()) {
menu.page = None;
menu.error = None;
app.set_status(i18n::tr("net.coop_solo_started"), now);
} else {
menu.error = Some(i18n::tr("net.coop_editor_none"));
}
}
MenuAction::MatchCoopResume(doc) => {
// with the relay: a room that goes on with the run; without it: offline with an AI partner
match coop_resume_of(&doc) {
Some((mission, seed, carry)) if app.client.connected => {
app.client.create_coop_doc_resumed(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty, Some((seed, &carry)));
}
Some((mission, seed, carry)) => {
if app.start_offline_coop(Some(doc), mission, Some(seed), carry) {
menu.page = None;
menu.error = None;
app.set_status(i18n::tr("net.coop_solo_started"), now);
} else {
menu.error = Some(i18n::tr("net.coop_editor_none"));
}
}
None => menu.error = Some(i18n::tr("net.coop_editor_none")),
}
}
MenuAction::MatchTakeSide(side) => app.client.take_side(side), MenuAction::MatchTakeSide(side) => app.client.take_side(side),
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai), MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai),
MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team), MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team),
@@ -396,7 +479,7 @@ fn apply_settings(s: &Settings, r3d: &mut R3d) {
r3d.outlines = s.outlines; r3d.outlines = s.outlines;
r3d.set_wall_detail(s.wall_subdivisions()); r3d.set_wall_detail(s.wall_subdivisions());
r3d.max_lights = s.max_lights as usize; r3d.max_lights = s.max_lights as usize;
r3d.render_div = s.render_scale.clamp(1, 16) as u32; r3d.pixel_rows = if [270, 360, 480].contains(&s.pixel_rows) { s.pixel_rows as u32 } else { 360 };
audio::set_volumes(s.music_volume, s.volume, s.muted); audio::set_volumes(s.music_volume, s.volume, s.muted);
} }
@@ -420,7 +503,8 @@ impl MusicDirector {
t t
} }
fn update(&mut self, g: &Game, title: bool, cam: (f32, f32)) { // `chosen`: a track picked in the music player; it replaces the calm music
fn update(&mut self, g: &Game, title: bool, cam: (f32, f32), chosen: Option<usize>) {
use audio::Category as C; use audio::Category as C;
let alive = |f: &dyn Fn(&game::Ent) -> bool| g.ents.iter().any(|e| e.alive && f(e)); let alive = |f: &dyn Fn(&game::Ent) -> bool| g.ents.iter().any(|e| e.alive && f(e));
let near_room = |k: map::Kind| { let near_room = |k: map::Kind| {
@@ -435,6 +519,19 @@ impl MusicDirector {
} }
n >= 4 n >= 4
}; };
// the camera looks at the land above ground: the music of the surface
let above = {
let (cx, cz) = (cam.0 as i32, cam.1 as i32);
let mut n = 0;
for y in (cz - 4..=cz + 4).step_by(2) {
for x in (cx - 4..=cx + 4).step_by(2) {
if map::inb(x, y) && g.map.sky(map::idx(x, y)) {
n += 1;
}
}
}
n >= 13
};
let want = if title { let want = if title {
C::Title C::Title
} else if g.state == 1 { } else if g.state == 1 {
@@ -456,11 +553,35 @@ impl MusicDirector {
} else { } else {
C::Build C::Build
}; };
let rotate = matches!(want, C::Build | C::Title) && self.current == Some(want) && audio::music_loops() >= 2; let want = match want {
if self.current != Some(want) || rotate || audio::current_track().is_none() { C::Battle if above => C::SurfaceBattle,
C::Build | C::Torture | C::Temple if above => C::Surface,
w => w,
};
let calm = matches!(want, C::Build | C::Title | C::Surface | C::Torture | C::Temple);
if let (true, Some(t)) = (calm, chosen) {
if audio::current_track() != Some(t) {
let fade = if self.current.map_or(false, |c| matches!(c, C::Victory | C::Defeat)) { 1.0 } else { 2.5 };
audio::play_music(t, fade);
}
self.current = Some(want);
return;
}
let rotate = matches!(want, C::Build | C::Title | C::Surface) && self.current == Some(want) && audio::music_loops() >= 2;
// after the chosen track was let go, the director picks again
let foreign = chosen.is_none() && audio::current_track().map_or(false, |t| crate::tracks::TRACKS.get(t).map_or(false, |x| x.1 != want)) && calm && self.current == Some(want);
if self.current != Some(want) || rotate || foreign || audio::current_track().is_none() {
let before = self.current;
self.current = Some(want); self.current = Some(want);
let t = self.pick(want); let t = self.pick(want);
let fade = if matches!(want, C::Victory | C::Defeat) { 1.0 } else { 2.5 }; // between the dungeon and the surface the music changes slowly
let fade = if matches!(want, C::Victory | C::Defeat) {
1.0
} else if matches!((before, want), (Some(C::Surface | C::SurfaceBattle), _) | (_, C::Surface | C::SurfaceBattle)) {
4.0
} else {
2.5
};
audio::play_music(t, fade); audio::play_music(t, fade);
} }
} }
@@ -499,6 +620,8 @@ struct App {
coop: bool, coop: bool,
coop_editor: bool, // the co-op match plays a mission of an editor campaign coop_editor: bool, // the co-op match plays a mission of an editor campaign
coop_run: Option<CoopRun>, // ... and this is where the run through that campaign stands coop_run: Option<CoopRun>, // ... and this is where the run through that campaign stands
offline: bool, // a co-op game played without the server: the other seat is an AI partner
tutorial: bool, // the tutorial mission is being played (its end leads back to the title)
fps: f32, fps: f32,
renderer: String, renderer: String,
frame_ms: f32, frame_ms: f32,
@@ -521,20 +644,20 @@ impl App {
self.status = Some((text, now)); self.status = Some((text, now));
} }
// Load a savegame (settings in the savegame are always applied) // Load a savegame (only the game state; the settings stay as they are). The camera looks at
// the heart (screenshot scenes bring their own camera).
fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) { fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) {
match save::from_bytes(bytes) { match save::from_bytes(bytes) {
Ok(l) => { Ok(l) => {
self.game = l.game; self.game = l.game;
if let Some(c) = l.camera { match l.camera {
self.cam = c; Some(c) => self.cam = c,
self.cam.snap(); None => {
} let (cx, cy) = self.game.map.core;
if let Some(s) = l.settings { self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
self.settings = s; }
apply_settings(&self.settings, r3d);
store_settings(&self.settings);
} }
self.cam.snap();
apply_game_settings(&mut self.game, &self.settings); apply_game_settings(&mut self.game, &self.settings);
self.game.coop = false; self.game.coop = false;
self.coop = false; self.coop = false;
@@ -550,7 +673,7 @@ impl App {
} }
fn autosave_json(&self) -> String { fn autosave_json(&self) -> String {
save::to_json(&self.game, &self.cam, &self.settings) save::to_json(&self.game)
} }
fn connect_match(&mut self, cloud: &Cloud) -> bool { fn connect_match(&mut self, cloud: &Cloud) -> bool {
@@ -623,9 +746,32 @@ impl App {
self.match_mode = false; self.match_mode = false;
self.coop = false; self.coop = false;
self.coop_editor = false; self.coop_editor = false;
self.offline = false;
self.client.on_disconnect(); self.client.on_disconnect();
} }
// A co-op mission without the server: built like a match start with the player on side 0 and an
// AI partner on side 2, then the world runs on this computer alone (no turns, no relay).
// `doc` is an editor campaign (None: a built-in co-op mission); `run_seed`/`carry` continue a run.
fn start_offline_coop(&mut self, doc: Option<campaign_doc::CampaignDoc>, mission: usize, run_seed: Option<u32>, carry: Vec<(u8, campaign::Carry)>) -> bool {
let size = match &doc {
Some(d) => d.missions.get(mission).map(|m| m.size),
None => campaign::missions().get(mission).map(|m| m.size),
};
let Some((map_w, map_h)) = size else { return false };
let next = run_seed.is_some();
let seed = run_seed.unwrap_or_else(|| next_seed(self.seed));
let editor = doc.is_some();
let info = StartInfo { seed, map_w, map_h, difficulty: self.settings.difficulty, sides: vec![0, 2], local_side: Some(0), mission: Some(mission), ai: vec![2], doc, editor, teams: Vec::new(), next, carry, surface: false, events: false };
if !self.start_match_game(info) {
return false;
}
self.game.end_match();
self.offline = true;
self.recorded = false;
true
}
// the shared part of a co-op start (built-in or editor mission): the masters, AI seats, settings // the shared part of a co-op start (built-in or editor mission): the masters, AI seats, settings
fn start_coop_game(&mut self, mut game: Game, info: &StartInfo) -> bool { fn start_coop_game(&mut self, mut game: Game, info: &StartInfo) -> bool {
let local = info.local_side.unwrap_or(0); let local = info.local_side.unwrap_or(0);
@@ -668,11 +814,14 @@ impl App {
self.coop_editor = true; self.coop_editor = true;
self.recorded = false; self.recorded = false;
self.pending_briefing = briefing_of(doc, mission); self.pending_briefing = briefing_of(doc, mission);
// the host keeps the progress of the run (a new run starts with the room's seed) // the host keeps the progress of the run (a new run starts with the room's seed) and
if !info.next && info.local_side == Some(0) { // where it stands, so an interrupted run can go on later
if info.local_side == Some(0) {
let mut p = coop_progress_all().remove(&doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(doc)); let mut p = coop_progress_all().remove(&doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(doc));
p.ensure(doc.missions.len()); p.ensure(doc.missions.len());
p.run_seed = info.seed; p.run_seed = info.seed;
p.next = Some(mission);
p.carry = info.carry.clone();
store_coop_progress(&doc.name, &p); store_coop_progress(&doc.name, &p);
} }
self.coop_run = Some(CoopRun { doc: doc.clone(), mission, run_seed: info.seed, carry_in: info.carry.clone(), next: None, carry_out: Vec::new(), sent: false }); self.coop_run = Some(CoopRun { doc: doc.clone(), mission, run_seed: info.seed, carry_in: info.carry.clone(), next: None, carry_out: Vec::new(), sent: false });
@@ -693,6 +842,11 @@ impl App {
if !self.game.start_match(&info.sides, local) { if !self.game.start_match(&info.sides, local) {
return false; return false;
} }
if info.surface {
// the same land on every client: built from the shared seed
self.game.add_surface(info.seed, 0.5, 2 + (info.map_h / 64) as u32);
}
self.game.set_events(info.events);
self.game.coop = false; self.game.coop = false;
self.game.set_teams(&info.teams); self.game.set_teams(&info.teams);
self.game.ai_difficulty = info.difficulty; self.game.ai_difficulty = info.difficulty;
@@ -737,6 +891,12 @@ async fn main() {
let mut r3d = R3d::new(); let mut r3d = R3d::new();
let mut sounds = SoundBank::new(); let mut sounds = SoundBank::new();
let mut music = MusicDirector::new(); let mut music = MusicDirector::new();
// lifetime statistics and achievements (browser storage, merged with the server after a login)
let stored_profile: achievements::Profile = platform::storage_get(achievements::PROFILE_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
let mut tracker = achievements::Tracker::new(stored_profile);
let mut want_profile = false; // fetch the server's profile when the client is free
let mut profile_store_t = 0.0f32;
let mut profile_upload_t = 0.0f32;
let mut ui = Ui::new(); let mut ui = Ui::new();
let mut menu = Menu::new(); let mut menu = Menu::new();
let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable
@@ -757,10 +917,10 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1; let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle // behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed); let (game, cam) = Game::new_demo(seed);
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, coop_run: None, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None }; let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, coop_run: None, offline: false, tutorial: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None };
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
apply_game_settings(&mut app.game, &app.settings); apply_game_settings(&mut app.game, &app.settings);
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, mclick: -1.0, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
let mut touch = TouchInput::new(); let mut touch = TouchInput::new();
let mut auto_touch = platform::coarse_pointer(); let mut auto_touch = platform::coarse_pointer();
let mut menu_swipe: Option<(f32, f32)> = None; // a swipe over a menu: last finger y, unused distance let mut menu_swipe: Option<(f32, f32)> = None; // a swipe over a menu: last finger y, unused distance
@@ -771,8 +931,12 @@ async fn main() {
let mut voice = voice::Voice::new(); let mut voice = voice::Voice::new();
let mut was_briefing = false; let mut was_briefing = false;
let mut cache_t = 0.0f32; let mut cache_t = 0.0f32;
#[cfg(not(target_arch = "wasm32"))]
audio::native::start();
loop { loop {
#[cfg(not(target_arch = "wasm32"))]
audio::native::pump();
let raw_dt = get_frame_time(); let raw_dt = get_frame_time();
let dt = raw_dt.min(0.1); let dt = raw_dt.min(0.1);
let now = get_time(); let now = get_time();
@@ -801,6 +965,7 @@ async fn main() {
} }
if app.editor.is_some() && !app.editor_testing { if app.editor.is_some() && !app.editor_testing {
editor_frame(&mut app, &mut menu, &mut cloud, &mut r3d, &gfx, &mut ui, dt, now); editor_frame(&mut app, &mut menu, &mut cloud, &mut r3d, &gfx, &mut ui, dt, now);
ui::end_frame();
next_frame().await; next_frame().await;
continue; continue;
} }
@@ -820,6 +985,22 @@ async fn main() {
_ => auto_touch, _ => auto_touch,
}; };
ui::set_touch(touch_mode); ui::set_touch(touch_mode);
// pixel look: the interface on a virtual screen whose pixels are whole parts of the world's
let rows = if [270, 360, 480].contains(&app.settings.pixel_rows) { app.settings.pixel_rows as u32 } else { 360 };
// the interface needs room: the smallest whole multiple of the world's rows with at least
// 900 rows (360 -> 1080), on a desktop and on a touch screen alike (a phone has about
// 1080 device rows in landscape; a smaller screen draws in device pixels)
let need: u32 = 900;
// measured on the shorter side: upright (portrait) the width gets those pixels and the
// height grows with the screen, so the world and the menus keep their size. The setting
// "interface size" picks the interface pixels per world pixel: large 2, medium 3, small 4
let short = match app.settings.ui_factor {
k @ 2..=4 => rows * k as u32,
_ => rows * need.div_ceil(rows),
};
let (w, h) = (screen_width().max(1.0), screen_height().max(1.0));
let vrows = if h > w { (short as f32 * h / w).round() as u32 } else { short };
ui::set_virtual_rows(vrows);
if touch_mode { if touch_mode {
// the pointer is the finger, or the position of this frame's gesture // the pointer is the finger, or the position of this frame's gesture
let gpos = gestures.iter().rev().find_map(|g| match *g { let gpos = gestures.iter().rev().find_map(|g| match *g {
@@ -882,6 +1063,7 @@ async fn main() {
} }
} }
CloudEvent::LoggedIn => { CloudEvent::LoggedIn => {
want_profile = true;
if menu.page == Some(Page::Login) { if menu.page == Some(Page::Login) {
menu.page = Some(Page::Title); menu.page = Some(Page::Title);
} }
@@ -1027,6 +1209,7 @@ async fn main() {
} }
let p = mouse_position(); let p = mouse_position();
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, p, false, now); draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, p, false, now);
ui::end_frame();
next_frame().await; next_frame().await;
continue; continue;
} }
@@ -1178,6 +1361,16 @@ async fn main() {
if is_key_down(key(Action::TiltDown)) { if is_key_down(key(Action::TiltDown)) {
cam.pitch -= rot * 0.6; cam.pitch -= rot * 0.6;
} }
// a double click with the middle button: the standard angle and turn (same place)
if is_mouse_button_pressed(MouseButton::Middle) && !touch_mode {
let now = get_time();
if now - inp.mclick < 0.35 {
cam.reset_view();
inp.mclick = -1.0;
} else {
inp.mclick = now;
}
}
if is_mouse_button_down(MouseButton::Middle) && !touch_mode { if is_mouse_button_down(MouseButton::Middle) && !touch_mode {
if let Some((lx, ly)) = inp.mlast { if let Some((lx, ly)) = inp.mlast {
let inv = if keys.invert_mouse { -1.0 } else { 1.0 }; let inv = if keys.invert_mouse { -1.0 } else { 1.0 };
@@ -1238,8 +1431,8 @@ async fn main() {
if !inp.rdrag && !over_ui && app.game.state == 0 { if !inp.rdrag && !over_ui && app.game.state == 0 {
if inp.ldown.is_some() { if inp.ldown.is_some() {
inp.ldown = None; inp.ldown = None;
} else if app.game.hand.is_empty() && matches!(ui.tool, Tool::Order(_)) { } else if app.game.hand.is_empty() && matches!(ui.tool, Tool::Order(_) | Tool::Flag) {
app.game.queue(Act::CancelOrders); app.game.queue(Act::Unmark { x: wx, y: wy });
} else { } else {
app.game.queue(Act::Drop { x: wx, y: wy }); app.game.queue(Act::Drop { x: wx, y: wy });
} }
@@ -1310,7 +1503,7 @@ async fn main() {
MenuAction::Save | MenuAction::FileSave => { MenuAction::Save | MenuAction::FileSave => {
let name = save_file_name(); let name = save_file_name();
platform::download(&name, app.autosave_json().as_bytes()); platform::download(&name, app.autosave_json().as_bytes());
app.set_status(i18n::trf("save.saved", &[name]), now); app.set_status(i18n::trf("save.saved", &[platform::saved_as(&name)]), now);
} }
MenuAction::CloudSave(id, name) => { MenuAction::CloudSave(id, name) => {
if cloud.save(id.as_deref(), &name, &app.autosave_json()) { if cloud.save(id.as_deref(), &name, &app.autosave_json()) {
@@ -1357,7 +1550,7 @@ async fn main() {
MenuAction::CampaignSave => { MenuAction::CampaignSave => {
let name = menu.campaign_doc.file_name(); let name = menu.campaign_doc.file_name();
platform::download(&name, menu.campaign_doc.save().as_bytes()); platform::download(&name, menu.campaign_doc.save().as_bytes());
app.set_status(i18n::trf("campaign.saved", &[name]), now); app.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)]), now);
} }
MenuAction::CampaignEditName => { MenuAction::CampaignEditName => {
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) { if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) {
@@ -1446,6 +1639,24 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
} }
} }
} }
MenuAction::Tutorial => {
let doc = editor::templates::tutorial_campaign();
let progress = campaign_doc::CampaignProgress::for_doc(&doc);
(app.game, app.cam) = custom_mission_game(&doc, 0, progress.run_seed);
app.pending_briefing = briefing_of(&doc, 0);
apply_game_settings(&mut app.game, &app.settings);
app.game.coop = false;
app.coop = false;
app.recorded = false;
app.custom_campaign = Some(doc);
app.custom_progress = Some(progress);
app.custom_mission_index = Some(0);
app.tutorial = true;
menu.page = None;
ui.log.clear();
ui.show_help = false;
autosave_t = 0.0;
}
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL), MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL), MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL),
MenuAction::OpenOpenCode => platform::open_url(menu::OPENCODE_URL), MenuAction::OpenOpenCode => platform::open_url(menu::OPENCODE_URL),
@@ -1470,6 +1681,8 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
| MenuAction::MatchAbort | MenuAction::MatchAbort
| MenuAction::MatchLeave | MenuAction::MatchLeave
| MenuAction::MatchCoopEditorCreate(..) | MenuAction::MatchCoopEditorCreate(..)
| MenuAction::MatchCoopSolo(..)
| MenuAction::MatchCoopResume(..)
| MenuAction::MatchChat(..) | MenuAction::MatchChat(..)
| MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now), | MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
MenuAction::EditName => { MenuAction::EditName => {
@@ -1481,6 +1694,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
MenuAction::BackToEditor => back_to_editor(&mut app, &mut menu, &mut r3d), MenuAction::BackToEditor => back_to_editor(&mut app, &mut menu, &mut r3d),
MenuAction::MainMenu if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d), MenuAction::MainMenu if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d),
MenuAction::MainMenu => { MenuAction::MainMenu => {
app.tutorial = false;
if app.match_mode || app.game.in_match() { if app.match_mode || app.game.in_match() {
app.leave_match(); app.leave_match();
} else if app.game.state == 0 { } else if app.game.state == 0 {
@@ -1516,13 +1730,41 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
// a co-op mission of an editor campaign is over: the host starts the next one (or the // a co-op mission of an editor campaign is over: the host starts the next one (or the
// same one again) for everybody; the others wait for it // same one again) for everybody; the others wait for it
UiAction::EndNext if app.coop_editor && app.game.local_side == 0 && app.coop_run.as_ref().map_or(false, |r| r.next.is_some() && !r.sent) => { UiAction::EndNext if app.coop_editor && app.game.local_side == 0 && app.coop_run.as_ref().map_or(false, |r| r.next.is_some() && !r.sent) => {
if let Some(run) = app.coop_run.as_mut() { if app.offline {
// no relay: the next mission of the run starts here at once
let run = app.coop_run.take().expect("a co-op run");
let next = run.next.unwrap_or(run.mission);
if app.start_offline_coop(Some(run.doc), next, Some(run.run_seed), run.carry_out) {
ui.log.clear();
autosave_t = 0.0;
}
} else if let Some(run) = app.coop_run.as_mut() {
if let Some(next) = run.next { if let Some(next) = run.next {
app.client.next_mission(next, run.run_seed, &run.carry_out); app.client.next_mission(next, run.run_seed, &run.carry_out);
run.sent = true; run.sent = true;
} }
} }
} }
// a built-in co-op mission without the server: the next one after a victory, else again
UiAction::EndNext if app.offline && !app.coop_editor => {
let i = app.game.mission.as_ref().map_or(0, |m| m.index);
let next = if app.game.state == 1 { campaign::next_coop_mission(i, 1) } else { i };
if next <= i && app.game.state == 1 {
app.leave_match();
menu.page = Some(Page::Title);
app.seed = next_seed(app.seed);
(app.game, app.cam) = Game::new_demo(app.seed);
} else if app.start_offline_coop(None, next, None, Vec::new()) {
ui.log.clear();
autosave_t = 0.0;
}
}
UiAction::EndCampaign if app.offline && !app.coop_editor => {
app.leave_match();
menu.page = Some(Page::Title);
app.seed = next_seed(app.seed);
(app.game, app.cam) = Game::new_demo(app.seed);
}
UiAction::EndNext if app.coop_editor && app.coop_run.as_ref().map_or(false, |r| r.next.is_some()) => {} UiAction::EndNext if app.coop_editor && app.coop_run.as_ref().map_or(false, |r| r.next.is_some()) => {}
// ... or the run is over (or the player leaves): the match ends, back to the lobby // ... or the run is over (or the player leaves): the match ends, back to the lobby
UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => { UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => {
@@ -1534,6 +1776,15 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
menu.error = Some(i18n::tr("net.status_offline")); menu.error = Some(i18n::tr("net.status_offline"));
} }
} }
UiAction::EndNext | UiAction::EndCampaign if app.tutorial && app.game.state == 1 => {
app.tutorial = false;
app.custom_campaign = None;
app.custom_progress = None;
app.custom_mission_index = None;
menu.page = Some(Page::Title);
app.seed = next_seed(app.seed);
(app.game, app.cam) = Game::new_demo(app.seed);
}
UiAction::EndNext => { UiAction::EndNext => {
if app.custom_campaign.is_some() { if app.custom_campaign.is_some() {
let campaign = app.custom_campaign.clone(); let campaign = app.custom_campaign.clone();
@@ -1599,6 +1850,7 @@ app.pending_briefing = briefing_of(&campaign, i);
} }
UiAction::OpenMenu => menu.page = Some(Page::Pause), UiAction::OpenMenu => menu.page = Some(Page::Pause),
UiAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }), UiAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
UiAction::LearnTalent(branch) => app.game.queue(Act::Talent { branch }),
UiAction::Locked => { UiAction::Locked => {
let locked = ui.last_locked.unwrap_or(usize::MAX); let locked = ui.last_locked.unwrap_or(usize::MAX);
app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]); app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]);
@@ -1740,11 +1992,23 @@ app.pending_briefing = briefing_of(&campaign, i);
} }
} }
app.game.smooth_display(dt); app.game.smooth_display(dt);
// winter: snow falls on the land above ground near the camera
if render3d::season() == 2 && !app.game.map.sky.is_empty() && app.game.parts.len() < 3000 {
let (cx, cz) = (app.cam.tx, app.cam.tz);
for _ in 0..3 {
let x = cx + (rand::gen_range(-1.0f32, 1.0)) * app.cam.zoom * 0.8;
let z = cz + (rand::gen_range(-1.0f32, 1.0)) * app.cam.zoom * 0.8;
if inb(x as i32, z as i32) && app.game.map.sky(map::idx(x as i32, z as i32)) {
app.game.parts.push(particles::Part { kind: particles::EMBER, x, y: z, h: 4.0, vx: rand::gen_range(-0.2f32, 0.2), vy: 0.0, vh: -0.6, t: 0.0, max: 6.0, col: particles::abgr(245, 248, 255), size: 0.04 });
}
}
}
let hear = app.cam.zoom * 1.2; let hear = app.cam.zoom * 1.2;
let (right, _) = app.cam.ground_axes(); let (right, _) = app.cam.ground_axes();
for ev in app.game.events.drain(..) { for ev in app.game.events.drain(..) {
sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now);
} }
sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), now);
// a won mission goes into the campaign progress (with the carry-over); a co-op mission of an // a won mission goes into the campaign progress (with the carry-over); a co-op mission of an
// editor campaign keeps no progress (its index is not a built-in mission) // editor campaign keeps no progress (its index is not a built-in mission)
if app.coop_editor { if app.coop_editor {
@@ -1754,6 +2018,8 @@ app.pending_briefing = briefing_of(&campaign, i);
run.finish(&app.game); run.finish(&app.game);
} }
} }
} else if app.coop {
// a built-in co-op mission is no step of the single-player campaign: no progress
} else if app.custom_campaign.is_none() { } else if app.custom_campaign.is_none() {
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) { if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
if app.game.state != 0 { if app.game.state != 0 {
@@ -1796,8 +2062,48 @@ app.pending_briefing = briefing_of(&campaign, i);
| Some(Page::Login) | Some(Page::Login)
| Some(Page::Account) | Some(Page::Account)
| Some(Page::Bestiary { from_title: true, .. }) | Some(Page::Bestiary { from_title: true, .. })
| Some(Page::Match { from_title: true })
| Some(Page::Achievements { from_title: true })
); );
music.update(&app.game, on_title, (app.cam.rx, app.cam.rz)); music.update(&app.game, on_title, (app.cam.rx, app.cam.rz), audio::track_by_id(&app.settings.music_track));
// ---------------- achievements: count the game, unlock, store and share the profile
{
let ctx = achievements::Context { coop: app.coop, editor_test: app.editor_testing };
let unix = miniquad::date::now() as u64;
let counting = !on_title && (playing || app.game.state != 0);
for id in if counting { tracker.update(&app.game, &ctx, dt, unix) } else { Vec::new() } {
let (name, _) = achievements::texts(&id);
app.game.msgs.push(game::Msg { key: "msg.achievement", args: vec![name] });
audio::play_sfx(game::Sfx::Victory.index(), 0.8, 0.0);
}
if let Some(json) = cloud.profile_loaded.take() {
if let Ok(p) = serde_json::from_str::<achievements::Profile>(&json) {
tracker.profile.merge(&p);
tracker.profile.check_tiers(unix);
tracker.dirty = true;
tracker.upload = tracker.profile != p;
}
}
if want_profile && cloud.session.is_some() && cloud.load_profile() {
want_profile = false;
}
profile_store_t += dt;
profile_upload_t += dt;
if tracker.dirty && (profile_store_t > 10.0 || tracker.upload) {
profile_store_t = 0.0;
tracker.dirty = false;
platform::storage_set(achievements::PROFILE_KEY, &serde_json::to_string(&tracker.profile).unwrap_or_default());
}
if tracker.upload && cloud.session.is_some() && profile_upload_t > 30.0 && !want_profile {
if cloud.save_profile(&serde_json::to_string(&tracker.profile).unwrap_or_default()) {
tracker.upload = false;
profile_upload_t = 0.0;
}
}
if matches!(menu.page, Some(Page::Achievements { .. })) {
menu.profile = tracker.profile.clone();
}
}
for m in app.game.msgs.drain(..) { for m in app.game.msgs.drain(..) {
if app.settings.voice && voice::speaks(m.key) { if app.settings.voice && voice::speaks(m.key) {
voice.say(&i18n::trf(m.key, &m.args), now, 12); voice.say(&i18n::trf(m.key, &m.args), now, 12);
@@ -1819,6 +2125,7 @@ app.pending_briefing = briefing_of(&campaign, i);
voice.update(now, if app.settings.muted { 0.0 } else { app.settings.volume }); voice.update(now, if app.settings.muted { 0.0 } else { app.settings.volume });
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now); draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now);
ui::end_frame();
next_frame().await next_frame().await
} }
} }
@@ -1838,7 +2145,11 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
let ground = app.cam.ground_point(mx, my); let ground = app.cam.ground_point(mx, my);
clear_background(Color::from_rgba(4, 3, 5, 255)); clear_background(Color::from_rgba(4, 3, 5, 255));
let (color, radius) = match ui.tool { let (color, radius) = match ui.tool {
Tool::Dig => (Color::new(1.0, 0.85, 0.3, 1.0), None), Tool::Dig => {
// the dig selection in the master's colour (like the marks)
let c = render3d::master_rgb(app.game.local_side);
(Color::new(c.x, c.y, c.z, 1.0), None)
}
Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None), Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None),
Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None), Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None),
Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None), Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None),
@@ -1846,7 +2157,9 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))), Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))),
Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))), Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))),
Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None), Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None),
Tool::Bridge => (Color::new(0.9, 0.7, 0.4, 1.0), None), Tool::Bridge | Tool::Drawbridge => (Color::new(0.9, 0.7, 0.4, 1.0), None),
Tool::Wall => (Color::new(0.8, 0.4, 1.0, 1.0), None),
Tool::Siege(_) => (Color::new(0.9, 0.7, 0.4, 1.0), None),
Tool::Steel => (Color::new(0.75, 0.8, 0.9, 1.0), None), Tool::Steel => (Color::new(0.75, 0.8, 0.9, 1.0), None),
Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None), Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None),
Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None), Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None),
@@ -1864,6 +2177,24 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
if app.settings.debug_trigger_zones || app.settings.debug_path_lines || app.editor_testing { if app.settings.debug_trigger_zones || app.settings.debug_path_lines || app.editor_testing {
draw_debug_3d(app, now); draw_debug_3d(app, now);
} }
// planned walls of this master: a pulsing frame where the wall will rise
let pulse = 0.55 + 0.25 * ((now * 3.0).sin() as f32);
let me = app.game.local_side;
for &(i, side) in app.game.wall_plans.iter().filter(|p| p.1 == me).take(200) {
let (x, z) = map::xy(i);
let (x0, z0, x1, z1) = (x as f32 + 0.08, z as f32 + 0.08, x as f32 + 0.92, z as f32 + 0.92);
let c = Color::new(0.75, 0.45, 1.0, pulse);
let _ = side;
for h in [0.02, 0.9] {
line3(vec3(x0, h, z0), vec3(x1, h, z0), 0.04, c);
line3(vec3(x1, h, z0), vec3(x1, h, z1), 0.04, c);
line3(vec3(x1, h, z1), vec3(x0, h, z1), 0.04, c);
line3(vec3(x0, h, z1), vec3(x0, h, z0), 0.04, c);
}
for (a, b) in [(x0, z0), (x1, z0), (x1, z1), (x0, z1)] {
line3(vec3(a, 0.02, b), vec3(a, 0.9, b), 0.04, c);
}
}
let title_screen = matches!( let title_screen = matches!(
menu.page, menu.page,
Some(Page::Title) Some(Page::Title)
@@ -1875,6 +2206,8 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
| Some(Page::Login) | Some(Page::Login)
| Some(Page::Account) | Some(Page::Account)
| Some(Page::Bestiary { from_title: true, .. }) | Some(Page::Bestiary { from_title: true, .. })
| Some(Page::Match { from_title: true })
| Some(Page::Achievements { from_title: true })
); );
if !title_screen { if !title_screen {
let info = if over_ui || (touch && inp.inspect.is_none()) { let info = if over_ui || (touch && inp.inspect.is_none()) {
@@ -2022,7 +2355,11 @@ app.pending_briefing = briefing_of(&ed.doc, i);
ui.show_help = false; ui.show_help = false;
r3d.invalidate(); r3d.invalidate();
} }
editor::EdAction::SaveFile => platform::download(&ed.doc.file_name(), ed.doc.save().as_bytes()), editor::EdAction::SaveFile => {
let name = ed.doc.file_name();
platform::download(&name, ed.doc.save().as_bytes());
ed.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)]));
}
editor::EdAction::OpenFile => platform::open_file(), editor::EdAction::OpenFile => platform::open_file(),
editor::EdAction::ServerSave => { editor::EdAction::ServerSave => {
if !cloud.available() { if !cloud.available() {
+85 -11
View File
@@ -68,8 +68,21 @@ pub enum Kind {
GuardPost, // room: creatures hold the position GuardPost, // room: creatures hold the position
Arena, // room: sparring Arena, // room: sparring
Altar, // room: sacrifices Altar, // room: sacrifices
Drawbridge, // bridge that can be raised (res: see DB_RAISED, DB_AUTO, DB_LAVA)
Obsidian, // lava cooled by frost: walkable until it melts again
Mushroom, // giant mushroom: imps fell it for wood once it is grown (food: growth left, 0 = grown; res: MUSH_GROVE)
Tree, // tree above ground: imps fell it for wood
Grove, // mushroom grove: mossy ground nobody can claim, giant mushrooms grow (back) here
} }
// a giant mushroom of a grove: when felled, the ground turns back into grove (Tile::res)
pub const MUSH_GROVE: u8 = 1;
// drawbridge state in Tile::res
pub const DB_RAISED: u8 = 1; // raised: nobody can cross
pub const DB_AUTO: u8 = 2; // rises by itself when enemies come near
pub const DB_LAVA: u8 = 4; // spans lava (stone), else water (wood)
// options for the map generator (campaign missions) // options for the map generator (campaign missions)
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct GenOpts { pub struct GenOpts {
@@ -96,7 +109,7 @@ pub struct River {
} }
// order matters for the packed savegame codec: only append new kinds // order matters for the packed savegame codec: only append new kinds
const KINDS: [Kind; 34] = [ const KINDS: [Kind; 39] = [
Kind::Rock, Kind::Rock,
Kind::Earth, Kind::Earth,
Kind::Gold, Kind::Gold,
@@ -131,6 +144,11 @@ const KINDS: [Kind; 34] = [
Kind::GuardPost, Kind::GuardPost,
Kind::Arena, Kind::Arena,
Kind::Altar, Kind::Altar,
Kind::Drawbridge,
Kind::Obsidian,
Kind::Mushroom,
Kind::Tree,
Kind::Grove,
]; ];
// treasures (veins, treasury content, imp loads): 0 gold, 1 silver, 2 ruby, 3 diamond // treasures (veins, treasury content, imp loads): 0 gold, 1 silver, 2 ruby, 3 diamond
@@ -151,6 +169,18 @@ pub enum Owner {
const OWNERS: [Owner; 6] = [Owner::None, Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3]; const OWNERS: [Owner; 6] = [Owner::None, Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3];
impl Owner { impl Owner {
// the side of a ground owner (255: nobody)
pub fn side(self) -> u8 {
match self {
Owner::Player => 0,
Owner::Hero => 1,
Owner::Rival1 => 2,
Owner::Rival2 => 3,
Owner::Rival3 => 4,
Owner::None => 255,
}
}
pub fn of_side(side: u8) -> Owner { pub fn of_side(side: u8) -> Owner {
match side { match side {
0 => Owner::Player, 0 => Owner::Player,
@@ -192,17 +222,31 @@ pub struct Tile {
pub reinforced: bool, // wall reinforced by imps pub reinforced: bool, // wall reinforced by imps
#[serde(rename = "rs", default)] #[serde(rename = "rs", default)]
pub res: u8, // treasury: kind of treasure stored here (see RES_GOLD) pub res: u8, // treasury: kind of treasure stored here (see RES_GOLD)
#[serde(rename = "mb", default)]
pub mark_by: u8, // side of the master who marked it for digging (its colour)
} }
impl Tile { impl Tile {
pub fn new(kind: Kind) -> Tile { pub fn new(kind: Kind) -> Tile {
Tile { kind, owner: Owner::None, hp: 0.0, gold: 0, marked: false, reserved: 0, food: 0.0, bed: 0, var: 0, reinforced: false, res: 0 } Tile { kind, owner: Owner::None, hp: 0.0, gold: 0, marked: false, reserved: 0, food: 0.0, bed: 0, var: 0, reinforced: false, res: 0, mark_by: 0 }
} }
pub fn solid(&self) -> bool { pub fn solid(&self) -> bool {
matches!(self.kind, Kind::Rock | Kind::Earth | Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond | Kind::Steel) matches!(self.kind, Kind::Rock | Kind::Earth | Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond | Kind::Steel)
} }
pub fn diggable(&self) -> bool { pub fn diggable(&self) -> bool {
matches!(self.kind, Kind::Earth | Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond | Kind::Steel) matches!(self.kind, Kind::Earth | Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond | Kind::Steel | Kind::Tree) || (self.kind == Kind::Mushroom && self.grown())
}
// a giant mushroom is felled only when it is fully grown (everything else counts as grown)
pub fn grown(&self) -> bool {
self.kind != Kind::Mushroom || self.food <= 0.0
}
// how far a giant mushroom has grown: 0 a sprout .. 1 fully grown
pub fn growth(&self) -> f32 {
if self.kind == Kind::Mushroom { (1.0 - self.food).clamp(0.0, 1.0) } else { 1.0 }
}
// a giant mushroom or a tree: felled like a wall is dug, gives wood
pub fn plant(&self) -> bool {
matches!(self.kind, Kind::Mushroom | Kind::Tree)
} }
// the treasure of a vein (None for everything else) // the treasure of a vein (None for everything else)
pub fn vein(&self) -> Option<u8> { pub fn vein(&self) -> Option<u8> {
@@ -240,20 +284,27 @@ impl Tile {
| Kind::GuardPost | Kind::GuardPost
| Kind::Arena | Kind::Arena
| Kind::Altar | Kind::Altar
) | Kind::Obsidian
| Kind::Grove
) || (self.kind == Kind::Drawbridge && self.res & DB_RAISED == 0)
} }
// river tiles (open, but nobody can walk there) // river tiles (open, but nobody can walk there)
pub fn liquid(&self) -> bool { pub fn liquid(&self) -> bool {
matches!(self.kind, Kind::Water | Kind::Lava) matches!(self.kind, Kind::Water | Kind::Lava)
} }
pub fn bridge(&self) -> bool { pub fn bridge(&self) -> bool {
matches!(self.kind, Kind::Bridge | Kind::LavaBridge) matches!(self.kind, Kind::Bridge | Kind::LavaBridge | Kind::Drawbridge)
} }
// the river below a bridge or river tile pub fn raised(&self) -> bool {
self.kind == Kind::Drawbridge && self.res & DB_RAISED != 0
}
// the river below a bridge or river tile (obsidian: the lava under the crust)
pub fn river(&self) -> Option<Kind> { pub fn river(&self) -> Option<Kind> {
match self.kind { match self.kind {
Kind::Water | Kind::Bridge => Some(Kind::Water), Kind::Water | Kind::Bridge => Some(Kind::Water),
Kind::Lava | Kind::LavaBridge => Some(Kind::Lava), Kind::Lava | Kind::LavaBridge | Kind::Obsidian => Some(Kind::Lava),
Kind::Drawbridge if self.res & DB_LAVA != 0 => Some(Kind::Lava),
Kind::Drawbridge => Some(Kind::Water),
_ => None, _ => None,
} }
} }
@@ -315,6 +366,10 @@ pub struct Map {
pub explored: Vec<bool>, // fog of war: tiles the dungeon knows (empty: no fog) pub explored: Vec<bool>, // fog of war: tiles the dungeon knows (empty: no fog)
#[serde(skip)] #[serde(skip)]
pub visible: Vec<bool>, // fog of war: tiles in sight right now pub visible: Vec<bool>, // fog of war: tiles in sight right now
#[serde(default, with = "crate::fog::bits")]
pub sky: Vec<bool>, // tiles above ground (daylight, grass); empty: all underground
#[serde(default)]
pub upgrades: Vec<(u8, u8, u8)>, // researched room levels for the look: (master side, room index, level)
} }
fn default_size() -> usize { fn default_size() -> usize {
@@ -332,6 +387,21 @@ impl Map {
} }
} }
// tile i lies above ground
pub fn sky(&self, i: usize) -> bool {
self.sky.get(i).copied().unwrap_or(false)
}
pub fn set_sky(&mut self, i: usize, on: bool) {
if self.sky.len() != self.t.len() {
if !on {
return;
}
self.sky = vec![false; self.t.len()];
}
self.sky[i] = on;
}
pub fn get(&self, x: i32, y: i32) -> Option<&Tile> { pub fn get(&self, x: i32, y: i32) -> Option<&Tile> {
if inb(x, y) { if inb(x, y) {
Some(&self.t[idx(x, y)]) Some(&self.t[idx(x, y)])
@@ -349,9 +419,10 @@ impl Map {
self.portals.iter().position(|&(px, py)| x >= px && x < px + 3 && y >= py && y < py + 3) self.portals.iter().position(|&(px, py)| x >= px && x < px + 3 && y >= py && y < py + 3)
} }
pub fn portal_owned(&self, k: usize) -> bool { // whose portal k is (any master can claim one)
pub fn portal_owner(&self, k: usize) -> Owner {
let (px, py) = self.portals[k]; let (px, py) = self.portals[k];
self.t[idx(px, py)].owner == Owner::Player self.t[idx(px, py)].owner
} }
pub fn generate(seed: u32, w: usize, h: usize) -> Map { pub fn generate(seed: u32, w: usize, h: usize) -> Map {
@@ -552,7 +623,7 @@ impl Map {
tl.hp = 6.0; tl.hp = 6.0;
} }
} }
Map { w, h, t, core, portals, legacy_portal: None, gate, treasures, explored: Vec::new(), visible: Vec::new() } Map { w, h, t, core, portals, legacy_portal: None, gate, treasures, explored: Vec::new(), visible: Vec::new(), sky: Vec::new(), upgrades: Vec::new() }
} }
// Carves a meandering river; the player's area, the core and portals stay dry. // Carves a meandering river; the player's area, the core and portals stay dry.
@@ -724,6 +795,8 @@ impl Map {
Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond => Some(12), Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond => Some(12),
Kind::Rock | Kind::Core | Kind::Tower | Kind::Water | Kind::Lava => None, Kind::Rock | Kind::Core | Kind::Tower | Kind::Water | Kind::Lava => None,
Kind::Door => Some(10), Kind::Door => Some(10),
Kind::Mushroom | Kind::Tree => Some(6),
Kind::Drawbridge if tl.res & DB_RAISED != 0 => None,
_ => Some(1), _ => Some(1),
} }
} }
@@ -808,7 +881,7 @@ fn tile_record(t: &Tile) -> [u8; REC] {
let mut r = [0u8; REC]; let mut r = [0u8; REC];
r[0] = KINDS.iter().position(|&k| k == t.kind).unwrap_or(0) as u8; r[0] = KINDS.iter().position(|&k| k == t.kind).unwrap_or(0) as u8;
r[1] = OWNERS.iter().position(|&o| o == t.owner).unwrap_or(0) as u8; r[1] = OWNERS.iter().position(|&o| o == t.owner).unwrap_or(0) as u8;
r[2] = t.marked as u8 | (t.reinforced as u8) << 1 | (t.res & 3) << 2; r[2] = t.marked as u8 | (t.reinforced as u8) << 1 | (t.res & 3) << 2 | (t.mark_by & 7) << 4;
r[3..7].copy_from_slice(&t.hp.to_le_bytes()); r[3..7].copy_from_slice(&t.hp.to_le_bytes());
r[7..11].copy_from_slice(&t.gold.to_le_bytes()); r[7..11].copy_from_slice(&t.gold.to_le_bytes());
r[11..15].copy_from_slice(&t.food.to_le_bytes()); r[11..15].copy_from_slice(&t.food.to_le_bytes());
@@ -873,6 +946,7 @@ fn tiles_de<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Tile>, D::Error> {
marked: r[2] & 1 != 0, marked: r[2] & 1 != 0,
reinforced: r[2] & 2 != 0, reinforced: r[2] & 2 != 0,
res: (r[2] >> 2) & 3, res: (r[2] >> 2) & 3,
mark_by: (r[2] >> 4) & 7,
hp: f32::from_le_bytes(f(3)), hp: f32::from_le_bytes(f(3)),
gold: i32::from_le_bytes(f(7)), gold: i32::from_le_bytes(f(7)),
food: f32::from_le_bytes(f(11)), food: f32::from_le_bytes(f(11)),
+189 -43
View File
@@ -14,6 +14,7 @@ use crate::settings::{all_actions, key_name, Action, Settings};
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
use macroquad::prelude::*; use macroquad::prelude::*;
mod achievements;
mod bestiary; mod bestiary;
pub mod flags; pub mod flags;
@@ -33,6 +34,7 @@ pub enum Page {
Account, // password, logout, users (admin) Account, // password, logout, users (admin)
Match { from_title: bool }, // multiplayer lobby and match status Match { from_title: bool }, // multiplayer lobby and match status
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
Achievements { from_title: bool },
} }
pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"]; pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"];
@@ -62,17 +64,19 @@ pub enum Item {
DebugPathLines, DebugPathLines,
GameSpeed, GameSpeed,
Hints, Hints,
Events,
TouchUi, TouchUi,
Voice, Voice,
Track,
} }
fn tab_items(tab: usize) -> &'static [Item] { fn tab_items(tab: usize) -> &'static [Item] {
use Item::*; use Item::*;
match tab { match tab {
0 => &[Language, Volume, MusicVolume, Mute, Voice], 0 => &[Language, Volume, MusicVolume, Track, Mute, Voice],
1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse], 1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse],
2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], 2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale],
3 => &[GameSpeed, Hints, TouchUi], 3 => &[GameSpeed, Hints, Events, TouchUi],
4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines], 4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines],
_ => &[], _ => &[],
} }
@@ -102,8 +106,10 @@ fn item_label(it: Item) -> String {
Item::DebugPathLines => "set.debug_path_lines", Item::DebugPathLines => "set.debug_path_lines",
Item::GameSpeed => "set.game_speed", Item::GameSpeed => "set.game_speed",
Item::Hints => "set.hints", Item::Hints => "set.hints",
Item::Events => "set.events",
Item::TouchUi => "set.touch_ui", Item::TouchUi => "set.touch_ui",
Item::Voice => "set.voice", Item::Voice => "set.voice",
Item::Track => "set.track",
}) })
} }
@@ -122,6 +128,7 @@ fn item_value(it: Item, s: &Settings) -> String {
Item::MusicVolume => pct(s.music_volume), Item::MusicVolume => pct(s.music_volume),
Item::Mute => onoff(s.muted), Item::Mute => onoff(s.muted),
Item::Voice => onoff(s.voice), Item::Voice => onoff(s.voice),
Item::Track => crate::audio::track_by_id(&s.music_track).map_or_else(|| tr("val.track_auto"), crate::audio::track_title),
Item::RotateSpeed => pct(s.rotate_speed), Item::RotateSpeed => pct(s.rotate_speed),
Item::PanSpeed => pct(s.pan_speed), Item::PanSpeed => pct(s.pan_speed),
Item::ZoomSpeed => pct(s.zoom_speed), Item::ZoomSpeed => pct(s.zoom_speed),
@@ -134,30 +141,22 @@ fn item_value(it: Item, s: &Settings) -> String {
_ => "val.medium", _ => "val.medium",
}), }),
Item::Lights => s.max_lights.to_string(), Item::Lights => s.max_lights.to_string(),
Item::UiScale => { Item::UiScale => tr(match s.ui_factor {
if s.ui_factor == 0 { 2 => "val.large",
trf("val.auto", &[crate::ui::F().to_string()]) 3 => "val.medium",
} else { 4 => "val.small",
format!("{}x", s.ui_factor) _ => "val.auto_plain",
} }),
}
Item::DebugFps => onoff(s.debug_fps), Item::DebugFps => onoff(s.debug_fps),
Item::DebugFrameTime => onoff(s.debug_frame_time), Item::DebugFrameTime => onoff(s.debug_frame_time),
Item::DebugPing => onoff(s.debug_ping), Item::DebugPing => onoff(s.debug_ping),
Item::DebugTriggerZones => onoff(s.debug_trigger_zones), Item::DebugTriggerZones => onoff(s.debug_trigger_zones),
Item::DebugCommandMarkers => onoff(s.debug_command_markers), Item::DebugCommandMarkers => onoff(s.debug_command_markers),
Item::DebugPathLines => onoff(s.debug_path_lines), Item::DebugPathLines => onoff(s.debug_path_lines),
Item::RenderScale => { Item::RenderScale => crate::i18n::trf("set.pixel_rows_val", &[s.pixel_rows.to_string()]),
// the setting is for a 1080 pixel high window; show what applies to this one
let now = crate::render3d::effective_div(s.render_scale.max(1) as u32, crate::ui::sh());
if now as u8 == s.render_scale.max(1) {
format!("1/{}", s.render_scale.max(1))
} else {
format!("1/{} (1/{})", s.render_scale.max(1), now)
}
}
Item::GameSpeed => format!("{}x", s.game_speed as i32), Item::GameSpeed => format!("{}x", s.game_speed as i32),
Item::Hints => onoff(s.hints), Item::Hints => onoff(s.hints),
Item::Events => onoff(s.events),
Item::TouchUi => match s.touch_ui { Item::TouchUi => match s.touch_ui {
1 => tr("val.on"), 1 => tr("val.on"),
2 => tr("val.off"), 2 => tr("val.off"),
@@ -187,6 +186,13 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
Item::MusicVolume => s.music_volume = step(s.music_volume, d, 0.0, 1.0, 0.1), Item::MusicVolume => s.music_volume = step(s.music_volume, d, 0.0, 1.0, 0.1),
Item::Mute => s.muted = !s.muted, Item::Mute => s.muted = !s.muted,
Item::Voice => s.voice = !s.voice, Item::Voice => s.voice = !s.voice,
Item::Track => {
// automatic, then every calm track
let mut opts: Vec<String> = vec![String::new()];
opts.extend(crate::audio::player_tracks().into_iter().map(|t| crate::audio::track_id(t).to_string()));
let i = opts.iter().position(|o| *o == s.music_track).unwrap_or(0) as i32;
s.music_track = opts[(i + d).rem_euclid(opts.len() as i32) as usize].clone();
}
Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25), Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25),
Item::PanSpeed => s.pan_speed = step(s.pan_speed, d, 0.25, 2.5, 0.25), Item::PanSpeed => s.pan_speed = step(s.pan_speed, d, 0.25, 2.5, 0.25),
Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25), Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25),
@@ -195,8 +201,8 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
Item::Outlines => s.outlines = !s.outlines, Item::Outlines => s.outlines = !s.outlines,
Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d), Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d),
Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d), Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d),
Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d), Item::UiScale => s.ui_factor = cycle(&[0u8, 2, 3, 4], if [0, 2, 3, 4].contains(&s.ui_factor) { s.ui_factor } else { 0 }, d),
Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::<Vec<u8>>(), s.render_scale.clamp(1, 16), d), Item::RenderScale => s.pixel_rows = cycle(&[270u16, 360, 480], if [270, 360, 480].contains(&s.pixel_rows) { s.pixel_rows } else { 360 }, d),
Item::DebugFps => s.debug_fps = !s.debug_fps, Item::DebugFps => s.debug_fps = !s.debug_fps,
Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time, Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time,
Item::DebugPing => s.debug_ping = !s.debug_ping, Item::DebugPing => s.debug_ping = !s.debug_ping,
@@ -205,6 +211,7 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines, Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines,
Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d), Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
Item::Hints => s.hints = !s.hints, Item::Hints => s.hints = !s.hints,
Item::Events => s.events = !s.events,
Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d), Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d),
} }
} }
@@ -230,6 +237,8 @@ enum Btn {
Tab(usize), Tab(usize),
Change(usize, i32), Change(usize, i32),
MapW(i32), MapW(i32),
MatchSize(i32),
Land,
MapH(i32), MapH(i32),
Difficulty(i32), Difficulty(i32),
Start, Start,
@@ -279,6 +288,8 @@ enum Btn {
MatchCoopNext, MatchCoopNext,
MatchCoopCreate, MatchCoopCreate,
MatchCoopSource, // switch between the built-in campaign and the editor campaign MatchCoopSource, // switch between the built-in campaign and the editor campaign
MatchCoopSolo, // the selected co-op mission without the server, with an AI partner
MatchCoopResume, // go on with the interrupted co-op run of the selected campaign
MatchTake(usize), MatchTake(usize),
MatchAi(usize), MatchAi(usize),
MatchTeam(usize), // next team for that seat MatchTeam(usize), // next team for that seat
@@ -291,6 +302,9 @@ enum Btn {
MatchLeave, MatchLeave,
MatchChat, MatchChat,
Bestiary, Bestiary,
Achievements,
AchHall, // achievements page: switch to the hall of the fallen and back
Tutorial,
BestTab(usize), BestTab(usize),
BestSel(i32), BestSel(i32),
BestPick(usize), BestPick(usize),
@@ -382,6 +396,7 @@ pub enum MenuAction {
CampaignStart(usize), CampaignStart(usize),
OpenRepo, OpenRepo,
OpenClaudeCode, OpenClaudeCode,
Tutorial, // start the tutorial mission
BackToEditor, BackToEditor,
OpenOpenCode, OpenOpenCode,
Login(String, String), Login(String, String),
@@ -397,6 +412,8 @@ pub enum MenuAction {
MatchCreate, MatchCreate,
MatchCoopCreate(usize), MatchCoopCreate(usize),
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc` MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
MatchCoopSolo(Option<crate::campaign_doc::CampaignDoc>, usize), // offline with an AI partner: editor campaign or built-in, mission
MatchCoopResume(crate::campaign_doc::CampaignDoc), // an interrupted run of this campaign goes on
MatchTakeSide(u8), MatchTakeSide(u8),
MatchSetAi(u8, bool), MatchSetAi(u8, bool),
MatchSetTeam(u8, u8, bool), // side, team, the own seat MatchSetTeam(u8, u8, bool), // side, team, the own seat
@@ -421,7 +438,10 @@ pub struct Menu {
pub error: Option<String>, // message on the login and account pages pub error: Option<String>, // message on the login and account pages
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
pub coop_mission: usize, // co-op campaign mission selected on the Match page pub coop_mission: usize, // co-op campaign mission selected on the Match page
pub coop_editor: bool, // the Match page offers the editor campaign instead of the built-in one match_size: usize, // the versus map size (from the settings, for drawing)
pub coop_editor: bool, // the Match page offers a campaign document instead of the built-in missions
pub coop_league: bool, // ... and that document is the co-op campaign "League against the Lightwatch"
league_doc: Option<CampaignDoc>, // the co-op campaign, built the first time it is chosen
pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op
pub in_match: bool, // a match is being played; the pause menu changes its shape pub in_match: bool, // a match is being played; the pause menu changes its shape
pub gold_allies: Vec<u8>, // allies of the local master that can get gold (pause menu) pub gold_allies: Vec<u8>, // allies of the local master that can get gold (pause menu)
@@ -434,6 +454,8 @@ pub struct Menu {
confirm: Option<Btn>, // overwrite / delete waiting for a second click confirm: Option<Btn>, // overwrite / delete waiting for a second click
scroll: usize, scroll: usize,
rows_visible: usize, rows_visible: usize,
pub profile: crate::achievements::Profile, // shown on the achievements page
ach_hall: bool, // the achievements page shows the hall of the fallen
buttons: Vec<(Rect, Btn)>, buttons: Vec<(Rect, Btn)>,
lang_open: Option<Rect>, // the language dropdown is open below/above this field lang_open: Option<Rect>, // the language dropdown is open below/above this field
lang_scroll: usize, // first language shown in the open dropdown lang_scroll: usize, // first language shown in the open dropdown
@@ -445,8 +467,24 @@ pub struct Menu {
fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) { fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) {
draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.13, 0.09, 0.08, 1.0) }); draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.13, 0.09, 0.08, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { accent }); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { accent });
let s = S(); // a label too wide for the button gets the small font, and is cut short if that is not enough
gfx.text(label, r.x + (r.w - gfx.measure(label, s)) * 0.5, r.y + (r.h - GH * s) * 0.5, s, if hot { FRAME_HI } else { TXT }); let mut s = S();
let room = r.w - 8.0;
let mut text = label.to_string();
if gfx.measure(&text, s) > room {
s = SMALL();
while gfx.measure(&text, s) > room && text.chars().count() > 2 {
text.pop();
while text.ends_with(' ') {
text.pop();
}
if gfx.measure(&format!("{text}…"), s) <= room {
text.push('…');
break;
}
}
}
gfx.text(&text, r.x + (r.w - gfx.measure(&text, s)) * 0.5, r.y + (r.h - GH * s) * 0.5, s, if hot { FRAME_HI } else { TXT });
} }
impl Menu { impl Menu {
@@ -461,8 +499,12 @@ impl Menu {
focus: 0, focus: 0,
error: None, error: None,
match_ui: MatchUi::default(), match_ui: MatchUi::default(),
ach_hall: false,
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0), coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
match_size: 128,
coop_editor: false, coop_editor: false,
coop_league: false,
league_doc: None,
coop_editor_mission: 0, coop_editor_mission: 0,
in_match: false, in_match: false,
gold_allies: Vec::new(), gold_allies: Vec::new(),
@@ -480,6 +522,7 @@ impl Menu {
lang_rows: 0, lang_rows: 0,
cur_lang: String::new(), cur_lang: String::new(),
rows_visible: 1, rows_visible: 1,
profile: Default::default(),
buttons: Vec::new(), buttons: Vec::new(),
} }
} }
@@ -505,6 +548,7 @@ impl Menu {
Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }), Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title), Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title),
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause), Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
Some(Page::Achievements { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause), Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign), Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
Some(Page::CustomBriefing) => None, Some(Page::CustomBriefing) => None,
@@ -643,6 +687,12 @@ impl Menu {
MenuAction::Consumed MenuAction::Consumed
} }
Some(Btn::Start) => MenuAction::NewGame, Some(Btn::Start) => MenuAction::NewGame,
Some(Btn::MatchSize(d)) => {
let sizes = crate::lobby::MATCH_SIZES;
let k = sizes.iter().position(|&s| s >= settings.match_size).unwrap_or(2) as i32;
settings.match_size = sizes[(k + d).clamp(0, sizes.len() as i32 - 1) as usize];
MenuAction::SettingsChanged
}
Some(Btn::MapW(d)) => { Some(Btn::MapW(d)) => {
settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize; settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
MenuAction::SettingsChanged MenuAction::SettingsChanged
@@ -651,6 +701,10 @@ impl Menu {
settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8; settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8;
MenuAction::SettingsChanged MenuAction::SettingsChanged
} }
Some(Btn::Land) => {
settings.surface = !settings.surface;
MenuAction::SettingsChanged
}
Some(Btn::MapH(d)) => { Some(Btn::MapH(d)) => {
settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize; settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
MenuAction::SettingsChanged MenuAction::SettingsChanged
@@ -764,7 +818,7 @@ impl Menu {
Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => { Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => {
let dir = if b == Btn::MatchCoopPrev { -1 } else { 1 }; let dir = if b == Btn::MatchCoopPrev { -1 } else { 1 };
if self.coop_editor { if self.coop_editor {
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, dir); self.coop_editor_mission = crate::campaign::next_editor_coop_mission(self.coop_doc(), self.coop_editor_mission, dir);
} else { } else {
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, dir); self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, dir);
} }
@@ -772,11 +826,31 @@ impl Menu {
MenuAction::Consumed MenuAction::Consumed
} }
Some(Btn::MatchCoopSource) => { Some(Btn::MatchCoopSource) => {
self.coop_editor = !self.coop_editor && !self.campaign_doc.missions.is_empty(); // built-in missions -> the league -> the editor's campaign (if it has missions) -> ...
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, 0); match (self.coop_editor, self.coop_league) {
(false, _) => {
if self.league_doc.is_none() {
self.league_doc = Some(crate::editor::coop_campaign::coop_campaign());
}
self.coop_editor = true;
self.coop_league = true;
}
(true, true) if !self.campaign_doc.missions.is_empty() => self.coop_league = false,
_ => {
self.coop_editor = false;
self.coop_league = false;
}
}
self.coop_editor_mission = 0;
if self.coop_editor {
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(self.coop_doc(), 0, 0);
}
self.confirm = None; self.confirm = None;
MenuAction::Consumed MenuAction::Consumed
} }
Some(Btn::MatchCoopResume) => MenuAction::MatchCoopResume(self.coop_doc().clone()),
Some(Btn::MatchCoopSolo) if self.coop_editor => MenuAction::MatchCoopSolo(Some(self.coop_doc().clone()), self.coop_editor_mission),
Some(Btn::MatchCoopSolo) => MenuAction::MatchCoopSolo(None, self.coop_mission),
Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission), Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission),
Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission), Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission),
Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready), Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready),
@@ -816,6 +890,7 @@ impl Menu {
Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai), Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai),
_ => MenuAction::Consumed, _ => MenuAction::Consumed,
}, },
Some(Btn::Tutorial) => MenuAction::Tutorial,
Some(Btn::Campaign) => { Some(Btn::Campaign) => {
self.page = Some(Page::Campaign); self.page = Some(Page::Campaign);
self.scroll = 0; self.scroll = 0;
@@ -867,6 +942,16 @@ impl Menu {
} }
MenuAction::Consumed MenuAction::Consumed
} }
Some(Btn::AchHall) => {
self.ach_hall = !self.ach_hall;
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::Achievements) => {
self.page = Some(Page::Achievements { from_title });
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::Bestiary) => { Some(Btn::Bestiary) => {
self.open_bestiary(from_title); self.open_bestiary(from_title);
MenuAction::Consumed MenuAction::Consumed
@@ -884,6 +969,7 @@ impl Menu {
self.buttons.clear(); self.buttons.clear();
self.lang_opts.clear(); self.lang_opts.clear();
self.cur_lang = settings.language.clone(); self.cur_lang = settings.language.clone();
self.match_size = settings.match_size;
let Some(page) = self.page else { let Some(page) = self.page else {
self.lang_open = None; self.lang_open = None;
return; return;
@@ -905,6 +991,7 @@ impl Menu {
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame), Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::CustomBriefing => self.draw_custom_briefing(gfx), Page::CustomBriefing => self.draw_custom_briefing(gfx),
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level), Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
Page::Achievements { .. } => self.draw_achievements(gfx),
Page::KeyCapture { action, .. } => { Page::KeyCapture { action, .. } => {
self.draw_settings(gfx, settings, 5); self.draw_settings(gfx, settings, 5);
self.buttons.clear(); self.buttons.clear();
@@ -939,9 +1026,13 @@ impl Menu {
} }
fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) { fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) {
self.column_pitch(gfx, x, y, w, items, CH() * 3.0);
}
fn column_pitch(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)], pitch: f32) {
let (mx, my) = mouse(); let (mx, my) = mouse();
for (k, (b, label)) in items.iter().enumerate() { for (k, (b, label)) in items.iter().enumerate() {
let r = Rect::new(x, y + k as f32 * CH() * 3.0, w, CH() * 2.0); let r = Rect::new(x, y + k as f32 * pitch, w, CH() * 2.0);
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME); button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, *b)); self.buttons.push((r, *b));
} }
@@ -1039,6 +1130,7 @@ impl Menu {
items.push((Btn::Continue, tr("menu.continue"))); items.push((Btn::Continue, tr("menu.continue")));
} }
items.push((Btn::Campaign, tr("menu.campaign"))); items.push((Btn::Campaign, tr("menu.campaign")));
items.push((Btn::Tutorial, tr("menu.tutorial")));
items.push((Btn::CampaignEditor, tr("menu.campaign_editor"))); items.push((Btn::CampaignEditor, tr("menu.campaign_editor")));
items.push((Btn::Match, tr("menu.multiplayer"))); items.push((Btn::Match, tr("menu.multiplayer")));
items.push((Btn::New, tr("menu.new"))); items.push((Btn::New, tr("menu.new")));
@@ -1049,10 +1141,25 @@ impl Menu {
} }
items.push((Btn::Standalone, tr("menu.standalone"))); items.push((Btn::Standalone, tr("menu.standalone")));
items.push((Btn::Bestiary, tr("menu.bestiary"))); items.push((Btn::Bestiary, tr("menu.bestiary")));
items.push((Btn::Achievements, tr("menu.achievements")));
items.push((Btn::Help, tr("menu.help"))); items.push((Btn::Help, tr("menu.help")));
let w = 26.0 * CW(); let w = 26.0 * CW();
let y = ty + GH * big + CH() * 4.0; let y = ty + GH * big + CH() * 4.0;
self.column(gfx, grid_center(sw, w), y, w, &items); // the buttons end above the hints and credits; on a short screen they move closer
// together or into two columns
let bottom = sh - CH() * 9.0;
let fits = |pitch: f32| y + items.len() as f32 * pitch <= bottom;
if fits(CH() * 3.0) {
self.column(gfx, grid_center(sw, w), y, w, &items);
} else if fits(CH() * 2.5) {
self.column_pitch(gfx, grid_center(sw, w), y, w, &items, CH() * 2.5);
} else {
let half = (items.len() + 1) / 2;
let gap = CW() * 2.0;
let x0 = grid_center(sw, w * 2.0 + gap);
self.column_pitch(gfx, x0, y, w, &items[..half], CH() * 2.5);
self.column_pitch(gfx, x0 + w + gap, y, w, &items[half..], CH() * 2.5);
}
let hint = tr("menu.drop_hint"); let hint = tr("menu.drop_hint");
gfx.text(&hint, grid_center(sw, gfx.measure(&hint, SMALL())), sh - CH() * 8.0, SMALL(), DIM); gfx.text(&hint, grid_center(sw, gfx.measure(&hint, SMALL())), sh - CH() * 8.0, SMALL(), DIM);
let credit = tr("help.font"); let credit = tr("help.font");
@@ -1109,27 +1216,30 @@ impl Menu {
let sh = sh(); let sh = sh();
let (mx, my) = mouse(); let (mx, my) = mouse();
let cols = 48.min((sw / CW()) as i32); let cols = 48.min((sw / CW()) as i32);
let rows = 22; let rows = 25;
let x = grid_center(sw, cols as f32 * CW()).max(0.0); let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI);
let diff = tr(&format!("diff.{}", s.difficulty.min(2))); let diff = tr(&format!("diff.{}", s.difficulty.min(2)));
let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2)]; let land = tr(if s.surface { "newgame.land1" } else { "newgame.land0" });
let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2), ("newgame.land", land, 3)];
for (k, (key, val, which)) in lines.iter().enumerate() { for (k, (key, val, which)) in lines.iter().enumerate() {
let (key, which) = (*key, *which); let (key, which) = (*key, *which);
let by = y + CH() * (2.0 + k as f32 * 2.5); let by = y + CH() * (2.0 + k as f32 * 2.5);
gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT); gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
let vw = 14.0 * CW(); let vw = 18.0 * CW();
let vx = x + cols as f32 * CW() - vw - CW() * 3.0; let vx = x + cols as f32 * CW() - vw - CW() * 3.0;
let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0); let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0);
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0); let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME); button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
let v = val.clone(); let v = val.clone();
gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD); let vs = if gfx.measure(&v, S()) > vw - CW() * 5.5 { SMALL() } else { S() };
gfx.text(&v, vx + (vw - gfx.measure(&v, vs)) * 0.5, by + CH() * 0.5, vs, GOLD);
let (l, r) = match which { let (l, r) = match which {
0 => (Btn::MapW(-1), Btn::MapW(1)), 0 => (Btn::MapW(-1), Btn::MapW(1)),
1 => (Btn::MapH(-1), Btn::MapH(1)), 1 => (Btn::MapH(-1), Btn::MapH(1)),
3 => (Btn::Land, Btn::Land),
_ => (Btn::Difficulty(-1), Btn::Difficulty(1)), _ => (Btn::Difficulty(-1), Btn::Difficulty(1)),
}; };
self.buttons.push((left, l)); self.buttons.push((left, l));
@@ -1139,14 +1249,14 @@ impl Menu {
let area = (s.map_w * s.map_h) as f32 / 4096.0; let area = (s.map_w * s.map_h) as f32 / 4096.0;
let portals = (area.round() as usize).max(1); let portals = (area.round() as usize).max(1);
let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]); let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]);
gfx.text(&info, x + CW() * 3.0, y + CH() * 10.0, S(), DIM); gfx.text(&info, x + CW() * 3.0, y + CH() * 12.5, S(), DIM);
let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" }); let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" });
let maxc = (cols - 6) as usize; let maxc = (cols - 6) as usize;
for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() { for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() {
gfx.text(l, x + CW() * 3.0, y + CH() * (11.0 + k as f32), S(), DIM); gfx.text(l, x + CW() * 3.0, y + CH() * (13.5 + k as f32), S(), DIM);
} }
let w = (cols - 6) as f32 * CW(); let w = (cols - 6) as f32 * CW();
self.column(gfx, x + CW() * 3.0, y + CH() * 14.0, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]); self.column(gfx, x + CW() * 3.0, y + CH() * 16.5, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]);
} }
fn draw_pause(&mut self, gfx: &Gfx) { fn draw_pause(&mut self, gfx: &Gfx) {
@@ -1163,6 +1273,7 @@ impl Menu {
items.extend([ items.extend([
(Btn::Settings, tr("menu.settings")), (Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")), (Btn::Bestiary, tr("menu.bestiary")),
(Btn::Achievements, tr("menu.achievements")),
(Btn::Help, tr("menu.help")), (Btn::Help, tr("menu.help")),
(Btn::MainMenu, tr("net.leave")), (Btn::MainMenu, tr("net.leave")),
]); ]);
@@ -1173,6 +1284,7 @@ impl Menu {
(Btn::Match, tr("menu.multiplayer")), (Btn::Match, tr("menu.multiplayer")),
(Btn::Settings, tr("menu.settings")), (Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")), (Btn::Bestiary, tr("menu.bestiary")),
(Btn::Achievements, tr("menu.achievements")),
(Btn::Help, tr("menu.help")), (Btn::Help, tr("menu.help")),
(Btn::MainMenu, tr("menu.main")), (Btn::MainMenu, tr("menu.main")),
]); ]);
@@ -1717,10 +1829,18 @@ impl Menu {
self.column(gfx, x + (cols as f32 * CW() - w) * 0.5, y + (rows - 4) as f32 * CH(), w, &[(Btn::Resume, label)]); self.column(gfx, x + (cols as f32 * CW() - w) * 0.5, y + (rows - 4) as f32 * CH(), w, &[(Btn::Resume, label)]);
} }
// the campaign document the co-op create offers: the league or the editor's campaign
pub fn coop_doc(&self) -> &CampaignDoc {
match (&self.league_doc, self.coop_league) {
(Some(d), true) => d,
_ => &self.campaign_doc,
}
}
pub fn scrollable(&self) -> bool { pub fn scrollable(&self) -> bool {
matches!( matches!(
self.page, self.page,
Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account) Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account) | Some(Page::Achievements { .. })
) )
} }
@@ -1920,15 +2040,16 @@ impl Menu {
} }
// the editor campaign can only be offered while it has missions (it may have been replaced) // the editor campaign can only be offered while it has missions (it may have been replaced)
let editor_coop = self.coop_editor && !self.campaign_doc.missions.is_empty(); let doc = self.coop_doc().clone();
let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&self.campaign_doc) } else { Vec::new() }; let editor_coop = self.coop_editor && !doc.missions.is_empty();
let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&doc) } else { Vec::new() };
if editor_coop && !editor_list.contains(&self.coop_editor_mission) { if editor_coop && !editor_list.contains(&self.coop_editor_mission) {
self.coop_editor_mission = editor_list.first().copied().unwrap_or(0); self.coop_editor_mission = editor_list.first().copied().unwrap_or(0);
} }
if !ui.started && !ui.room_open() && editor_coop { if !ui.started && !ui.room_open() && editor_coop {
let line = match self.campaign_doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) { let line = match doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) {
Some(m) => { Some(m) => {
let name = if self.campaign_doc.name.trim().is_empty() { "?".to_string() } else { self.campaign_doc.name.clone() }; let name = if doc.name.trim().is_empty() { "?".to_string() } else { doc.name.clone() };
let title = if m.title.trim().is_empty() { m.key.clone() } else { m.title.clone() }; let title = if m.title.trim().is_empty() { m.key.clone() } else { m.title.clone() };
trf("net.coop_editor_mission", &[name, (self.coop_editor_mission + 1).to_string(), title]) trf("net.coop_editor_mission", &[name, (self.coop_editor_mission + 1).to_string(), title])
} }
@@ -1949,6 +2070,19 @@ impl Menu {
} }
} }
} }
// a new versus match: the map size and with it the number of seats
if !ui.started && !ui.room_open() {
let n = self.match_size;
let seats = crate::lobby::seats_for(n).len();
let r = Rect::new(ix, yy, 4.0 * CW(), CH() * 2.0);
button(gfx, r, "<", r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchSize(-1)));
gfx.text(&trf("net.map_size", &[n.to_string(), n.to_string(), seats.to_string()]), ix + 5.0 * CW(), yy + CH() * 0.5, S(), TXT);
let r = Rect::new(ix + 30.0 * CW(), yy, 4.0 * CW(), CH() * 2.0);
button(gfx, r, ">", r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchSize(1)));
yy += CH() * 2.4;
}
self.field(gfx, ix, yy, w, &tr("net.join_hint"), 0, false); self.field(gfx, ix, yy, w, &tr("net.join_hint"), 0, false);
self.field(gfx, ix, yy + CH() * 2.4, w, &tr("net.chat"), 1, false); self.field(gfx, ix, yy + CH() * 2.4, w, &tr("net.chat"), 1, false);
yy += CH() * 4.8; yy += CH() * 4.8;
@@ -1962,10 +2096,21 @@ impl Menu {
items.push((Btn::MatchCoopPrev, "<".to_string())); items.push((Btn::MatchCoopPrev, "<".to_string()));
items.push((Btn::MatchCoopCreate, tr("net.coop_create"))); items.push((Btn::MatchCoopCreate, tr("net.coop_create")));
items.push((Btn::MatchCoopNext, ">".to_string())); items.push((Btn::MatchCoopNext, ">".to_string()));
items.push((Btn::MatchCoopSolo, tr("net.coop_solo")));
} }
if !self.campaign_doc.missions.is_empty() { // an interrupted run of the shown campaign can go on (online, or offline with the AI)
items.push((Btn::MatchCoopSource, tr(if editor_coop { "net.coop_builtin" } else { "net.coop_editor" }))); if editor_coop {
if let Some((m, _, _)) = crate::coop_resume_of(&doc) {
items.push((Btn::MatchCoopResume, trf("net.coop_resume", &[(m + 1).to_string()])));
}
} }
// the button names what comes next
let next = match (editor_coop, self.coop_league) {
(false, _) => "net.coop_league",
(true, true) if !self.campaign_doc.missions.is_empty() => "net.coop_editor",
_ => "net.coop_builtin",
};
items.push((Btn::MatchCoopSource, tr(next)));
} }
if ui.room_open() { if ui.room_open() {
items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" }))); items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));
@@ -1986,7 +2131,8 @@ impl Menu {
items.push((Btn::MatchLeave, tr("net.leave"))); items.push((Btn::MatchLeave, tr("net.leave")));
} }
items.push((Btn::Back, tr("menu.back"))); items.push((Btn::Back, tr("menu.back")));
let bw = 14.0 * CW(); // three buttons a row over the whole width
let bw = ((w - 2.0 * CW()) / 3.0).max(14.0 * CW());
let shown = items.iter().take(9).count(); let shown = items.iter().take(9).count();
for (k, (b, label)) in items.iter().take(shown).enumerate() { for (k, (b, label)) in items.iter().take(shown).enumerate() {
let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0); let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0);
+137
View File
@@ -0,0 +1,137 @@
// The achievements page: every achievement with its badge (bronze, silver, gold, ... in the
// colour of its tier), name, description and the progress towards it. Hidden ones show "???"
// until they are unlocked.
use super::*;
use crate::achievements::{all, texts, TIERS};
const TIER_COL: [(f32, f32, f32); 5] = [(0.72, 0.45, 0.25), (0.75, 0.78, 0.85), (0.95, 0.78, 0.25), (0.55, 0.9, 1.0), (0.85, 0.5, 1.0)];
impl Menu {
pub(super) fn draw_achievements(&mut self, gfx: &Gfx) {
let sw = sw();
let sh = sh();
let cols = 70.min((sw / CW()) as i32 - 2);
let rows = 40.min((sh / CH()) as i32 - 2);
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
let title = if self.ach_hall { tr("hall.title") } else { tr("menu.achievements") };
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI);
if self.ach_hall {
self.draw_hall_page(gfx, x, y, cols, rows);
self.ach_buttons(gfx, x, y, rows);
return;
}
let list = all();
let done = list.iter().filter(|a| self.profile.unlocked.contains_key(&a.0)).count();
let head = trf("ach.count", &[done.to_string(), list.len().to_string(), crate::achievements::fmt_count(self.profile.total("minutes"))]);
gfx.text(&head, x + CW() * 3.0, y + CH() * 1.6, S(), GOLD);
let top = y + CH() * 3.5;
let bottom = y + (rows - 4) as f32 * CH();
let row_h = CH() * 3.2;
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
self.scroll = self.scroll.min(list.len().saturating_sub(self.rows_visible));
let ix = x + CW() * 3.0;
let w = (cols - 6) as f32 * CW();
let maxc = ((w - CW() * 6.0) / (GW * SMALL())) as usize;
for (k, (id, hidden, icon, tier)) in list.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
let ry = top + (k - self.scroll) as f32 * row_h;
let got = self.profile.unlocked.contains_key(id);
// the badge: a frame in the tier's colour around the icon (dark while locked)
let (r, g, b) = TIER_COL[(*tier).min(4)];
let bc = if got { Color::new(r, g, b, 1.0) } else { Color::new(0.25, 0.22, 0.22, 1.0) };
let pic = CH() * 2.6;
draw_rectangle(ix, ry, pic, pic, Color::new(0.08, 0.05, 0.05, 1.0));
draw_rectangle_lines(ix, ry, pic, pic, 3.0, bc);
let tint = if got { WHITE } else { Color::new(0.3, 0.3, 0.3, 1.0) };
draw_texture_ex(&gfx.badges, ix + 4.0, ry + 4.0, tint, DrawTextureParams { source: Some(crate::badges::badge_rect(*icon)), dest_size: Some(vec2(pic - 8.0, pic - 8.0)), ..Default::default() });
let tx = ix + pic + CW();
let (name, desc) = if *hidden && !got { ("???".to_string(), tr("ach.hidden")) } else { texts(id) };
gfx.text(&name, tx, ry + CH() * 0.1, S(), if got { GOLD } else { TXT });
let d: String = desc.chars().take(maxc).collect();
gfx.text(&d, tx, ry + CH() * 1.3, SMALL(), DIM);
// progress towards a tier
if let Some((cat, t)) = id.split_once('.') {
if !got {
let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t.parse::<usize>().unwrap_or(1) - 1)).copied().unwrap_or(1.0);
let have = self.profile.total(cat);
let bar_x = x + (cols - 20) as f32 * CW();
crate::ui::ascii_bar(gfx, bar_x, ry + CH() * 0.1, 10, (have / need).min(1.0) as f32, FRAME_HI, SMALL());
let p = format!("{}/{}", crate::achievements::fmt_count(have.floor()), crate::achievements::fmt_count(need));
gfx.text(&p, bar_x, ry + CH() * 1.3, SMALL(), DIM);
}
}
}
// scrolling and back
let _ = ix;
self.ach_buttons(gfx, x, y, rows);
}
// scrolling, the switch between achievements and the hall, back
fn ach_buttons(&mut self, gfx: &Gfx, x: f32, y: f32, rows: i32) {
let ix = x + CW() * 3.0;
let by = y + (rows - 3) as f32 * CH();
let (mx, my) = mouse();
let bw = 14.0 * CW();
let switch = if self.ach_hall { tr("menu.achievements") } else { tr("hall.title") };
let items = [(Btn::Scroll(-1), "▲".to_string()), (Btn::Scroll(1), "▼".to_string()), (Btn::AchHall, switch), (Btn::Back, tr("menu.back"))];
let bw = bw.min((sw() - ix - CW() * 4.0) / 4.0 - CW());
for (k, (b, label)) in items.iter().enumerate() {
let r = Rect::new(ix + k as f32 * (bw + CW()), by, bw, CH() * 2.0);
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, *b));
}
}
// the hall of the fallen over all games: newest first, two lines each
fn draw_hall_page(&mut self, gfx: &Gfx, x: f32, y: f32, cols: i32, rows: i32) {
let hall = &self.profile.hall;
gfx.text(&trf("hall.count", &[hall.len().to_string()]), x + CW() * 3.0, y + CH() * 1.6, S(), GOLD);
let top = y + CH() * 3.5;
let bottom = y + (rows - 4) as f32 * CH();
let row_h = CH() * 2.4;
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
self.scroll = self.scroll.min(hall.len().saturating_sub(self.rows_visible));
let ix = x + CW() * 3.0;
let maxc = (cols - 6) as usize;
if hall.is_empty() {
gfx.text(&tr("hall.empty"), ix, top, S(), DIM);
}
for (k, f) in hall.iter().rev().enumerate().skip(self.scroll).take(self.rows_visible) {
let ry = top + (k - self.scroll) as f32 * row_h;
let name = format!("{} ({})", crate::names::full_name(f.kind, f.name, f.level), trf("info.level", &[tr(crate::game::st(f.kind).name), f.level.to_string()]));
gfx.text(&name.chars().take(maxc).collect::<String>(), ix, ry, S(), FRAME_HI);
let kills = if f.kills > 0 { format!(" · {}", trf("hall.kills", &[f.kills.to_string()])) } else { String::new() };
let line = format!("{}{} · {}", crate::names::cause(f.bane), kills, date(f.when));
gfx.text(&line.chars().take(maxc * 2).collect::<String>(), ix + CW() * 2.0, ry + CH(), SMALL(), DIM);
}
}
}
// a unix time as a date (year-month-day)
fn date(unix: u64) -> String {
if unix == 0 {
return String::new();
}
// days since 1970-01-01 to a civil date (Howard Hinnant's algorithm)
let z = (unix / 86400) as i64 + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
format!("{:04}-{:02}-{:02}", y, m, d)
}
#[cfg(test)]
mod tests {
#[test]
fn dates_are_civil() {
assert_eq!(super::date(0), "");
assert_eq!(super::date(1_798_675_200), "2026-12-31");
assert_eq!(super::date(1_709_164_800), "2024-02-29");
}
}
+5 -3
View File
@@ -7,10 +7,10 @@ use crate::ui::{tool_cost_static, BLUE as BLUE_C, GREEN as GREEN_C, RED as RED_C
pub const TABS: [&str; 4] = ["best.tab.creatures", "best.tab.humans", "best.tab.dungeon", "best.tab.ranks"]; pub const TABS: [&str; 4] = ["best.tab.creatures", "best.tab.humans", "best.tab.dungeon", "best.tab.ranks"];
const CREATURES: [EK; 14] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Cogling, EK::Revenant, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm]; const CREATURES: [EK; 16] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Cogling, EK::Revenant, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm, EK::CaveBear, EK::Beetle];
const HUMANS: [EK; 8] = [EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Fairy, EK::BattleMage, EK::Giant, EK::Paladin]; const HUMANS: [EK; 14] = [EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Fairy, EK::Thief, EK::BattleMage, EK::Monk, EK::Giant, EK::Ranger, EK::Inquisitor, EK::Paladin, EK::Ram, EK::Catapult];
// buildings: indices into ui::TOOLS // buildings: indices into ui::TOOLS
const BUILDINGS: [usize; 30] = [2, 3, 4, 5, 6, 12, 40, 13, 18, 21, 41, 42, 43, 44, 17, 19, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]; const BUILDINGS: [usize; 32] = [2, 3, 4, 5, 6, 12, 40, 13, 18, 21, 41, 42, 43, 44, 17, 47, 49, 19, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum Entry { enum Entry {
@@ -266,6 +266,8 @@ impl Menu {
crate::ui::Tool::Room(r) => crate::game::ROOM_NAMES[r].trim_start_matches("room.").to_string(), crate::ui::Tool::Room(r) => crate::game::ROOM_NAMES[r].trim_start_matches("room.").to_string(),
crate::ui::Tool::Tower(k) => crate::game::tower_name(k).trim_start_matches("tower.").to_string(), crate::ui::Tool::Tower(k) => crate::game::tower_name(k).trim_start_matches("tower.").to_string(),
crate::ui::Tool::Bridge => "bridge".into(), crate::ui::Tool::Bridge => "bridge".into(),
crate::ui::Tool::Drawbridge => "drawbridge".into(),
crate::ui::Tool::Wall => "wall".into(),
crate::ui::Tool::Trap(k) => k.id().into(), crate::ui::Tool::Trap(k) => k.id().into(),
_ => "steel".into(), _ => "steel".into(),
}; };
+49 -1
View File
@@ -109,6 +109,8 @@ enum Act {
Terrain(String, String), Terrain(String, String),
Kill(String, i32), Kill(String, i32),
Trigger(String, bool), Trigger(String, bool),
Weather(u8, f64),
TalentXp(f64),
} }
// the triggering unit (for "where the unit is" and "the unit is a ...") // the triggering unit (for "where the unit is" and "the unit is a ...")
@@ -142,7 +144,15 @@ pub fn kind_by_name(name: &str) -> Option<EK> {
18 => EK::Paladin, 18 => EK::Paladin,
19 => EK::Giant, 19 => EK::Giant,
20 => EK::Fairy, 20 => EK::Fairy,
_ => EK::BattleMage, 21 => EK::BattleMage,
22 => EK::Ram,
23 => EK::Catapult,
24 => EK::CaveBear,
25 => EK::Beetle,
26 => EK::Thief,
27 => EK::Monk,
28 => EK::Ranger,
_ => EK::Inquisitor,
}) })
} }
@@ -531,6 +541,12 @@ impl Game {
Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64, Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64,
Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64, Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64,
Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64, Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64,
Val::WeatherIs { kind } => {
// the weather felt now (a change counts once it has come in)
let w = &self.weather;
let now = if w.level > 0.5 { w.shown } else { crate::weather::FAIR };
(crate::blocks::WEATHER.iter().position(|k| k == kind) == Some(now as usize)) as i32 as f64
}
}; };
if r.is_finite() { if r.is_finite() {
r r
@@ -560,6 +576,8 @@ impl Game {
Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64), Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64),
Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)), Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)),
Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)), Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)),
Block::Weather { kind, secs } => Act::Weather(crate::blocks::WEATHER.iter().position(|k| k == kind).unwrap_or(0) as u8, self.eval(s, secs, ev)),
Block::TalentXp { amount } => Act::TalentXp(self.eval(s, amount, ev)),
Block::SetVar { name, value } => { Block::SetVar { name, value } => {
let v = self.eval(s, value, ev); let v = self.eval(s, value, ev);
s.vars.insert(name.clone(), v); s.vars.insert(name.clone(), v);
@@ -816,6 +834,13 @@ impl Game {
} }
} }
Act::Mana(n) => self.mana = (self.mana + n as f32).clamp(0.0, self.mana_max()), Act::Mana(n) => self.mana = (self.mana + n as f32).clamp(0.0, self.mana_max()),
// the weather comes (softly) and stays the given seconds (0: until a block changes it)
Act::Weather(k, secs) => {
self.weather.kind = k;
self.weather.left = if secs > 0.0 { secs as f32 } else { 1.0e9 };
self.weather.strike_t = self.weather.strike_t.min(5.0);
}
Act::TalentXp(n) => self.add_master_xp(n.max(0.0) as f32),
Act::Win => { Act::Win => {
self.state = 1; self.state = 1;
self.script_msg("msg.mission_won", vec![]); self.script_msg("msg.mission_won", vec![]);
@@ -846,6 +871,7 @@ impl Game {
"water" => Kind::Water, "water" => Kind::Water,
"lava" => Kind::Lava, "lava" => Kind::Lava,
"gold" => Kind::Gold, "gold" => Kind::Gold,
"grove" => Kind::Grove,
_ => Kind::Floor, _ => Kind::Floor,
}; };
for i in zone_tiles(&z) { for i in zone_tiles(&z) {
@@ -952,6 +978,28 @@ mod tests {
assert!(g.script.as_ref().unwrap().error.is_none(), "{:?}", g.script.as_ref().unwrap().error); assert!(g.script.as_ref().unwrap().error.is_none(), "{:?}", g.script.as_ref().unwrap().error);
} }
#[test]
fn weather_and_talent_blocks_work() {
let mut g = Game::new(3, 64, 64);
g.add_surface(3, 0.5, 2);
let scripts = MissionScripts {
start: vec![
Block::Weather { kind: "storm".into(), secs: Val::num(0.0) },
Block::TalentXp { amount: Val::num(400.0) },
Block::Wait { secs: Val::num(25.0) },
Block::If { cond: Val::WeatherIs { kind: "storm".into() }, then: vec![Block::SetVar { name: "storm".into(), value: Val::num(1.0) }], els: vec![] },
],
..Default::default()
};
g.script = Some(ScriptState::new(scripts, vec![], 7));
run(&mut g, 30.0);
assert_eq!(g.weather.kind, crate::weather::STORM);
assert!(g.weather.left > 1.0e8, "it stays");
assert!(g.talents.xp >= 400.0 && g.talents.free() >= 1);
assert_eq!(g.script.as_ref().unwrap().vars.get("storm"), Some(&1.0), "the condition sees the storm");
assert!(crate::blocks::check(&g.script.as_ref().unwrap().scripts.start, &crate::blocks::Names { zones: &[], triggers: &[], vars: &[] }).iter().all(|p| p.key != "blk.err_weather"));
}
#[test] #[test]
fn a_zone_trigger_spawns_and_the_victory_script_wins() { fn a_zone_trigger_spawns_and_the_victory_script_wins() {
let mut g = Game::new(3, 64, 64); let mut g = Game::new(3, 64, 64);
+289
View File
@@ -0,0 +1,289 @@
// Named creatures: every creature of a master carries a name seed from its birth. The name
// comes from the list of its kind in the language files (so it follows a language switch);
// from level 5 on it earns a humorous epithet, from level 8 a grand one. A creature of level
// 3 or more that falls leaves a memorial stone with its name on its master's ground.
use crate::game::*;
use crate::i18n::{tr, trf};
use crate::map::*;
use serde::{Deserialize, Serialize};
// the fallen are remembered in the hall from HALL_LEVEL on; only the greatest (MEMORIAL_LEVEL)
// get a stone on the map, at most MAX_MEMORIALS of them (the oldest go)
pub const HALL_LEVEL: u32 = 3;
pub const MEMORIAL_LEVEL: u32 = 10;
pub const MAX_MEMORIALS: usize = 30;
// what killed a creature besides another unit (Ent::bane)
pub const BANE_SNARE: u8 = 250;
pub const BANE_LIGHTNING: u8 = 251;
pub const BANE_LAVA: u8 = 252;
pub const BANE_WATER: u8 = 253;
// an entry of the hall of the fallen (in the game and in the player's profile)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Fallen {
pub kind: EK,
pub name: u32,
pub level: u32,
pub kills: u32,
pub bane: u8,
pub time: f32, // game seconds when it fell
#[serde(default)]
pub when: u64, // unix time (set when it goes into the profile)
}
// how it fell, in words
pub fn cause(bane: u8) -> String {
match bane {
BANE_SNARE => tr("cause.snare"),
BANE_LIGHTNING => tr("cause.lightning"),
BANE_LAVA => tr("cause.lava"),
BANE_WATER => tr("cause.water"),
0 => tr("cause.fell"),
k => match crate::gfx::SPRITE_KINDS.get(k as usize - 1) {
Some(&kind) => trf("cause.slain", &[tr(st(kind).name)]),
None => tr("cause.fell"),
},
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Memorial {
pub tile: usize,
pub kind: EK,
pub name: u32,
pub level: u32,
}
// the name list of a kind of creature
pub fn group(k: EK) -> &'static str {
match k {
EK::Worker => "imp",
EK::Kobold => "kobold",
EK::Spider => "spider",
EK::Mage => "mage",
EK::Ogre => "ogre",
EK::Jailer => "jailer",
EK::Priestess => "priestess",
EK::Chainbreaker => "chainbreaker",
EK::Bat => "bat",
EK::Gargoyle => "gargoyle",
EK::Owl => "owl",
EK::Wyrm => "wyrm",
EK::Cogling => "cogling",
EK::Revenant => "revenant",
EK::CaveBear | EK::Beetle => "beast",
_ => "human",
}
}
// the seed of a new creature's name (from its id: the same on every computer of a match)
pub fn seed_for(id: u32) -> u32 {
(id.wrapping_mul(2654435761) ^ 0x5bd1_e995) | 1
}
fn pick(key: &str, seed: u32) -> Option<String> {
let list = tr(key);
if list == key {
return None; // no list in this language
}
let items: Vec<&str> = list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
(!items.is_empty()).then(|| items[seed as usize % items.len()].to_string())
}
// the name with its epithet (if the level has earned one); "" if the creature has no name
pub fn full_name(kind: EK, seed: u32, level: u32) -> String {
if seed == 0 {
return String::new();
}
let Some(base) = pick(&format!("names.{}", group(kind)), seed) else { return String::new() };
let list = if level >= 8 { "epithets.high" } else { "epithets.mid" };
match pick(list, seed >> 7).filter(|_| level >= 5) {
Some(ep) => trf("name.epithet", &[base, ep]),
None => base,
}
}
// the name of a unit, or its kind's name if it has none
pub fn display_name(e: &Ent) -> String {
let n = full_name(e.kind, e.name, e.level);
if n.is_empty() { tr(st(e.kind).name) } else { n }
}
// a message argument that is turned into the name when the message is shown (i18n::trf)
pub fn arg(e: &Ent) -> String {
if e.name == 0 {
return format!("@{}", st(e.kind).name);
}
format!("%{}.{}.{}", crate::gfx::sprite_index(e.kind), e.name, e.level)
}
// the counterpart of `arg`
pub fn from_arg(a: &str) -> String {
let p: Vec<u32> = a.split('.').filter_map(|s| s.parse().ok()).collect();
match p.as_slice() {
[k, seed, level] => {
let kind = crate::gfx::SPRITE_KINDS.get(*k as usize).copied().unwrap_or(EK::Kobold);
let n = full_name(kind, *seed, *level);
if n.is_empty() { tr(st(kind).name) } else { n }
}
_ => a.to_string(),
}
}
impl Game {
// a named creature of some standing fell: a stone with its name on its master's ground, as
// near to where it fell as there is plain floor
pub(crate) fn raise_memorial(&mut self, e: &Ent) -> bool {
if e.hero || e.name == 0 || e.level < HALL_LEVEL || !Owner::MASTER_SIDES.contains(&e.side) {
return false;
}
// the hall of the fallen keeps the local master's (also without room for a stone)
if e.side == self.local_side {
self.hall.push(Fallen { kind: e.kind, name: e.name, level: e.level, kills: e.kills, bane: e.bane, time: self.time, when: 0 });
}
// a stone only for the greatest
if e.level < MEMORIAL_LEVEL {
return false; // the usual message tells of it
}
let owner = Owner::of_side(e.side);
let (x, y) = (e.x as i32, e.y as i32);
let taken: Vec<usize> = self.memorials.iter().map(|m| m.tile).collect();
let mut best: Option<(usize, i32)> = None;
for dy in -6..=6 {
for dx in -6..=6 {
let (tx, ty) = (x + dx, y + dy);
if !inb(tx, ty) {
continue;
}
let i = idx(tx, ty);
let t = &self.map.t[i];
if t.kind == Kind::Floor && t.owner == owner && !taken.contains(&i) {
let d = dx * dx + dy * dy;
if best.map_or(true, |b| d < b.1) {
best = Some((i, d));
}
}
}
}
let Some((tile, _)) = best else { return false };
if self.memorials.len() >= MAX_MEMORIALS {
self.memorials.remove(0);
}
self.memorials.push(Memorial { tile, kind: e.kind, name: e.name, level: e.level });
if e.side == self.local_side {
self.tell_local("msg.creature_fallen", vec![arg(e)], None);
}
true
}
// stones on ground that was lost or built over are taken away
pub(crate) fn tidy_memorials(&mut self) {
let map = &self.map;
self.memorials.retain(|m| map.t[m.tile].kind == Kind::Floor && Owner::MASTER_SIDES.contains(&map.t[m.tile].owner.side()));
}
pub fn memorial_at(&self, i: usize) -> Option<&Memorial> {
self.memorials.iter().find(|m| m.tile == i)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creatures_have_names_and_earn_epithets() {
let mut g = Game::new(9, 64, 64);
let (cx, cy) = g.map.core;
let id = g.spawn(EK::Ogre, false, cx as f32 + 0.5, cy as f32 + 4.5, 1);
let e = g.ents.iter().find(|e| e.id == id).unwrap().clone();
assert!(e.name != 0);
let n1 = full_name(e.kind, e.name, 1);
assert!(!n1.is_empty() && !n1.contains("names."), "{}", n1);
let n5 = full_name(e.kind, e.name, 5);
let n9 = full_name(e.kind, e.name, 9);
assert!(n5.starts_with(&n1) || n5.ends_with(&n1), "{} / {}", n1, n5);
assert!(n5.len() > n1.len() && n9 != n5, "{} {} {}", n1, n5, n9);
assert_eq!(from_arg(arg(&e).trim_start_matches('%')), display_name(&e));
// heroes have none
let h = g.spawn(EK::Knight, true, cx as f32 + 0.5, cy as f32 + 5.5, 1);
assert_eq!(g.ents.iter().find(|e| e.id == h).unwrap().name, 0);
}
#[test]
fn the_hall_remembers_kills_and_how_they_fell() {
let mut g = Game::new(9, 64, 64);
let (cx, cy) = g.map.core;
for y in cy + 4..cy + 8 {
for x in cx..cx + 6 {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
let mut t = crate::achievements::Tracker::new(Default::default());
let ctx = crate::achievements::Context { coop: false, editor_test: false };
g.time = 1.0;
t.update(&g, &ctx, 6.0, 1_800_000_000);
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 1.5, cy as f32 + 5.5, 5);
let knight = g.spawn(EK::Knight, true, cx as f32 + 2.5, cy as f32 + 5.5, 1);
// the ogre slays the knight
let (oj, kj) = (g.ents.iter().position(|e| e.id == ogre).unwrap(), g.ents.iter().position(|e| e.id == knight).unwrap());
g.ents[kj].hp = 1.0;
let mut o = g.ents[oj].clone();
g.hit_for_test(&mut o, kj);
assert_eq!(o.kills, 1, "a hero slain");
g.ents[oj] = o;
// a second knight brings the ogre down
let k2 = g.spawn(EK::Knight, true, cx as f32 + 2.5, cy as f32 + 6.5, 1);
let k2j = g.ents.iter().position(|e| e.id == k2).unwrap();
let oj = g.ents.iter().position(|e| e.id == ogre).unwrap();
g.ents[oj].hp = 1.0;
let mut k = g.ents[k2j].clone();
g.hit_for_test(&mut k, oj);
assert_eq!(g.hall.len(), 1);
let f = &g.hall[0];
assert_eq!((f.kind, f.kills), (EK::Ogre, 1));
assert_eq!(f.bane as u32, crate::gfx::sprite_index(EK::Knight) + 1);
assert!(cause(f.bane).contains(&crate::i18n::tr("unit.knight")));
// into the profile, once
g.time = 100.0;
t.update(&g, &ctx, 6.0, 1_800_000_100);
t.update(&g, &ctx, 6.0, 1_800_000_200);
assert_eq!(t.profile.hall.len(), 1);
assert_eq!(t.profile.hall[0].when, 1_800_000_100);
}
#[test]
fn the_fallen_get_a_memorial_stone() {
let mut g = Game::new(9, 64, 64);
let (cx, cy) = g.map.core;
for y in cy + 4..cy + 8 {
for x in cx..cx + 4 {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
let id = g.spawn(EK::Ogre, false, cx as f32 + 1.5, cy as f32 + 5.5, 10);
let j = g.ents.iter().position(|e| e.id == id).unwrap();
g.kill(j);
assert_eq!(g.memorials.len(), 1);
let m = g.memorials[0].clone();
assert_eq!((m.kind, m.level), (EK::Ogre, 10));
// level 4: in the hall, but no stone on the map
let o4 = g.spawn(EK::Ogre, false, cx as f32 + 3.5, cy as f32 + 5.5, 4);
let j = g.ents.iter().position(|e| e.id == o4).unwrap();
g.kill(j);
assert_eq!(g.memorials.len(), 1);
assert_eq!(g.hall.len(), 2);
assert!(g.memorial_at(m.tile).is_some());
// a level 1 kobold is not remembered
let k = g.spawn(EK::Kobold, false, cx as f32 + 2.5, cy as f32 + 5.5, 1);
let j = g.ents.iter().position(|e| e.id == k).unwrap();
g.kill(j);
assert_eq!(g.memorials.len(), 1);
}
}
+11 -16
View File
@@ -152,7 +152,7 @@ impl Game {
} }
// a message and a sound for the player at this computer, whichever master is being processed // a message and a sound for the player at this computer, whichever master is being processed
fn tell_local(&mut self, key: &'static str, args: Vec<String>, sfx: Option<crate::game::Sfx>) { pub(crate) fn tell_local(&mut self, key: &'static str, args: Vec<String>, sfx: Option<crate::game::Sfx>) {
let me = self.me_side; let me = self.me_side;
let local = self.local_side; let local = self.local_side;
self.switch_to(local); self.switch_to(local);
@@ -356,23 +356,16 @@ impl Game {
.filter(|side| *side != self.local_side) .filter(|side| *side != self.local_side)
.collect(); .collect();
for side in self.ai_sides.clone() { for side in self.ai_sides.clone() {
if self.is_out(side) { if self.is_out(side) || self.rivals.iter().any(|r| r.side == side) {
continue; // a fallen master who left has nothing left for an AI to play continue; // a fallen master has nothing left to play; a brain is there already
} }
let core = if side == self.me_side { if !self.is_master(side) {
continue; continue;
} else if let Some(m) = self.masters.iter().find(|m| m.side == side) {
Some(m.core)
} else {
None
};
let Some(core) = core else { continue };
if let Some(k) = self.masters.iter().position(|m| m.side == side) {
self.masters.remove(k);
}
if !self.is_rival_side(side) {
self.add_rival(side, core.0 + 1, core.1 + 1, self.ai_difficulty);
} }
// the seat keeps its master (dungeon, gold, creatures): the brain (ai.rs) plays it
let core = self.core_of_side(side);
let difficulty = self.ai_difficulty;
self.rivals.push(crate::rival::Rival { side, core, hp: crate::rival::RIVAL_CORE_HP, alive: true, radius: 6, grow_t: 0.0, spawn_t: 0.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: crate::rival::PARTNER_GIFT_EVERY, think_t: 1.0, attacking: false, army0: 0.0, focus: None, defending: false });
} }
} }
@@ -629,7 +622,7 @@ mod tests {
b.sync_ai_sides(&[3], 1); b.sync_ai_sides(&[3], 1);
assert_eq!(a.checksum(), b.checksum(), "both clients convert side 3 the same way"); assert_eq!(a.checksum(), b.checksum(), "both clients convert side 3 the same way");
assert!(a.is_rival_side(3) && b.is_rival_side(3)); assert!(a.is_rival_side(3) && b.is_rival_side(3));
assert!(!a.is_master(3) && !b.is_master(3)); assert!(a.is_master(3) && b.is_master(3), "the seat keeps its master; a brain plays it");
a.receive_turn(&[]); a.receive_turn(&[]);
b.receive_turn(&[]); b.receive_turn(&[]);
assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically"); assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically");
@@ -755,6 +748,8 @@ mod tests {
e.side = 3; e.side = 3;
} }
assert_eq!(g.nearest_enemy(&red_e, 3.0), Some(blue), "the other team is"); assert_eq!(g.nearest_enemy(&red_e, 3.0), Some(blue), "the other team is");
// the blue test unit next to green's heart has done its job (it would see the heart)
g.ents.retain(|e| e.id != blue);
} }
for _ in 0..40 { for _ in 0..40 {
turn_all(&mut games, &[]); turn_all(&mut games, &[]);
+246 -6
View File
@@ -1,11 +1,14 @@
// Orders from the hand: "attack" sends the fighting creatures nearby at an enemy unit or tower, // Orders: "attack" on an enemy unit or tower sends the fighting creatures nearby at it; on open
// "guard" posts a few creatures in a room. Right click with an order tool cancels the orders. // ground it sets an attack marker (a sword) - the ready creatures share out over all swords and
// fight where they stand. "Guard" sets a guard marker (a shield) that three creatures hold. Up to
// eight of each; a right click on a marker removes it (elsewhere: the guards stand down).
use crate::game::*; use crate::game::*;
use crate::map::*; use crate::map::*;
const ORDER_RADIUS: f32 = 18.0; const ORDER_RADIUS: f32 = 18.0;
const GUARDS: usize = 3; const GUARDS: usize = 3;
pub const MAX_MARKS: usize = 8;
fn ready(e: &Ent) -> bool { fn ready(e: &Ent) -> bool {
e.alive && !e.held && e.creature() && !matches!(e.job, Job::Leave | Job::Register | Job::MakeBed(_)) && e.hp > e.max_hp * 0.25 e.alive && !e.held && e.creature() && !matches!(e.job, Job::Leave | Job::Register | Job::MakeBed(_)) && e.hp > e.max_hp * 0.25
@@ -26,8 +29,8 @@ impl Game {
(Some(j), _) => (self.ents[j].x, self.ents[j].y), (Some(j), _) => (self.ents[j].x, self.ents[j].y),
(None, Some((_, p))) => p, (None, Some((_, p))) => p,
_ => { _ => {
self.hint(31, "hint.order_attack"); // open ground: an attack marker
self.sfx_ui(Sfx::Error); self.place_mark(wx.floor() as i32, wy.floor() as i32, true);
return; return;
} }
}; };
@@ -59,8 +62,177 @@ impl Game {
self.msg("msg.order_attack", vec![n.to_string()]); self.msg("msg.order_attack", vec![n.to_string()]);
} }
// guard: a few creatures take positions in the room at (x, y) // guard: a guard marker at (x, y) (three creatures hold it, see update_marks)
pub fn order_guard(&mut self, x: i32, y: i32) { pub fn order_guard(&mut self, x: i32, y: i32) {
self.place_mark(x, y, false);
}
// a sword (attack) or shield (guard) marker on tile (x, y)
pub fn place_mark(&mut self, x: i32, y: i32, attack: bool) {
if !inb(x, y) {
return;
}
let i = idx(x, y);
let t = self.map.t[i];
// a guard marker stands on own ground, an attack marker anywhere one can walk
let ok = if attack { t.walkable() || t.bridge() } else { t.owner == self.me && t.walkable() };
if !ok {
self.hint(31, if attack { "hint.order_attack" } else { "hint.order_guard" });
self.sfx_ui(Sfx::Error);
return;
}
let marks = if attack { &self.attack_marks } else { &self.guard_marks };
if marks.iter().any(|&m| {
let (mx, my) = xy(m);
(mx - x).abs() <= 1 && (my - y).abs() <= 1
}) {
// a second click (tap) on a marker takes it away again
self.unmark(x as f32 + 0.5, y as f32 + 0.5);
return;
}
if marks.len() >= MAX_MARKS {
self.hint(31, "hint.marks_full");
self.sfx_ui(Sfx::Error);
return;
}
if attack {
self.attack_marks.push(i);
} else {
self.guard_marks.push(i);
}
self.marks_t = 0.0; // creatures are sent at once
self.fx(7, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, if attack { 0xff3040ff } else { 0xff40d0ff });
self.sfx_ui(Sfx::FlagSet);
self.msg(if attack { "msg.mark_attack" } else { "msg.mark_guard" }, vec![]);
}
// a right click with an order tool: the marker there goes (its creatures are free again);
// without a marker there the guards stand down
pub fn unmark(&mut self, wx: f32, wy: f32) {
// the command markers there go with it
self.marks.retain(|m| (m.x - wx).powi(2) + (m.y - wy).powi(2) >= 2.0 * 2.0);
let near = |m: &usize| {
let (mx, my) = xy(*m);
(mx as f32 + 0.5 - wx).powi(2) + (my as f32 + 0.5 - wy).powi(2) < 1.6 * 1.6
};
if let Some(k) = self.attack_marks.iter().position(near) {
let m = self.attack_marks.remove(k);
for e in self.ents.iter_mut().filter(|e| e.job == Job::March(m)) {
e.job = Job::None;
e.path.clear();
}
} else if let Some(k) = self.guard_marks.iter().position(near) {
let m = self.guard_marks.remove(k);
for j in 0..self.ents.len() {
if let Job::Guard(spot) = self.ents[j].job {
if guard_of(m, spot) && self.ents[j].creature() {
let me = self.ents[j].id + 1;
self.unreserve(me);
self.ents[j].job = Job::None;
}
}
}
} else if let Some(f) = self.flag.filter(|f| near(f)) {
// the rally flag goes the same way
let (fx, fy) = xy(f);
self.set_flag(fx, fy);
return;
} else {
self.cancel_orders();
return;
}
self.msg("msg.mark_removed", vec![]);
self.sfx_ui(Sfx::Click);
}
// every second: three creatures for every shield, the ready ones shared out over the swords
pub(crate) fn update_marks(&mut self, dt: f32) {
self.marks_t -= dt;
if self.marks_t > 0.0 || (self.guard_marks.is_empty() && self.attack_marks.is_empty()) {
return;
}
self.marks_t = 1.0;
// markers on ground that is gone (dug in, lost) go
let me = self.me;
let map = &self.map;
self.guard_marks.retain(|&m| map.t[m].owner == me && map.t[m].walkable());
self.attack_marks.retain(|&m| map.t[m].walkable() || map.t[m].bridge());
// shields
for m in self.guard_marks.clone() {
let have = self.ents.iter().filter(|e| e.alive && e.creature() && matches!(e.job, Job::Guard(s) if guard_of(m, s))).count();
for _ in have..GUARDS {
if !self.send_guard(m) {
break;
}
}
}
// swords: every ready creature that is free goes to the nearest sword with room
if self.attack_marks.is_empty() {
return;
}
let free: Vec<usize> = (0..self.ents.len())
.filter(|&j| {
let e = &self.ents[j];
ready(e) && e.hp > e.max_hp * 0.5 && e.energy > 0.15 && matches!(e.job, Job::None | Job::Wander | Job::Train(_) | Job::Study(_) | Job::March(_))
})
.collect();
let cap = free.len().div_ceil(self.attack_marks.len()).max(1);
let mut count: Vec<usize> = self.attack_marks.iter().map(|&m| self.ents.iter().filter(|e| e.alive && e.job == Job::March(m)).count()).collect();
for j in free {
if matches!(self.ents[j].job, Job::March(_)) {
continue;
}
let (ex, ey) = (self.ents[j].x, self.ents[j].y);
let best = (0..self.attack_marks.len()).filter(|&k| count[k] < cap).min_by(|&a, &b| {
let d = |k: usize| {
let (mx, my) = xy(self.attack_marks[k]);
(mx as f32 + 0.5 - ex).powi(2) + (my as f32 + 0.5 - ey).powi(2)
};
d(a).partial_cmp(&d(b)).unwrap_or(std::cmp::Ordering::Equal)
});
let Some(k) = best else { break };
count[k] += 1;
let mark = self.attack_marks[k];
let me = self.ents[j].id + 1;
self.unreserve(me);
let e = &mut self.ents[j];
e.job = Job::March(mark);
e.path.clear();
e.repath = 0.0;
}
}
// the nearest free creature takes a free spot next to shield m
fn send_guard(&mut self, m: usize) -> bool {
let (x, y) = xy(m);
let spot = (-2..=2).flat_map(|dy| (-2..=2).map(move |dx| (dx, dy))).filter(|&(dx, dy)| inb(x + dx, y + dy)).map(|(dx, dy)| idx(x + dx, y + dy)).filter(|&j| {
let t = &self.map.t[j];
t.walkable() && t.owner == self.me && t.reserved == 0 && !self.ents.iter().any(|e| e.job == Job::Guard(j))
}).min_by_key(|&j| {
let (sx, sy) = xy(j);
(sx - x).pow(2) + (sy - y).pow(2)
});
let Some(spot) = spot else { return false };
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let cand = (0..self.ents.len()).filter(|&j| ready(&self.ents[j]) && !matches!(self.ents[j].job, Job::Attack(_) | Job::Guard(_) | Job::March(_) | Job::Rally)).min_by(|&a, &b| {
let d = |j: usize| (self.ents[j].x - fx).powi(2) + (self.ents[j].y - fy).powi(2);
d(a).partial_cmp(&d(b)).unwrap_or(std::cmp::Ordering::Equal)
});
let Some(j) = cand else { return false };
let Some(p) = self.map.creature_path(flies(self.ents[j].kind), self.ents[j].tile(), spot) else { return false };
let me = self.ents[j].id + 1;
self.unreserve(me);
self.map.t[spot].reserved = me;
let e = &mut self.ents[j];
e.job = Job::Guard(spot);
e.path = p;
e.timer = 1.0e9; // until the marker goes
true
}
// the old one-off guard order in a room (kept for scripts and tests)
#[allow(dead_code)]
pub fn order_guard_room(&mut self, x: i32, y: i32) {
if !inb(x, y) { if !inb(x, y) {
return; return;
} }
@@ -134,6 +306,13 @@ impl Game {
} }
} }
// is guard spot s next to shield m?
fn guard_of(m: usize, s: usize) -> bool {
let (mx, my) = xy(m);
let (sx, sy) = xy(s);
(mx - sx).abs() <= 2 && (my - sy).abs() <= 2
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -152,10 +331,71 @@ mod tests {
let j = g.find(id).unwrap(); let j = g.find(id).unwrap();
g.ents[j].job = Job::None; g.ents[j].job = Job::None;
} }
g.order_guard(cx - 1, cy - 1); g.order_guard_room(cx - 1, cy - 1);
let guards = g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count(); let guards = g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count();
assert_eq!(guards, 3); assert_eq!(guards, 3);
g.cancel_orders(); g.cancel_orders();
assert_eq!(g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count(), 0); assert_eq!(g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count(), 0);
} }
#[test]
fn a_second_tap_removes_a_marker_and_right_click_takes_the_flag() {
let mut g = Game::new(14, 64, 64);
let (cx, cy) = g.map.core;
for y in cy - 6..=cy + 8 {
for x in cx - 6..=cx + 10 {
let t = &mut g.map.t[idx(x, y)];
if t.kind != Kind::Core {
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
}
g.place_mark(cx + 5, cy + 5, false);
g.place_mark(cx + 7, cy + 2, true);
assert_eq!((g.guard_marks.len(), g.attack_marks.len()), (1, 1));
g.place_mark(cx + 5, cy + 5, false);
g.place_mark(cx + 7, cy + 2, true);
assert_eq!((g.guard_marks.len(), g.attack_marks.len()), (0, 0), "tapped again: gone");
g.set_flag(cx + 4, cy - 3);
assert!(g.flag.is_some());
g.unmark(cx as f32 + 4.5, cy as f32 - 2.5);
assert!(g.flag.is_none(), "the flag goes too");
}
#[test]
fn shields_are_held_and_swords_share_the_creatures() {
let mut g = Game::new(14, 64, 64);
let (cx, cy) = g.map.core;
for y in cy - 6..=cy + 8 {
for x in cx - 6..=cx + 10 {
let t = &mut g.map.t[idx(x, y)];
if t.kind != Kind::Core {
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
}
let ids: Vec<u32> = (0..6).map(|k| g.spawn(EK::Ogre, false, cx as f32 - 2.5, cy as f32 + 0.5 + k as f32 * 0.3, 2)).collect();
for &id in &ids {
let j = g.find(id).unwrap();
g.ents[j].settled = true;
g.ents[j].job = Job::None;
}
g.order_guard(cx + 5, cy + 5);
g.update_marks(1.0);
let guards = g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count();
assert_eq!(guards, 3, "three hold the shield");
g.place_mark(cx + 8, cy - 4, true);
g.place_mark(cx - 4, cy - 5, true);
g.update_marks(1.0);
let a = g.ents.iter().filter(|e| e.job == Job::March(g.attack_marks[0])).count();
let b = g.ents.iter().filter(|e| e.job == Job::March(g.attack_marks[1])).count();
assert!(a + b == 3 && a.abs_diff(b) <= 1, "{} and {}", a, b);
// a right click on a sword frees its creatures
let (sx, sy) = xy(g.attack_marks[0]);
g.unmark(sx as f32 + 0.5, sy as f32 + 0.5);
assert_eq!(g.attack_marks.len(), 1);
assert!(!g.ents.iter().any(|e| e.job == Job::March(idx(sx, sy))));
}
} }
+2
View File
@@ -28,6 +28,8 @@ pub fn persona(k: EK) -> Persona {
EK::Wyrm => p(Kind::Treasury, &[EK::Bat], Some((7, "inferno"))), EK::Wyrm => p(Kind::Treasury, &[EK::Bat], Some((7, "inferno"))),
EK::Cogling => p(Kind::Forge, &[EK::Ogre], Some((4, "bomb"))), EK::Cogling => p(Kind::Forge, &[EK::Ogre], Some((4, "bomb"))),
EK::Revenant => p(Kind::Graveyard, &[EK::Priestess], Some((5, "drain"))), EK::Revenant => p(Kind::Graveyard, &[EK::Priestess], Some((5, "drain"))),
EK::CaveBear => p(Kind::Farm, &[EK::Spider], None),
EK::Beetle => p(Kind::Lair, &[EK::Bat], None),
_ => p(Kind::Lair, &[], None), _ => p(Kind::Lair, &[], None),
} }
} }
+128 -14
View File
@@ -1,7 +1,7 @@
// Browser integration via our own miniquad JS plugin (www/dc_plugin.js): // Browser integration via our own miniquad JS plugin (www/dc_plugin.js):
// persistent key/value storage (localStorage), a cache that is written to storage when the // persistent key/value storage (localStorage), a cache that is written to storage when the
// page is hidden or closed, file download, file upload and HTTP requests. // page is hidden or closed, file download, file upload and HTTP requests.
// Native builds use simple fallbacks. // Native builds keep the storage as files in the data directory (see imp below).
// Result of an HTTP request: status 0 means the service could not be reached // Result of an HTTP request: status 0 means the service could not be reached
pub struct HttpResult { pub struct HttpResult {
@@ -113,6 +113,11 @@ mod imp {
unsafe { dc_cache_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) } unsafe { dc_cache_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) }
} }
// where download() put a file, for the status line: the browser's download folder
pub fn saved_as(name: &str) -> String {
name.to_string()
}
pub fn download(name: &str, data: &[u8]) { pub fn download(name: &str, data: &[u8]) {
unsafe { dc_download(name.as_ptr(), name.len() as u32, data.as_ptr(), data.len() as u32) } unsafe { dc_download(name.as_ptr(), name.len() as u32, data.as_ptr(), data.len() as u32) }
} }
@@ -188,14 +193,84 @@ mod imp {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
mod imp { mod imp {
use std::cell::RefCell; // Desktop builds: the storage keys are files in the data directory, savegames and
// campaigns are written to its "saves" folder, the system file dialog opens files.
use std::collections::HashMap; use std::collections::HashMap;
thread_local! { use std::path::PathBuf;
static STORE: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new()); use std::sync::Mutex;
static STORE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
static PICKED: Mutex<Option<Vec<u8>>> = Mutex::new(None);
static PICKING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
// DUNGEONCRAFT_DATA, else the usual per-user application data folder
pub fn data_dir() -> PathBuf {
let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
if let Some(d) = env("DUNGEONCRAFT_DATA") {
return d;
}
if cfg!(target_os = "windows") {
if let Some(d) = env("APPDATA") {
return d.join("DungeonCraft");
}
} else if cfg!(target_os = "macos") {
if let Some(h) = env("HOME") {
return h.join("Library/Application Support/DungeonCraft");
}
} else if let Some(d) = env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share"))) {
return d.join("dungeoncraft");
}
PathBuf::from("dungeoncraft-data")
} }
pub fn saves_dir() -> PathBuf {
data_dir().join("saves")
}
// a storage key as a file name: only harmless characters
pub(super) fn key_file(key: &str) -> String {
let name: String = key.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }).collect();
if name.is_empty() || name.starts_with('.') {
format!("_{}", name)
} else {
name
}
}
// writes through a temporary file, so a crash never leaves half a file
fn write_file(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)
}
pub fn storage_get(key: &str) -> Option<String> { pub fn storage_get(key: &str) -> Option<String> {
STORE.with(|s| s.borrow().get(key).cloned()) let mut store = STORE.lock().unwrap_or_else(|e| e.into_inner());
let store = store.get_or_insert_with(HashMap::new);
if let Some(v) = store.get(key) {
return Some(v.clone());
}
let v = std::fs::read_to_string(data_dir().join("store").join(key_file(key))).ok()?;
store.insert(key.to_string(), v.clone());
Some(v)
} }
pub fn storage_set(key: &str, val: &str) {
STORE.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).insert(key.to_string(), val.to_string());
let path = data_dir().join("store").join(key_file(key));
if let Err(e) = write_file(&path, val.as_bytes()) {
eprintln!("DungeonCraft: cannot write {}: {}", path.display(), e);
}
}
// there is no "page left" moment on the desktop: write at once
pub fn cache_on_leave(key: &str, val: &str) {
storage_set(key, val);
}
pub fn prompt(_title: &str, _default: &str) -> Option<String> { pub fn prompt(_title: &str, _default: &str) -> Option<String> {
None None
} }
@@ -208,19 +283,47 @@ mod imp {
pub fn fetch_download_state() -> i32 { pub fn fetch_download_state() -> i32 {
0 0
} }
pub fn storage_set(key: &str, val: &str) {
STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string())); // a "download" lands in the saves folder
}
pub fn cache_on_leave(key: &str, val: &str) {
storage_set(key, val);
}
pub fn download(name: &str, data: &[u8]) { pub fn download(name: &str, data: &[u8]) {
let _ = std::fs::write(name, data); let path = saves_dir().join(key_file(name));
if let Err(e) = write_file(&path, data) {
eprintln!("DungeonCraft: cannot write {}: {}", path.display(), e);
}
} }
pub fn open_file() {}
// where download() put a file, for the status line
pub fn saved_as(name: &str) -> String {
saves_dir().join(key_file(name)).display().to_string()
}
fn pick() {
let dir = saves_dir();
let _ = std::fs::create_dir_all(&dir);
let file = rfd::FileDialog::new().set_directory(&dir).pick_file();
if let Some(bytes) = file.and_then(|f| std::fs::read(f).ok()) {
*PICKED.lock().unwrap_or_else(|e| e.into_inner()) = Some(bytes);
}
PICKING.store(false, std::sync::atomic::Ordering::SeqCst);
}
// opens the system file dialog (one at a time); poll with take_file()
pub fn open_file() {
if PICKING.swap(true, std::sync::atomic::Ordering::SeqCst) {
return;
}
// macOS dialogs must run on the main thread; elsewhere the game keeps running
if cfg!(target_os = "macos") {
pick();
} else if std::thread::Builder::new().name("file dialog".into()).spawn(pick).is_err() {
PICKING.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
pub fn take_file() -> Option<Vec<u8>> { pub fn take_file() -> Option<Vec<u8>> {
None PICKED.lock().unwrap_or_else(|e| e.into_inner()).take()
} }
// no savegame service in native builds: every request fails like an unreachable server // no savegame service in native builds: every request fails like an unreachable server
pub fn http(_method: &str, _url: &str, _body: &[u8]) -> u32 { pub fn http(_method: &str, _url: &str, _body: &[u8]) -> u32 {
0 0
@@ -270,3 +373,14 @@ pub use imp::*;
pub extern "C" fn dungeoncraft_crate_version() -> u32 { pub extern "C" fn dungeoncraft_crate_version() -> u32 {
1 1
} }
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
#[test]
fn storage_keys_become_harmless_file_names() {
assert_eq!(super::imp::key_file("dc.settings"), "dc.settings");
assert_eq!(super::imp::key_file("../etc/passwd"), "_.._etc_passwd");
assert_eq!(super::imp::key_file("a b/c"), "a_b_c");
assert_eq!(super::imp::key_file(""), "_");
}
}
+564 -103
View File
File diff suppressed because it is too large. Load diff
+85 -2
View File
@@ -34,16 +34,40 @@ pub const RESEARCH: &[(usize, f32)] = &[
(25, 650.0), // stone roller (25, 650.0), // stone roller
(37, 700.0), // invisibility (37, 700.0), // invisibility
(19, 700.0), // steel walls (19, 700.0), // steel walls
(47, 700.0), // drawbridge
(28, 800.0), // frost glow (28, 800.0), // frost glow
(48, 800.0), // frost spell
(38, 850.0), // plague (38, 850.0), // plague
(27, 900.0), // rune mine (27, 900.0), // rune mine
(39, 950.0), // quake (39, 950.0), // quake
(44, 900.0), // sacrifice altar (44, 900.0), // sacrifice altar
(31, 1100.0), // magic door (31, 1100.0), // magic door
(50, 900.0), // battering ram
(51, 1000.0), // catapult
];
// room levels, researched once everything else is known: (index in ROOM_KINDS, level, points)
pub const UPGRADES: &[(usize, u8, f32)] = &[
(1, 2, 500.0), // lair
(2, 2, 500.0), // mushroom garden
(0, 2, 550.0), // treasury
(4, 2, 600.0), // library
(3, 2, 600.0), // training hall
(8, 2, 650.0), // forge
(6, 2, 650.0), // temple
(7, 2, 650.0), // aerie
(1, 3, 1000.0), // lair
(2, 3, 1000.0), // mushroom garden
(0, 3, 1100.0), // treasury
(4, 3, 1200.0), // library
(3, 3, 1200.0), // training hall
(8, 3, 1300.0), // forge
(6, 3, 1300.0), // temple
(7, 3, 1300.0), // aerie
]; ];
// known from the start in free play // known from the start in free play
pub const BASE_TOOLS: &[usize] = &[0, 1, 2, 3, 4, 6, 7, 8, 9, 14, 17, 20, 45, 46]; pub const BASE_TOOLS: &[usize] = &[0, 1, 2, 3, 4, 6, 7, 8, 9, 14, 17, 20, 45, 46, 49];
// campaign: what the next missions bring may be researched early // campaign: what the next missions bring may be researched early
const LOOKAHEAD: usize = 4; const LOOKAHEAD: usize = 4;
@@ -88,13 +112,45 @@ impl Game {
!self.known(tool) && self.researchable.get(tool).copied().unwrap_or(false) && RESEARCH.iter().any(|&(t, _)| t == tool) !self.known(tool) && self.researchable.get(tool).copied().unwrap_or(false) && RESEARCH.iter().any(|&(t, _)| t == tool)
} }
// the room level being researched once no tool is left: index in UPGRADES
pub fn upgrade_target(&self) -> Option<usize> {
if self.research_target().is_some() || self.known.is_empty() {
return None;
}
UPGRADES.iter().position(|&(r, lv, _)| self.room_level.get(r).copied().unwrap_or(1).max(1) < lv)
}
pub(crate) fn add_research(&mut self, points: f32) { pub(crate) fn add_research(&mut self, points: f32) {
if self.research_target().is_some() { if self.research_target().is_some() || self.upgrade_target().is_some() {
self.research += points; self.research += points;
} }
} }
// a room kind reached level `lv` (the map keeps it for the look of the rooms)
pub fn set_room_level(&mut self, r: usize, lv: u8) {
if self.room_level.len() < crate::game::N_ROOMS {
self.room_level.resize(crate::game::N_ROOMS, 1);
}
self.room_level[r] = lv;
let side = self.me_side;
self.map.upgrades.retain(|u| !(u.0 == side && u.1 as usize == r));
if lv > 1 {
self.map.upgrades.push((side, r as u8, lv));
}
}
pub(crate) fn update_research(&mut self) { pub(crate) fn update_research(&mut self) {
if let Some(u) = self.upgrade_target() {
let (r, lv, cost) = UPGRADES[u];
if self.research >= cost {
self.research -= cost;
self.set_room_level(r, lv);
let roman = if lv == 2 { "II" } else { "III" };
self.msg("msg.room_upgraded", vec![format!("@{}", crate::game::ROOM_NAMES[r]), roman.to_string()]);
self.sfx_ui(Sfx::LevelUp);
}
return;
}
let Some(k) = self.research_target() else { return }; let Some(k) = self.research_target() else { return };
let (tool, cost) = RESEARCH[k]; let (tool, cost) = RESEARCH[k];
if self.research >= cost { if self.research >= cost {
@@ -150,4 +206,31 @@ mod tests {
assert!(g.can_research(10), "heal comes soon and can be researched"); assert!(g.can_research(10), "heal comes soon and can be researched");
assert!(!g.can_research(31), "magic doors are far away"); assert!(!g.can_research(31), "magic doors are far away");
} }
#[test]
fn siege_engines_are_not_known_in_the_first_missions() {
let g = crate::campaign::start_mission(0, None);
for t in [19, 47, 48, 50, 51] {
assert!(!g.tool_allowed(t), "tool {} known in mission 1", t);
}
}
#[test]
fn once_everything_is_known_the_library_raises_room_levels() {
let mut g = Game::new(9, 64, 64);
assert!(g.upgrade_target().is_none(), "tools first");
for v in g.known.iter_mut() {
*v = true;
}
let u = g.upgrade_target().expect("a room level to research");
let (r, lv, cost) = UPGRADES[u];
assert_eq!(lv, 2);
let before = g.room_bonus(ROOM_KINDS[r]);
g.research = cost + 1.0;
g.update_research();
assert_eq!(g.room_level_of(ROOM_KINDS[r]), 2);
assert!(g.room_bonus(ROOM_KINDS[r]) > before);
assert!(g.map.upgrades.iter().any(|x| x.1 as usize == r && x.2 == 2), "the map shows it");
assert_ne!(g.upgrade_target(), Some(u), "the next one");
}
} }
+408 -189
View File
@@ -1,12 +1,17 @@
// Rival dungeon masters (simplified): a dungeon around their own soul core that grows over // Rival dungeon masters: each is a real master (gold, imps, rooms, research - see add_rival)
// time, creatures that appear there, guard it and attack in groups - the player, the other // played by the brain in ai.rs; this keeps their record (side, heart, difficulty, alive) for the
// master or the humans. A master whose core falls is out; its ground becomes free. // interface, the goals and the victory. A master whose heart falls is out; his ground becomes
// free. Units of a rival without a master (old savegames) still use rival_ai until he gets one.
use crate::game::*; use crate::game::*;
use crate::map::*; use crate::map::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const RIVAL_CORE_HP: f32 = 2000.0; pub const RIVAL_CORE_HP: f32 = 2000.0;
// a co-op AI partner helps masters short of gold: every minute it may send some
pub const PARTNER_GIFT_EVERY: f32 = 60.0;
pub const PARTNER_GIFT_BELOW: f32 = 300.0;
pub const PARTNER_GIFT: f32 = 400.0;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Rival { pub struct Rival {
@@ -20,23 +25,18 @@ pub struct Rival {
pub attack_t: f32, pub attack_t: f32,
pub difficulty: u8, // 0 easy, 1 medium, 2 hard pub difficulty: u8, // 0 easy, 1 medium, 2 hard
pub power: f32, // grows with the time: level of the new creatures pub power: f32, // grows with the time: level of the new creatures
} #[serde(default)]
pub gift_t: f32, // co-op AI partner: seconds until it looks whether its allies need gold
struct Pace { #[serde(default)]
spawn: f32, // seconds between new creatures pub think_t: f32, // the brain (ai.rs): seconds until the next thought
grow: f32, // seconds between growth steps #[serde(default)]
attack: f32, // seconds between attacks pub attacking: bool, // the attack flag is set on an enemy heart
cap: usize, // most creatures at a time #[serde(default)]
max_radius: i32, // largest dungeon pub army0: f32, // army strength when the attack began
power: f32, // levels per minute #[serde(default)]
} pub focus: Option<u8>, // balance runs: this master learns only one talent branch
#[serde(default)]
fn pace(d: u8) -> Pace { pub defending: bool, // the rally flag is on intruders in the dungeon (ai.rs)
match d {
0 => Pace { spawn: 55.0, grow: 45.0, attack: 330.0, cap: 6, max_radius: 8, power: 0.25 },
2 => Pace { spawn: 26.0, grow: 22.0, attack: 170.0, cap: 13, max_radius: 13, power: 0.6 },
_ => Pace { spawn: 38.0, grow: 30.0, attack: 240.0, cap: 9, max_radius: 11, power: 0.4 },
}
} }
impl Rival { impl Rival {
@@ -96,183 +96,77 @@ impl Game {
self.rivals.iter().any(|r| r.side == side && r.alive) self.rivals.iter().any(|r| r.side == side && r.alive)
} }
// sets up a rival dungeon around (cx, cy) // sets up a rival master around (cx, cy): a real master (ai.rs) with his heart, a little
// ground, a portal of his own, four imps and gold by the difficulty
pub fn add_rival(&mut self, side: u8, cx: i32, cy: i32, difficulty: u8) { pub fn add_rival(&mut self, side: u8, cx: i32, cy: i32, difficulty: u8) {
let owner = Owner::of_side(side);
let core = (cx - 1, cy - 1); let core = (cx - 1, cy - 1);
let r = 4; self.carve_master_area(side, core);
for y in cy - r..=cy + r { self.add_master_portal(side, core);
for x in cx - r..=cx + r { self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: 5, grow_t: 20.0, spawn_t: 5.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY, think_t: 2.0, attacking: false, army0: 0.0, focus: None, defending: false });
if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { self.adopt_master(side, core, difficulty, true);
continue;
}
let t = &mut self.map.t[idx(x, y)];
if matches!(t.kind, Kind::Portal | Kind::Gate | Kind::Core) {
continue; // portals and other cores stay
}
t.kind = Kind::Floor;
t.owner = owner;
t.gold = 0;
t.marked = false;
t.reinforced = false;
}
}
for y in 0..3 {
for x in 0..3 {
let t = &mut self.map.t[idx(core.0 + x, core.1 + y)];
t.kind = Kind::Core;
t.owner = owner;
}
}
// a lair and a treasury (the player can take it)
for y in cy - 3..=cy + 3 {
let t = &mut self.map.t[idx(cx - 3, y)];
t.kind = Kind::Lair;
}
for y in cy - 1..=cy + 1 {
let t = &mut self.map.t[idx(cx + 3, y)];
t.kind = Kind::Treasury;
t.gold = 300;
t.res = 0;
}
self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0 });
for k in 0..3 {
self.spawn_rival_unit(side, EK::Kobold, 1, k as f32);
}
} }
fn spawn_rival_unit(&mut self, side: u8, kind: EK, level: u32, k: f32) { // the rival on `side` gets (or has) a master of his own. `fresh`: a new one with imps and gold
let Some(rv) = self.rivals.iter().find(|r| r.side == side) else { return }; pub(crate) fn adopt_master(&mut self, side: u8, core: (i32, i32), difficulty: u8, fresh: bool) {
let (cx, cy) = rv.centre(); if self.is_master(side) {
let a = k * 1.7 + self.rng.f() * 2.0; return;
let (x, y) = (cx + a.cos() * 2.4, cy + a.sin() * 2.4);
let id = self.spawn(kind, false, x, y, level);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.side = side;
e.home = Some((cx, cy));
} }
let me = self.me_side;
self.add_master(side, core);
self.switch_to(side);
self.gold = crate::ai::start_gold(difficulty);
self.mana = 150.0;
self.recount();
if fresh {
let (cx, cy) = core;
for j in 0..4 {
self.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + j as f32 * 0.7 + 0.3, 1);
}
}
// the creatures of that side are his
for e in self.ents.iter_mut().filter(|e| e.alive && !e.hero && e.side == side) {
e.home = None;
e.target = None;
}
self.switch_to(me);
} }
pub fn update_rivals(&mut self, dt: f32) { pub fn update_rivals(&mut self, dt: f32) {
let back = self.me_side;
for k in 0..self.rivals.len() { for k in 0..self.rivals.len() {
if !self.rivals[k].alive { if !self.rivals[k].alive {
continue; continue;
} }
let p = pace(self.rivals[k].difficulty);
let side = self.rivals[k].side; let side = self.rivals[k].side;
{ // a rival from an older savegame or a painted map: he gets his master now
let r = &mut self.rivals[k]; if !self.is_master(side) {
r.power += dt / 60.0 * p.power; let (core, diff) = (self.rivals[k].core, self.rivals[k].difficulty);
r.grow_t -= dt; self.adopt_master(side, core, diff, false);
r.spawn_t -= dt;
r.attack_t -= dt;
} }
// growth: one more ring of ground (no imps needed) // his heart fell: he is out
if self.rivals[k].grow_t <= 0.0 { if self.core_hp_of(side) <= 0.0 {
self.rivals[k].grow_t = p.grow * (0.8 + self.rng.f() * 0.4); if !self.out_sides.contains(&side) {
if self.rivals[k].radius < p.max_radius { self.out_sides.push(side);
self.rivals[k].radius += 1;
self.rival_grow(k);
} }
self.eliminate_rival(k);
continue;
} }
// new creatures self.rivals[k].hp = self.core_hp_of(side) / CORE_HP * RIVAL_CORE_HP;
let units = self.ents.iter().filter(|e| e.alive && e.side == side && !e.hero).count(); // a co-op partner sends gold to allied masters who run short
if self.rivals[k].spawn_t <= 0.0 { if self.is_partner(side) {
self.rivals[k].spawn_t = p.spawn * (0.8 + self.rng.f() * 0.4); self.rivals[k].gift_t -= dt;
if units < p.cap { if self.rivals[k].gift_t <= 0.0 {
let power = self.rivals[k].power; self.rivals[k].gift_t = PARTNER_GIFT_EVERY;
let mut pool = vec![EK::Kobold, EK::Spider]; self.partner_gift(side);
if power >= 1.5 {
pool.extend([EK::Mage, EK::Ogre]);
}
if power >= 3.0 {
pool.push(EK::Gargoyle);
}
let kind = pool[(self.rng.next() as usize) % pool.len()];
let level = (1.0 + power) as u32;
self.spawn_rival_unit(side, kind, level.clamp(1, 10), units as f32);
}
}
// attack: about two thirds of the creatures march on an enemy
if self.rivals[k].attack_t <= 0.0 {
self.rivals[k].attack_t = p.attack * (0.8 + self.rng.f() * 0.4);
let mut targets: Vec<(u8, u32)> = Vec::new();
if self.coop {
if self.gate_on || self.ents.iter().any(|e| e.alive && e.hero) {
targets.push((1, 3));
}
} else {
// the masters (the player, or the human masters of a match) that are not allies
for s in self.master_sides() {
if !self.is_out(s) && !self.allied(side, s) {
targets.push((s, 3));
}
}
for o in &self.rivals {
if o.alive && o.side != side && !self.allied(side, o.side) {
targets.push((o.side, 1));
}
}
if self.has_gate() && self.gate_hp > 0.0 {
targets.push((1, 1));
}
}
let total: u32 = targets.iter().map(|t| t.1).sum();
if total == 0 {
continue;
}
let mut r = self.rng.next() % total;
let mut target = 0;
for (s, w) in targets {
if r < w {
target = s;
break;
}
r -= w;
}
let mut n = 0;
for e in self.ents.iter_mut() {
if e.alive && !e.hero && e.side == side && e.target.is_none() && n * 3 < units * 2 {
e.target = Some(target);
e.path.clear();
n += 1;
}
}
if n > 0 && target == 0 {
self.msg(if side == 2 { "msg.rival1_attack" } else { "msg.rival2_attack" }, vec![n.to_string()]);
self.sfx_ui(Sfx::WaveAlarm);
} }
} }
self.switch_to(side);
self.ai_think(k, dt);
self.switch_to(back);
} }
} }
// a ring of earth around the rival dungeon becomes its ground
fn rival_grow(&mut self, k: usize) {
let rv = self.rivals[k].clone();
let owner = Owner::of_side(rv.side);
let (cx, cy) = (rv.core.0 + 1, rv.core.1 + 1);
let r = rv.radius;
for y in cy - r..=cy + r {
for x in cx - r..=cx + r {
if (x - cx).abs() != r && (y - cy).abs() != r {
continue;
}
if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 {
continue;
}
let t = self.map.t[idx(x, y)];
let free = matches!(t.kind, Kind::Earth | Kind::Floor) && !t.reinforced && t.owner == Owner::None;
let touches = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == owner && n.walkable()));
if free && touches {
let tl = &mut self.map.t[idx(x, y)];
tl.kind = Kind::Floor;
tl.owner = owner;
tl.gold = 0;
tl.marked = false;
}
}
}
}
// damage to the core of a rival master // damage to the core of a rival master
pub fn rival_core_hit(&mut self, side: u8, dmg: f32) { pub fn rival_core_hit(&mut self, side: u8, dmg: f32) {
@@ -294,7 +188,16 @@ impl Game {
let (x, y) = self.rivals[k].centre(); let (x, y) = self.rivals[k].centre();
self.fx(1, x, y, 0.0, 0.0, 1.0, 0xffffffff); self.fx(1, x, y, 0.0, 0.0, 1.0, 0xffffffff);
self.sfx(Sfx::Victory, x, y); self.sfx(Sfx::Victory, x, y);
self.msg(if side == 2 { "msg.rival1_out" } else { "msg.rival2_out" }, vec![]); let key = if self.is_partner(side) {
"msg.partner_out"
} else if side == 2 {
"msg.rival1_out"
} else if side == 3 {
"msg.rival2_out"
} else {
"msg.rival3_out"
};
self.msg(key, vec![]);
// its creatures scatter and vanish, its ground is free // its creatures scatter and vanish, its ground is free
for e in self.ents.iter_mut() { for e in self.ents.iter_mut() {
if e.alive && !e.hero && e.side == side { if e.alive && !e.hero && e.side == side {
@@ -385,6 +288,41 @@ impl Game {
} }
// units of a rival master: fight enemies nearby, march when sent, otherwise guard home // units of a rival master: fight enemies nearby, march when sent, otherwise guard home
// an AI seat of a co-op game: an allied master who builds his own dungeon
pub fn is_partner(&self, side: u8) -> bool {
self.coop && self.ai_sides.contains(&side)
}
// the partner gives gold to every allied master below the limit
fn partner_gift(&mut self, side: u8) {
let me = self.me_side;
for s in self.master_sides() {
if self.is_out(s) || self.gold_of(s) >= PARTNER_GIFT_BELOW {
continue;
}
self.switch_to(s);
let got = self.add_gold(PARTNER_GIFT).floor();
if got >= 1.0 {
self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{side}")]);
self.sfx_ui(Sfx::Payday);
}
}
self.switch_to(me);
}
// a co-op partner's creatures follow the attack flag of the first master still standing
fn partner_flag(&self, side: u8) -> Option<usize> {
if !self.is_partner(side) {
return None;
}
let lead = self.master_sides().into_iter().find(|s| !self.is_out(*s))?;
if lead == self.me_side {
self.flag
} else {
self.masters.iter().find(|m| m.side == lead)?.flag
}
}
pub fn rival_ai(&mut self, e: &mut Ent, dt: f32) { pub fn rival_ai(&mut self, e: &mut Ent, dt: f32) {
if e.think <= 0.0 { if e.think <= 0.0 {
e.think = 0.5; e.think = 0.5;
@@ -397,18 +335,41 @@ impl Game {
} }
} }
} }
// two of three healthy creatures of a co-op partner join the attack at the allies' flag
let assist = if e.id % 3 != 0 && e.hp > e.max_hp * 0.4 { self.partner_flag(e.side) } else { None };
if let Job::Attack(id) = e.job { if let Job::Attack(id) = e.job {
let (hx, hy) = e.home.unwrap_or((e.x, e.y)); let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let far = e.target.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0; let far = e.target.is_none() && assist.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0;
if !far && self.attack_job(e, id, dt) { if !far && self.attack_job(e, id, dt) {
return; return;
} }
e.job = Job::None; e.job = Job::None;
e.path.clear(); e.path.clear();
} }
if let Some(f) = assist {
// the flag at the humans' gate: attack the gate
let (fx, fy) = xy(f);
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
let (gx, gy) = self.gate_center();
if e.target.is_none() && self.has_gate() && self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0 {
e.target = Some(1);
}
}
if self.march_on(e, dt) { if self.march_on(e, dt) {
return; return;
} }
if let Some(f) = assist {
let (fx, fy) = xy(f);
let d2 = (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2);
if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.5;
if let Some(p) = self.map.creature_path(flies(e.kind), e.tile(), f) {
e.path = p;
}
}
self.step_path(e, dt);
return;
}
// guard the own dungeon // guard the own dungeon
let (hx, hy) = e.home.unwrap_or((e.x, e.y)); let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let d2 = (e.x - hx).powi(2) + (e.y - hy).powi(2); let d2 = (e.x - hx).powi(2) + (e.y - hy).powi(2);
@@ -454,21 +415,113 @@ mod tests {
} }
#[test] #[test]
fn rival_grows_spawns_and_attacks() { fn an_ai_master_builds_its_dungeon() {
let mut g = rival_game(2); let mut g = rival_game(2);
let r0 = g.rivals[0].radius; let side = g.rivals[0].side;
let core0 = g.core_hp; let owner = Owner::of_side(side);
let mut attacked = false; assert!(g.is_master(side), "a real master");
for _ in 0..(12 * 60 * 20) { let ground0 = g.map.t.iter().filter(|t| t.owner == owner).count();
for _ in 0..(8 * 60 * 20) {
g.update(0.05); g.update(0.05);
attacked |= g.ents.iter().any(|e| e.rival() && e.target == Some(0)); }
if g.core_hp < core0 - 50.0 { let ground = g.map.t.iter().filter(|t| t.owner == owner).count();
break; assert!(ground > ground0 + 30, "its imps dig and claim: {} -> {}", ground0, ground);
g.switch_to(side);
let rooms: usize = g.counts.iter().sum();
let imps = g.worker_count();
g.switch_to(0);
assert!(rooms >= 9, "it builds rooms ({} tiles)", rooms);
assert!(imps >= 4, "and keeps its imps");
assert!(g.torches.iter().any(|t| g.map.t[t.wall].owner == owner || g.map.t[t.wall].solid()), "torches");
}
#[test]
fn the_fourth_master_works_too_and_is_no_dwarf() {
let mut g = (1..200u32).map(|s| Game::new_free(s, 96, 96, 1)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap();
let far = |g: &Game, (x, y): (i32, i32)| {
let d = |(a, b): (i32, i32)| (a - x).pow(2) + (b - y).pow(2);
d(g.map.core).min(d(g.rivals[0].core))
};
let spot = [(14, 14), (80, 14), (14, 80), (80, 80)].into_iter().max_by_key(|&p| far(&g, p)).unwrap();
g.add_rival(4, spot.0, spot.1, 1);
let owner = Owner::of_side(4);
let ground0 = g.map.t.iter().filter(|t| t.owner == owner).count();
for _ in 0..(6 * 60 * 20) {
g.update(0.05);
}
assert!(g.ents.iter().any(|e| e.alive && e.side == 4 && e.rival() && !e.dwarf()), "its units belong to a master");
let ground = g.map.t.iter().filter(|t| t.owner == owner).count();
assert!(ground > ground0 + 20, "the yellow master digs and claims: {} -> {}", ground0, ground);
}
#[test]
fn ai_marks_stay_near_its_ground_and_imps_dig_only_their_masters_marks() {
let mut g = rival_game(2);
let side = g.rivals[0].side;
let owner = Owner::of_side(side);
for _ in 0..(10 * 60 * 20) {
g.update(0.05);
if g.time as i32 % 20 == 0 {
for (i, t) in g.map.t.iter().enumerate() {
if t.marked && t.mark_by == side {
let (x, y) = crate::map::xy(i);
let near = (-5..=5).any(|dy| (-5..=5).any(|dx| g.map.get(x + dx, y + dy).map_or(false, |o| o.owner == owner)));
assert!(near, "AI mark at {},{} far from its ground", x, y);
}
}
} }
} }
assert!(g.rivals[0].radius > r0, "the rival dungeon grows"); // a foreign mark next to the player's imps stays untouched
assert!(attacked, "the rival sends creatures"); let (cx, cy) = g.map.core;
assert!(g.core_hp < core0, "they reach the player's core"); let spot = (0..g.map.t.len()).find(|&i| {
let (x, y) = crate::map::xy(i);
(x - cx).abs() < 10 && (y - cy).abs() < 10 && g.map.t[i].kind == Kind::Earth && !g.map.t[i].marked
&& DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |o| o.owner == Owner::Player && o.walkable()))
}).expect("earth beside the player's floor");
g.map.t[spot].reinforced = false;
g.map.t[spot].marked = true;
g.map.t[spot].mark_by = side;
for _ in 0..(60 * 20) {
g.update(0.05);
}
assert_eq!(g.map.t[spot].kind, Kind::Earth, "the player's imps leave the AI's mark alone");
}
#[test]
fn the_ai_summons_new_imps_when_its_imps_die() {
let mut g = rival_game(1);
let side = g.rivals[0].side;
for _ in 0..(60 * 20) {
g.update(0.05);
}
for e in g.ents.iter_mut().filter(|e| e.alive && e.side == side && e.kind == EK::Worker) {
e.hp = 0.0;
}
for _ in 0..20 {
g.update(0.05);
}
assert_eq!(g.ents.iter().filter(|e| e.alive && e.side == side && e.kind == EK::Worker).count(), 0, "all its imps are dead");
for _ in 0..(3 * 60 * 20) {
g.update(0.05);
}
let imps = g.ents.iter().filter(|e| e.alive && e.side == side && e.kind == EK::Worker).count();
assert!(imps >= 2, "new imps (one per 80 mana, like the player): {}", imps);
}
#[test]
fn ai_dig_marks_carry_the_masters_side() {
let mut g = rival_game(1);
let side = g.rivals[0].side;
for _ in 0..(40 * 20) {
g.update(0.05);
}
assert!(g.map.t.iter().any(|t| t.marked && t.mark_by == side), "the AI marks in its own colour");
let back = crate::save::from_bytes(crate::save::to_json(&g).as_bytes()).ok().expect("loads").game;
assert_eq!(
back.map.t.iter().filter(|t| t.marked && t.mark_by == side).count(),
g.map.t.iter().filter(|t| t.marked && t.mark_by == side).count(),
"kept in the save"
);
} }
#[test] #[test]
@@ -507,4 +560,170 @@ mod tests {
} }
assert!(fought, "heroes fight the rival master"); assert!(fought, "heroes fight the rival master");
} }
#[test]
fn hero_waves_march_on_the_strongest_master() {
let mut g = (1..200u32).map(|s| Game::new_free(s, 160, 160, 1)).find(|g| g.rivals.len() == 1 && g.gate_on).unwrap();
let side = g.rivals[0].side;
let (rx, ry) = g.rivals[0].centre();
let army = |g: &mut Game, side: u8, x: f32, y: f32, n: usize| {
for k in 0..n {
let id = g.spawn(EK::Ogre, false, x + k as f32 * 0.2, y, 6);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.side = side;
}
}
};
// the rival master is far stronger: the wave marches on him and is measured against him
g.gold = 0.0;
army(&mut g, side, rx, ry + 2.0, 8);
assert_eq!(g.wave_goal(), side);
let before = g.ents.len();
g.spawn_wave();
let heroes: Vec<&Ent> = g.ents[before..].iter().filter(|e| e.hero).collect();
assert!(!heroes.is_empty());
assert!(heroes.iter().all(|e| e.target == Some(side)), "the wave marches on the rival");
// now the player is the strongest: the next wave comes for him
let (cx, cy) = g.map.core;
army(&mut g, 0, cx as f32 + 1.5, cy as f32 + 4.0, 20);
assert_eq!(g.wave_goal(), 0);
let before = g.ents.len();
g.spawn_wave();
assert!(g.ents[before..].iter().filter(|e| e.hero).all(|e| e.target.is_none()), "the wave comes for the player");
}
// the first mission of the co-op league, set up like an offline co-op game: player on side 0,
// an AI partner on side 2, no server
fn offline_league_game() -> Game {
let doc = crate::editor::coop_campaign::coop_campaign();
let mut g = crate::campaign::start_editor_coop_mission(&doc.missions[0], 0, 77);
g.coop = true;
assert!(g.start_match(&[0, 2], 0));
g.sync_ai_sides(&[2], 1);
g.end_match();
g
}
#[test]
fn the_ai_partner_builds_only_what_is_researched() {
// the first missions of both co-op campaigns: no library, no forge yet
let doc = crate::editor::coop_campaign::coop_campaign();
let games = [
crate::campaign::start_editor_coop_mission(&doc.missions[0], 0, 77),
crate::campaign::start_coop_mission(crate::campaign::coop_mission_indexes()[0], None),
];
let lib = crate::ui::TOOLS.iter().position(|&t| t == crate::ui::Tool::Room(4)).unwrap();
let forge = crate::ui::TOOLS.iter().position(|&t| t == crate::ui::Tool::Room(8)).unwrap();
for mut g in games {
g.coop = true;
assert!(g.start_match(&[0, 2], 0));
g.sync_ai_sides(&[2], 1);
g.end_match();
let mine_knows = (g.tool_allowed(lib), g.tool_allowed(forge));
for _ in 0..(10 * 60 * 20) {
g.update(0.05);
}
let owner = Owner::of_side(2);
let has = |k: Kind| g.map.t.iter().any(|t| t.kind == k && t.owner == owner);
if !mine_knows.0 {
assert!(!has(Kind::Library), "the partner has no library either");
}
if !mine_knows.1 {
assert!(!has(Kind::Forge), "the partner has no forge either");
}
assert!(has(Kind::Lair) || has(Kind::Treasury), "but builds what it knows");
}
}
#[test]
fn imps_take_ground_from_enemies_but_never_from_allies() {
let mut g = offline_league_game();
assert!(g.allied(0, 2));
// a partner floor tile right beside the player's floor
let spot = (0..g.map.t.len()).find(|&i| {
let (x, y) = crate::map::xy(i);
g.map.t[i].kind == Kind::Floor && g.map.t[i].owner == Owner::Player
&& DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |o| o.kind == Kind::Floor && o.owner == Owner::Player))
&& x > 2 && y > 2
}).expect("player floor");
let (x, y) = crate::map::xy(spot);
let n = DIR4.iter().map(|&(dx, dy)| crate::map::idx(x + dx, y + dy)).find(|&j| g.map.t[j].owner == Owner::Player).unwrap();
g.map.t[n].owner = Owner::of_side(2);
g.map.t[n].reserved = 0;
assert!(!g.claimable(n, 999), "an ally's floor stays his");
// the same tile of an enemy master may be taken
g.coop = false;
g.teams = vec![(0, 0), (2, 1)];
assert!(!g.allied(0, 2));
assert!(g.claimable(n, 999), "an enemy's floor can be claimed");
}
#[test]
fn an_offline_coop_partner_is_an_allied_ai_master() {
let mut g = offline_league_game();
assert!(g.netplay.is_none() && g.is_master(2), "the partner is a real master without the server");
assert!(g.rival_alive(2) && g.is_partner(2));
assert!(g.allied(0, 2) && g.allied(2, 0));
// short of gold: the partner helps out within a minute
g.gold = 0.0;
for _ in 0..(65 * 20) {
g.update(0.05);
}
assert!(g.msgs.iter().any(|m| m.key == "msg.gold_received"), "the partner sent gold");
assert_eq!(g.state, 0);
// the partner's creatures never fight the player's
assert!(!g.ents.iter().any(|e| e.alive && !e.hero && e.side == 0 && e.hp < e.max_hp && matches!(e.job, Job::Attack(_))));
}
#[test]
fn the_partner_follows_the_attack_flag() {
let mut g = offline_league_game();
for _ in 0..(90 * 20) {
g.update(0.05);
}
let (rx, ry) = g.rivals.iter().find(|r| r.side == 2).unwrap().centre();
// a flag on the partner's own ground a little away from its core
let f = (0..g.map.t.len())
.find(|&i| {
let (x, y) = xy(i);
let d2 = (x as f32 - rx).powi(2) + (y as f32 - ry).powi(2);
g.map.t[i].owner == Owner::of_side(2) && g.map.walk(x, y) && d2 > 16.0 && d2 < 36.0
})
.expect("partner ground");
g.flag = Some(f);
let (fx, fy) = xy(f);
let mut near = false;
for _ in 0..(30 * 20) {
g.update(0.05);
near |= g.ents.iter().any(|e| e.alive && e.side == 2 && !e.hero && e.id % 3 != 0 && (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2) < 3.0 * 3.0);
if near {
break;
}
}
assert!(near, "partner creatures gather at the flag");
}
#[test]
#[ignore]
fn ai_master_report() {
for d in 0..3u8 {
let mut g = (1..200u32).map(|s| Game::new_free(s, 96, 96, d)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap();
let side = g.rivals[0].side;
for m in 1..=15 {
for _ in 0..(60 * 20) {
g.update(0.05);
}
g.switch_to(side);
let (rooms, imps, cr, gold) = (g.counts.iter().sum::<usize>(), g.worker_count(), g.creature_count(), g.gold);
let flag = g.flag.is_some();
let earned = g.stats.get(crate::stats::GOLD);
let me = g.me;
let veins = (0..g.map.t.len()).filter(|&i| g.map.t[i].marked && g.map.t[i].vein().is_some()).count();
let marked = g.map.t.iter().filter(|t| t.marked).count();
let near = (0..g.map.t.len()).filter(|&i| { let (x, y) = xy(i); g.map.t[i].marked && DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable())) }).count();
g.switch_to(0);
println!("diff {} min {:2}: rooms {:3} imps {:2} creatures {:2} gold {:5.0} attacking {} | marked {} veins {} reachable {} earned {:.0} | player core {:.0}", d, m, rooms, imps, cr, gold, flag, marked, veins, near, earned, g.core_hp);
}
}
}
} }
+367
View File
@@ -0,0 +1,367 @@
// The rogues of the later waves: the thief sneaks into a treasury and runs off with gold, the
// monk shrugs off spells, the ranger lays snares in the dungeon's corridors and the inquisitor
// now and then turns one of the weakest creatures (kobolds, spiders) to the heroes' side.
use crate::game::*;
use crate::map::*;
// (kind, first wave, first campaign mission (index)) - the wave pool takes them from then on
pub const ROGUES: [(EK, u32, usize); 4] = [(EK::Thief, 3, 4), (EK::Monk, 5, 8), (EK::Ranger, 7, 11), (EK::Inquisitor, 9, 15)];
// gold a thief can carry, and how fast he scoops it
pub const THIEF_BAG: i32 = 250;
const THIEF_GRAB: i32 = 25;
// a ranger's snares: one every SNARE_EVERY seconds, at most MAX_SNARES on the map
const SNARE_EVERY: f32 = 18.0;
const MAX_SNARES: usize = 30;
pub const SNARE_DAMAGE: f32 = 25.0;
pub const SNARE_STUN: f32 = 3.0;
// the inquisitor turns a weak creature every CONVERT_EVERY seconds, within CONVERT_RANGE tiles
pub const CONVERT_EVERY: f32 = 20.0;
const CONVERT_RANGE: f32 = 5.0;
// spells do not touch a monk of the order
pub fn spell_proof(e: &Ent) -> bool {
e.hero && e.kind == EK::Monk
}
// the creatures an inquisitor can turn: only the weakest
pub fn convertible(k: EK) -> bool {
matches!(k, EK::Kobold | EK::Spider)
}
fn master_ground(t: &Tile) -> bool {
Owner::MASTER_SIDES.contains(&t.owner.side())
}
impl Game {
// true: the rogue did all he wanted this step (the usual hero AI is skipped)
pub(crate) fn rogue_ai(&mut self, e: &mut Ent, dt: f32) -> bool {
if !e.active_hero() {
return false;
}
match e.kind {
EK::Thief => self.thief_ai(e, dt),
EK::Ranger => {
self.lay_snare(e);
false
}
EK::Inquisitor => {
self.convert_creature(e);
false
}
_ => false,
}
}
// ------------------------------------------------------------ thief
fn thief_ai(&mut self, e: &mut Ent, dt: f32) -> bool {
let cur = e.tile();
let full = e.carry >= THIEF_BAG;
// a full bag (or nothing left to steal): back to the gate and away
if full || (e.carry > 0 && !self.gold_to_steal(cur)) {
if !self.has_gate() {
return false;
}
let (gx, gy) = self.gate_center();
if (e.x - gx).powi(2) + (e.y - gy).powi(2) < 2.0 * 2.0 {
e.alive = false;
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
self.tell_local("msg.thief_escaped", vec![e.carry.to_string()], Some(Sfx::Error));
return true;
}
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.5;
if let Some(p) = self.map.path_to(cur, idx(gx as i32, gy as i32)) {
e.path = p;
}
}
self.step_path(e, dt);
return true;
}
// in a treasury with gold: scoop it up
let t = self.map.t[cur];
if t.kind == Kind::Treasury && t.gold > 0 && master_ground(&t) {
e.path.clear();
e.working = true;
if e.cd <= 0.0 {
e.cd = 0.5;
let want = THIEF_GRAB.min(THIEF_BAG - e.carry).min(t.gold);
let got = self.steal_gold(t.owner.side(), cur, want);
if got > 0 && e.carry == 0 && t.owner.side() == self.local_side {
self.tell_local("msg.thief_in_treasury", vec![], Some(Sfx::WaveAlarm));
}
e.carry += got;
self.fx(5, e.x, e.y, 0.0, 0.0, 0.5, 0xff30d0ff);
self.sfx(Sfx::Steal, e.x, e.y);
}
return true;
}
// on the way to the nearest treasury with gold
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 2.0;
let cost = self.map.hero_cost();
let found = self.map.search(cur, cost, |i| {
let t = &self.map.t[i];
t.kind == Kind::Treasury && t.gold > 0 && master_ground(t)
}, true);
match found {
Some(p) => e.path = p,
None if e.carry > 0 => {}
None => return false, // nothing to steal: he fights like the others
}
}
if e.path.is_empty() {
return e.carry > 0;
}
self.step_path(e, dt);
true
}
// is there a treasury with gold anywhere a thief could reach from tile i?
fn gold_to_steal(&self, i: usize) -> bool {
let cost = self.map.hero_cost();
self.map.search(i, cost, |j| {
let t = &self.map.t[j];
t.kind == Kind::Treasury && t.gold > 0 && master_ground(t)
}, true).is_some()
}
// takes gold from the treasury tile i of the master on `side`; returns what was taken
fn steal_gold(&mut self, side: u8, i: usize, want: i32) -> i32 {
let me = self.me_side;
if side != me {
if !self.is_master(side) {
return 0;
}
self.switch_to(side);
}
let take = (want as f32).min(self.gold).floor().max(0.0) as i32;
self.gold -= take as f32;
let t = &mut self.map.t[i];
t.gold = (t.gold - take).max(0);
if side != me {
self.switch_to(me);
}
take
}
// a slain thief drops his loot: it goes to whoever slew him (with the bounty)
pub(crate) fn thief_loot(&self, id: u32) -> f32 {
self.ents.iter().find(|e| e.id == id).map_or(0.0, |e| e.carry.max(0) as f32)
}
// ------------------------------------------------------------ ranger
// a snare on the dungeon's ground under the ranger's feet, now and then
fn lay_snare(&mut self, e: &mut Ent) {
if e.skill_cd > 0.0 || self.snares.len() >= MAX_SNARES {
return;
}
let i = e.tile();
let t = &self.map.t[i];
if !t.walkable() || !master_ground(t) || t.bridge() || self.snares.contains(&i) || self.traps.iter().any(|tr| tr.tile == i) {
e.skill_cd = 2.0;
return;
}
self.snares.push(i);
e.skill_cd = SNARE_EVERY;
self.fx(6, e.x, e.y, 0.0, 0.0, 0.4, 0xff3a5a7a);
}
// the shared step: a creature that steps into a snare is caught (hurt and stuck for a while)
pub(crate) fn update_snares(&mut self) {
if self.snares.is_empty() {
return;
}
let mut k = 0;
while k < self.snares.len() {
let i = self.snares[k];
if !self.map.t[i].walkable() {
self.snares.swap_remove(k);
continue;
}
let caught = self.ents.iter().position(|e| {
e.alive && !e.held && !e.hero && !e.chained && e.captive == 0 && !flies(e.kind) && Owner::MASTER_SIDES.contains(&e.side) && e.tile() == i
});
let Some(j) = caught else {
k += 1;
continue;
};
self.snares.swap_remove(k);
let e = &mut self.ents[j];
e.stun = e.stun.max(SNARE_STUN);
e.hp -= SNARE_DAMAGE;
e.bane = crate::names::BANE_SNARE;
e.flash = 0.2;
let (x, y, side, kind, dead) = (e.x, e.y, e.side, e.kind, e.hp <= 0.0);
self.fx(2, x, y, 0.0, 0.0, 0.4, 0xff4040ff);
self.sfx(Sfx::Snare, x, y);
if side == self.local_side {
self.tell_local("msg.snare_caught", vec![format!("@{}", st(kind).name)], None);
}
if dead {
self.kill(j);
}
}
}
// ------------------------------------------------------------ inquisitor
// turns the nearest weak creature in sight to the heroes' side
fn convert_creature(&mut self, e: &mut Ent) {
if e.skill_cd > 0.0 {
return;
}
e.skill_cd = 1.0;
let target = self
.ents
.iter()
.enumerate()
.filter(|(_, o)| o.alive && !o.held && !o.hero && !o.chained && o.captive == 0 && convertible(o.kind) && Owner::MASTER_SIDES.contains(&o.side))
.map(|(j, o)| (j, (o.x - e.x).powi(2) + (o.y - e.y).powi(2)))
.filter(|&(j, d2)| d2 < CONVERT_RANGE * CONVERT_RANGE && self.los(e.x, e.y, self.ents[j].x, self.ents[j].y))
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(j, _)| j);
let Some(j) = target else { return };
e.skill_cd = CONVERT_EVERY;
let goal = e.target;
let (side, kind, id, x, y) = {
let o = &self.ents[j];
(o.side, o.kind, o.id, o.x, o.y)
};
self.release_all(id);
let o = &mut self.ents[j];
o.hero = true;
o.side = 1;
o.job = Job::None;
o.path.clear();
o.home = None;
o.bed = None;
o.guard = false;
o.target = goal;
o.waypoint = None;
o.flash = 0.4;
self.shot(e.x, e.y, 0.6, x, y, 0.45, 0.35, 0xff40d0ff);
self.style_last_fx(crate::particles::ST_HOLY);
self.fx(4, x, y, 0.0, 0.0, 0.9, 0xffffeecc);
self.sfx(Sfx::Sermon, x, y);
if side == self.local_side {
self.tell_local("msg.converted", vec![format!("@{}", st(kind).name)], Some(Sfx::WaveAlarm));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// a free game with an open dungeon around the core and no waves
fn open_game() -> Game {
let mut g = Game::new(5, 96, 96);
g.gate_on = false;
let (cx, cy) = g.map.core;
for y in cy - 8..cy + 10 {
for x in cx - 8..cx + 10 {
let t = &mut g.map.t[idx(x, y)];
if t.kind != Kind::Core {
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
}
g.ents.clear();
g.recount();
g
}
fn hero(g: &mut Game, k: EK, x: f32, y: f32) -> u32 {
let id = g.spawn(k, true, x, y, 1);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.job = Job::None;
}
id
}
#[test]
fn a_thief_steals_from_the_treasury() {
let mut g = open_game();
let (cx, cy) = g.map.core;
g.build_rect(0, cx + 4, cy + 4, cx + 6, cy + 6);
g.gold = 3000.0;
g.sync_treasury();
let gold0 = g.gold;
let id = hero(&mut g, EK::Thief, cx as f32 - 5.5, cy as f32 + 5.5);
let mut carried = 0;
for _ in 0..(60 * 20) {
g.state = 0; // no creatures in the test: it would count as over
g.update(0.05);
carried = g.ents.iter().find(|e| e.id == id).map_or(carried, |e| e.carry);
if carried >= THIEF_BAG {
break;
}
}
assert!(carried > 0, "the thief took gold");
assert!(g.gold < gold0, "the master lost it: {} -> {}", gold0, g.gold);
}
#[test]
fn monks_shrug_off_spells() {
let mut g = open_game();
let (cx, cy) = g.map.core;
let monk = hero(&mut g, EK::Monk, cx as f32 + 6.5, cy as f32 + 6.5);
let knight = hero(&mut g, EK::Knight, cx as f32 + 6.5, cy as f32 + 7.0);
g.mana = 400.0;
g.lightning(cx as f32 + 6.5, cy as f32 + 6.7);
let hp = |g: &Game, id: u32| g.ents.iter().find(|e| e.id == id).map_or(0.0, |e| e.hp);
assert_eq!(hp(&g, monk), st(EK::Monk).hp, "the monk is untouched");
assert!(hp(&g, knight) < st(EK::Knight).hp, "the knight is not");
}
#[test]
fn a_rangers_snare_catches_a_creature() {
let mut g = open_game();
let (cx, cy) = g.map.core;
let r = hero(&mut g, EK::Ranger, cx as f32 + 6.5, cy as f32 + 6.5);
let mut e = g.ents.iter().find(|e| e.id == r).unwrap().clone();
e.skill_cd = 0.0;
g.lay_snare(&mut e);
let i = idx(cx + 6, cy + 6);
assert!(g.snares.contains(&i));
g.ents.retain(|e| e.id != r);
let k = g.spawn(EK::Ogre, false, cx as f32 + 6.5, cy as f32 + 6.5, 1);
g.update_snares();
let o = g.ents.iter().find(|e| e.id == k).unwrap();
assert!(o.stun > 0.0 && o.hp < st(EK::Ogre).hp, "caught");
assert!(g.snares.is_empty(), "a snare works once");
}
#[test]
fn the_inquisitor_turns_only_weak_creatures() {
let mut g = open_game();
let (cx, cy) = g.map.core;
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 5.5, cy as f32 + 6.0, 1);
let kobold = g.spawn(EK::Kobold, false, cx as f32 + 6.5, cy as f32 + 7.5, 1);
let q = hero(&mut g, EK::Inquisitor, cx as f32 + 6.5, cy as f32 + 5.5);
let mut e = g.ents.iter().find(|e| e.id == q).unwrap().clone();
e.skill_cd = 0.0;
g.convert_creature(&mut e);
let get = |g: &Game, id: u32| g.ents.iter().find(|e| e.id == id).unwrap().clone();
assert!(get(&g, kobold).hero, "the kobold now fights for the heroes");
assert!(!get(&g, ogre).hero, "an ogre is too strong to be turned");
assert_eq!(e.skill_cd, CONVERT_EVERY);
// nothing else to turn: the ogre stays
e.skill_cd = 0.0;
g.convert_creature(&mut e);
assert!(!get(&g, ogre).hero);
}
#[test]
fn rogues_join_the_later_waves() {
for (k, wave, mission) in ROGUES {
assert!(wave >= 3 && mission >= 4, "{:?} comes late", k);
}
assert!(ROGUES.iter().any(|r| r.0 == EK::Inquisitor && r.1 == 9));
}
}
+24 -18
View File
@@ -1,9 +1,9 @@
// Savegames: JSON with format tag and version, containing the game state, the camera // Savegames: JSON with format tag and version, containing the game state - nothing else. The
// and the player's settings (settings are always applied when a savegame is loaded). // player's settings live in the browser's local storage; settings in older savegames are
// ignored. Only the screenshot scenes (scenes.rs) also carry a camera.
use crate::game::Game; use crate::game::Game;
use crate::render3d::Cam; use crate::render3d::Cam;
use crate::settings::Settings;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const FORMAT: &str = "dungeoncraft-save"; pub const FORMAT: &str = "dungeoncraft-save";
@@ -15,8 +15,8 @@ struct SaveOut<'a> {
format: &'a str, format: &'a str,
version: u32, version: u32,
game: &'a Game, game: &'a Game,
camera: &'a Cam, #[serde(skip_serializing_if = "Option::is_none")]
settings: &'a Settings, camera: Option<&'a Cam>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -25,17 +25,21 @@ struct SaveIn {
version: u32, version: u32,
game: Game, game: Game,
camera: Option<Cam>, camera: Option<Cam>,
settings: Option<Settings>,
} }
pub struct Loaded { pub struct Loaded {
pub game: Game, pub game: Game,
pub camera: Option<Cam>, pub camera: Option<Cam>,
pub settings: Option<Settings>,
} }
pub fn to_json(game: &Game, camera: &Cam, settings: &Settings) -> String { pub fn to_json(game: &Game) -> String {
serde_json::to_string(&SaveOut { format: FORMAT, version: VERSION, game, camera, settings }).unwrap_or_default() serde_json::to_string(&SaveOut { format: FORMAT, version: VERSION, game, camera: None }).unwrap_or_default()
}
// a screenshot scene: the game and where the camera looks
#[allow(dead_code)] // scenes.rs (tests) only
pub fn to_json_with_camera(game: &Game, camera: &Cam) -> String {
serde_json::to_string(&SaveOut { format: FORMAT, version: VERSION, game, camera: Some(camera) }).unwrap_or_default()
} }
// Err contains a language key describing the problem // Err contains a language key describing the problem
@@ -48,9 +52,15 @@ pub fn from_bytes(data: &[u8]) -> Result<Loaded, &'static str> {
return Err("save.err_version"); return Err("save.err_version");
} }
let mut game = s.game; let mut game = s.game;
// older saves kept the dwarves on side 4, which is now the fourth master
if !game.is_master(4) && !game.rivals.iter().any(|r| r.side == 4) {
for e in game.ents.iter_mut().filter(|e| !e.hero && e.side == 4) {
e.side = crate::dwarves::DWARF_SIDE;
}
}
game.map.activate(); game.map.activate();
game.recount(); game.recount();
Ok(Loaded { game, camera: s.camera, settings: s.settings }) Ok(Loaded { game, camera: s.camera })
} }
#[cfg(test)] #[cfg(test)]
@@ -65,18 +75,14 @@ mod tests {
for _ in 0..3000 { for _ in 0..3000 {
g.update(0.05); g.update(0.05);
} }
let cam = Cam::new(10.0, 20.0); let json = to_json(&g);
let mut settings = Settings::default();
settings.language = "de".into();
let json = to_json(&g, &cam, &settings);
println!("savegame size: {} KB", json.len() / 1024); println!("savegame size: {} KB", json.len() / 1024);
let l = from_bytes(json.as_bytes()).expect("load"); let l = from_bytes(json.as_bytes()).expect("load");
assert_eq!(l.game.time, g.time); assert_eq!(l.game.time, g.time);
assert_eq!(l.game.ents.len(), g.ents.len()); assert_eq!(l.game.ents.len(), g.ents.len());
assert_eq!(l.game.gold, g.gold); assert_eq!(l.game.gold, g.gold);
assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.kind == b.kind && a.owner == b.owner && a.marked == b.marked)); assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.kind == b.kind && a.owner == b.owner && a.marked == b.marked));
assert_eq!(l.settings.unwrap().language, "de"); assert!(l.camera.is_none() && !json.contains("\"settings\"") && !json.contains("\"camera\""), "only the game state");
assert_eq!(l.camera.unwrap().tx, 10.0);
assert_eq!((l.game.map.w, l.game.map.h), (96, 128)); assert_eq!((l.game.map.w, l.game.map.h), (96, 128));
assert_eq!(l.game.map.portals.len(), g.map.portals.len()); assert_eq!(l.game.map.portals.len(), g.map.portals.len());
assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.var == b.var && a.hp == b.hp && a.food == b.food && a.gold == b.gold)); assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.var == b.var && a.hp == b.hp && a.food == b.food && a.gold == b.gold));
@@ -107,7 +113,7 @@ mod tests {
g.update(0.05); g.update(0.05);
} }
let sim = t1.elapsed(); let sim = t1.elapsed();
let json = to_json(&g, &Cam::new(0.0, 0.0), &Settings::default()); let json = to_json(&g);
println!("256x256: generate {:?}, 2 min simulated in {:?}, savegame {} KB", gen, sim, json.len() / 1024); println!("256x256: generate {:?}, 2 min simulated in {:?}, savegame {} KB", gen, sim, json.len() / 1024);
let l = from_bytes(json.as_bytes()).expect("load"); let l = from_bytes(json.as_bytes()).expect("load");
assert_eq!(l.game.map.t.len(), 256 * 256); assert_eq!(l.game.map.t.len(), 256 * 256);
@@ -132,7 +138,7 @@ mod tests {
let (ex, ey) = (cx + 1, cy + 1); let (ex, ey) = (cx + 1, cy + 1);
let mut cam = Cam::new(ex as f32 + 0.5, ey as f32 + 0.5); let mut cam = Cam::new(ex as f32 + 0.5, ey as f32 + 0.5);
cam.zoom = 12.0; cam.zoom = 12.0;
std::fs::write(path, to_json(&g, &cam, &Settings { language: "en".into(), ..Settings::default() })).unwrap(); std::fs::write(path, to_json_with_camera(&g, &cam)).unwrap();
} }
} }
+139 -1
View File
@@ -55,7 +55,7 @@ fn save(name: &str, g: &mut Game, x: f32, z: f32, zoom: f32, yaw: f32, fog: bool
let mut s = Settings::default(); let mut s = Settings::default();
s.language = "en".into(); s.language = "en".into();
s.hints = false; s.hints = false;
std::fs::write(format!("/tmp/scenes/{}.json", name), crate::save::to_json(g, &cam, &s)).unwrap(); std::fs::write(format!("/tmp/scenes/{}.json", name), crate::save::to_json_with_camera(g, &cam)).unwrap();
} }
fn run(g: &mut Game, secs: f32) { fn run(g: &mut Game, secs: f32) {
@@ -162,4 +162,142 @@ fn export_scenes() {
g.place_all_torches(); g.place_all_torches();
run(&mut g, 4.0); run(&mut g, 4.0);
save("lava-light", &mut g, cx as f32 + 5.0, cy as f32, 22.0, 0.0, false); save("lava-light", &mut g, cx as f32 + 5.0, cy as f32, 22.0, 0.0, false);
// bridges: a raised and a lowered drawbridge over water, a burning bridge, obsidian on lava
let mut g = Game::new(17, 80, 64);
let (cx, cy) = g.map.core;
g.gold = 50000.0;
open(&mut g, cx - 6, cy - 9, cx + 18, cy + 9);
for y in cy - 9..=cy + 9 {
for x in cx + 6..cx + 9 {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Water;
t.owner = Owner::None;
}
for x in cx + 12..cx + 15 {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Lava;
t.owner = Owner::None;
}
}
g.drawbridge_order(cx + 5, cy - 5, cx + 9, cy - 5);
g.drawbridge_order(cx + 5, cy - 1, cx + 9, cy - 1);
g.drawbridge_order(cx + 6, cy - 1, cx + 6, cy - 1); // raised
g.build_bridges(cx + 5, cy + 4, cx + 9, cy + 4);
g.ignite_at(cx as f32 + 7.5, cy as f32 + 4.5);
g.cool_lava_around(cx as f32 + 13.5, cy as f32 + 1.5, 2.5, 60.0);
g.place_all_torches();
run(&mut g, 2.5);
save("bridges", &mut g, cx as f32 + 9.0, cy as f32, 18.0, 0.0, false);
// wood: giant mushrooms in a cavern, an imp felling one; planned walls
let mut g = Game::new(9, 96, 96);
let m = g.map.t.iter().position(|t| t.kind == Kind::Mushroom).unwrap();
let (mx, my) = xy(m);
let (cx, cy) = g.map.core;
g.gold = 5000.0;
open(&mut g, cx - 2, cy - 2, cx + 6, cy + 4);
g.plan_walls(cx + 5, cy - 1, cx + 5, cy + 2);
run(&mut g, 2.0);
save("wood", &mut g, mx as f32 + 0.5, my as f32 + 0.5, 14.0, 0.0, false);
// above ground: the cliff with a cave mouth, grass, a forest
let mut g = Game::new(23, 128, 96);
g.add_surface(23, 0.45, 3);
g.init_fog();
let mid = (mh() / 2) as i32;
let ex = (0..mw() as i32).find(|&x| g.map.sky(idx(x, mid))).unwrap_or(60);
run(&mut g, 1.0);
save("surface", &mut g, ex as f32 + 6.0, mid as f32, 24.0, 0.0, false);
// room levels: a lair at level 3, a treasury at level 2 next to plain rooms
let mut g = Game::new(11, 80, 64);
let (cx, cy) = g.map.core;
g.gold = 50000.0;
open(&mut g, cx - 9, cy - 8, cx + 11, cy + 9);
g.build_rect(1, cx - 8, cy - 7, cx - 4, cy - 3);
g.build_rect(0, cx + 5, cy - 7, cx + 9, cy - 4);
g.build_rect(2, cx - 8, cy + 4, cx - 3, cy + 8);
g.set_room_level(1, 3);
g.set_room_level(0, 2);
g.place_all_torches();
run(&mut g, 1.0);
save("room-levels", &mut g, cx as f32 + 1.5, cy as f32 - 2.0, 16.0, 0.0, false);
}
// a mushroom grove: moss, grown (glowing) and young mushrooms
#[test]
#[ignore]
fn export_grove_scene() {
std::fs::create_dir_all("/tmp/scenes").unwrap();
let mut g = Game::new(9, 96, 96);
// the grove tile with the most grove around it
let best = (0..g.map.t.len())
.filter(|&i| g.map.t[i].kind == Kind::Grove)
.max_by_key(|&i| {
let (x, y) = xy(i);
(-3..=3).flat_map(|dy| (-3..=3).map(move |dx| (dx, dy))).filter(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |t| matches!(t.kind, Kind::Grove | Kind::Mushroom))).count()
})
.unwrap();
let (x, y) = xy(best);
run(&mut g, 1.0);
save("mushroom-grove", &mut g, x as f32 + 0.5, y as f32 + 0.5, 12.0, 0.0, false);
}
// the rogues of the later waves in a dungeon: thief, monk, ranger, inquisitor and a snare
#[test]
#[ignore]
fn export_rogues_scene() {
std::fs::create_dir_all("/tmp/scenes").unwrap();
let mut g = Game::new(11, 80, 64);
let (cx, cy) = g.map.core;
open(&mut g, cx - 9, cy - 8, cx + 11, cy + 9);
g.place_all_torches();
for (k, kind) in [EK::Thief, EK::Monk, EK::Ranger, EK::Inquisitor].into_iter().enumerate() {
let id = g.spawn(kind, true, cx as f32 + 4.5 + k as f32 * 1.2, cy as f32 + 5.5, 3);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.guard = true;
e.home = Some((e.x, e.y));
}
}
g.snares.push(idx(cx + 5, cy + 3));
g.snares.push(idx(cx + 7, cy + 3));
save("rogues", &mut g, cx as f32 + 6.5, cy as f32 + 4.5, 8.0, 0.0, false);
}
// weather above ground: rain and fog over the land
#[test]
#[ignore]
fn export_weather_scenes() {
std::fs::create_dir_all("/tmp/scenes").unwrap();
for (name, kind) in [("weather-rain", crate::weather::RAIN), ("weather-fog", crate::weather::FOG), ("weather-storm", crate::weather::STORM)] {
let mut g = Game::new(7, 96, 96);
g.add_surface(7, 0.5, 3);
g.weather = crate::weather::Weather { kind, shown: kind, level: 1.0, left: 900.0, ..Default::default() };
// the camera on the land above ground, near the edge of the dungeon side
let i = (0..g.map.t.len()).filter(|&i| g.map.sky(i)).min_by_key(|&i| {
let (x, y) = xy(i);
(x - 52).abs() + (y - 48).abs()
}).unwrap();
let (x, y) = xy(i);
save(name, &mut g, x as f32 + 4.5, y as f32 + 0.5, 18.0, 0.0, false);
}
}
// named creatures and memorial stones of the fallen
#[test]
#[ignore]
fn export_names_scene() {
std::fs::create_dir_all("/tmp/scenes").unwrap();
let mut g = Game::new(11, 80, 64);
let (cx, cy) = g.map.core;
open(&mut g, cx - 9, cy - 8, cx + 11, cy + 9);
g.place_all_torches();
creatures(&mut g, &[(EK::Ogre, 9), (EK::Mage, 5), (EK::Kobold, 2)], cx as f32 + 6.5, cy as f32 + 5.0);
for (k, &(kind, lv)) in [(EK::Spider, 4), (EK::Ogre, 8), (EK::Mage, 6)].iter().enumerate() {
let id = g.spawn(kind, false, cx as f32 + 2.5 + k as f32 * 2.0, cy as f32 + 6.5, lv);
let j = g.ents.iter().position(|e| e.id == id).unwrap();
g.kill(j);
}
save("names", &mut g, cx as f32 + 5.0, cy as f32 + 5.5, 9.0, 0.0, false);
} }
+12 -2
View File
@@ -137,6 +137,7 @@ pub struct Settings {
pub music_volume: f32, pub music_volume: f32,
pub muted: bool, pub muted: bool,
pub voice: bool, // Crookfinger's babbling voice pub voice: bool, // Crookfinger's babbling voice
pub music_track: String, // music player: "" = automatic, else the id of a calm track that replaces the calm music
// camera // camera
pub rotate_speed: f32, pub rotate_speed: f32,
pub pan_speed: f32, pub pan_speed: f32,
@@ -147,8 +148,9 @@ pub struct Settings {
pub outlines: bool, pub outlines: bool,
pub wall_detail: u8, // 1 low, 2 medium, 3 high pub wall_detail: u8, // 1 low, 2 medium, 3 high
pub max_lights: u8, // 8, 16 or 24 pub max_lights: u8, // 8, 16 or 24
pub ui_factor: u8, // interface size: 0 = automatic, 1..4 = fixed integer factor pub ui_factor: u8, // interface size: 0 = automatic, 2 large, 3 medium, 4 small (interface pixels per world pixel)
pub render_scale: u8, // 3D world resolution divisor: 1 = full, 2..16 = pixel look pub render_scale: u8, // old pixel-look divisor (no longer used, kept for old settings files)
pub pixel_rows: u16, // pixel look: rows of the world on every screen (270, 360, 480)
pub touch_ui: u8, // touch interface: 0 = automatic (after the first touch), 1 = on, 2 = off pub touch_ui: u8, // touch interface: 0 = automatic (after the first touch), 1 = on, 2 = off
pub debug_fps: bool, // show FPS and rendering backend pub debug_fps: bool, // show FPS and rendering backend
pub debug_frame_time: bool, // show the frame-time line and graph pub debug_frame_time: bool, // show the frame-time line and graph
@@ -161,7 +163,10 @@ pub struct Settings {
pub hints: bool, pub hints: bool,
pub difficulty: u8, // rival masters in free games: 0 easy, 1 medium, 2 hard pub difficulty: u8, // rival masters in free games: 0 easy, 1 medium, 2 hard
pub map_w: usize, // size of new maps (64..256) pub map_w: usize, // size of new maps (64..256)
pub match_size: usize, // versus matches: the map is match_size x match_size (see lobby::seats_for)
pub map_h: usize, pub map_h: usize,
pub surface: bool, // new free games: the eastern half lies above ground (forest)
pub events: bool, // random events in free games and versus matches
// key bindings: action id -> key name // key bindings: action id -> key name
pub keys: BTreeMap<String, String>, pub keys: BTreeMap<String, String>,
} }
@@ -174,6 +179,7 @@ impl Default for Settings {
music_volume: 0.6, music_volume: 0.6,
muted: false, muted: false,
voice: true, voice: true,
music_track: String::new(),
rotate_speed: 1.0, rotate_speed: 1.0,
pan_speed: 1.25, pan_speed: 1.25,
zoom_speed: 1.0, zoom_speed: 1.0,
@@ -184,6 +190,7 @@ impl Default for Settings {
max_lights: 24, max_lights: 24,
ui_factor: 0, ui_factor: 0,
render_scale: 1, render_scale: 1,
pixel_rows: 360,
touch_ui: 0, touch_ui: 0,
debug_fps: false, debug_fps: false,
debug_frame_time: false, debug_frame_time: false,
@@ -195,7 +202,10 @@ impl Default for Settings {
hints: true, hints: true,
difficulty: 1, difficulty: 1,
map_w: 64, map_w: 64,
match_size: 128,
map_h: 64, map_h: 64,
surface: false,
events: true,
keys: BTreeMap::new(), keys: BTreeMap::new(),
} }
} }
+43
View File
@@ -164,6 +164,31 @@ fn def(s: Sfx, r: f32) -> Vec<f32> {
Sfx::Voice4 => l(&[seg(Square(0.15), 900.0, 980.0, 0.05, 0.13, 10.0), vib(seg(Square(0.2), 700.0, 560.0, 0.06, 0.14, 8.0), 0.04)], 65), Sfx::Voice4 => l(&[seg(Square(0.15), 900.0, 980.0, 0.05, 0.13, 10.0), vib(seg(Square(0.2), 700.0, 560.0, 0.06, 0.14, 8.0), 0.04)], 65),
Sfx::Voice5 => mix(vec![l(&[vib(seg(Square(0.25), 470.0, 560.0, 0.1, 0.15, 5.0), 0.06)], 66), l(&[seg(Noise, 3000.0, 2000.0, 0.025, 0.06, 30.0)], 67)]), Sfx::Voice5 => mix(vec![l(&[vib(seg(Square(0.25), 470.0, 560.0, 0.1, 0.15, 5.0), 0.06)], 66), l(&[seg(Noise, 3000.0, 2000.0, 0.025, 0.06, 30.0)], 67)]),
Sfx::Voice6 => l(&[vib(seg(Square(0.2), 680.0, 820.0, 0.06, 0.15, 9.0), 0.05), seg(Square(0.2), 820.0, 700.0, 0.04, 0.12, 12.0)], 68), Sfx::Voice6 => l(&[vib(seg(Square(0.2), 680.0, 820.0, 0.06, 0.15, 9.0), 0.05), seg(Square(0.2), 820.0, 700.0, 0.04, 0.12, 12.0)], 68),
// a long rumble rolling away after the crack
Sfx::Thunder => mix(vec![
l(&[seg(Noise, 420.0, 70.0, 2.4, 0.55, 1.1)], 70),
l(&[vib(seg(Tri, 55.0, 32.0, 2.2, 0.5, 1.2), 0.08)], 71),
l(&[seg(Noise, 160.0, 160.0, 0.35, 0.0, 0.0), seg(Noise, 160.0, 90.0, 1.4, 0.3, 1.6)], 72),
]),
// the thief's fingers in the coins: a quick, soft jingle
Sfx::Steal => mix(vec![l(&arp(Square(0.25), &[2093.0, 1760.0, 2349.0], 0.04, 0.1), 73), l(&[seg(Noise, 7000.0, 7000.0, 0.03, 0.08, 60.0)], 74)]),
// iron jaws snap shut
Sfx::Snare => mix(vec![l(&[seg(Noise, 8000.0, 3000.0, 0.04, 0.5, 50.0)], 75), l(&[seg(Square(0.5), 1400.0, 300.0, 0.09, 0.22, 25.0)], 76)]),
// the inquisitor's sermon: a short, hollow chant
Sfx::Sermon => mix(vec![
l(&[vib(seg(Sine, 330.0, 330.0, 0.7, 0.28, 2.0), 0.02)], 77),
l(&[vib(seg(Sine, 392.0, 415.0, 0.7, 0.2, 2.0), 0.02)], 78),
l(&[vib(seg(Sine, 494.0, 494.0, 0.7, 0.16, 2.0), 0.03)], 79),
]),
// rain on the land: fades in and out, played over and over while it rains
Sfx::Rain => mix(vec![
l(&[seg(Noise, 7000.0, 7000.0, 0.3, 0.04, -6.0), seg(Noise, 7000.0, 7000.0, 0.9, 0.24, 0.0), seg(Noise, 7000.0, 7000.0, 0.3, 0.24, 6.0)], 80),
l(&[seg(Noise, 2500.0, 2500.0, 0.3, 0.02, -6.0), seg(Noise, 2500.0, 2500.0, 0.9, 0.12, 0.0), seg(Noise, 2500.0, 2500.0, 0.3, 0.12, 6.0)], 81),
]),
// wind over the land: a slow, howling sweep
Sfx::Wind => l(&[seg(Noise, 300.0, 700.0, 0.6, 0.03, -3.0), seg(Noise, 700.0, 500.0, 1.4, 0.18, 0.2), seg(Noise, 500.0, 250.0, 0.6, 0.15, 3.0)], 82),
// the spores of a grove: a faint chime
Sfx::Spores => l(&arp(Sine, &[2093.0, 2637.0, 3136.0, 2637.0], 0.09, 0.07), 83),
} }
} }
@@ -171,3 +196,21 @@ fn def(s: Sfx, r: f32) -> Vec<f32> {
pub fn generate_all(rate: f32) -> Vec<Vec<f32>> { pub fn generate_all(rate: f32) -> Vec<Vec<f32>> {
SFX_ALL.iter().map(|&s| def(s, rate)).collect() SFX_ALL.iter().map(|&s| def(s, rate)).collect()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_effect_is_audible_and_does_not_clip() {
let all = generate_all(22050.0);
for (s, buf) in SFX_ALL.iter().zip(all.iter()) {
let peak = buf.iter().fold(0.0f32, |m, v| m.max(v.abs()));
assert!(!buf.is_empty() && peak > 0.02, "{:?} is silent", s);
assert!(peak < 1.5, "{:?} is too loud: {}", s, peak);
}
// the rain loops: it is long enough to overlap when played every 1.25 s
let rain = &all[Sfx::Rain.index()];
assert!(rain.len() as f32 / 22050.0 > 1.3);
}
}
+165
View File
@@ -0,0 +1,165 @@
// Siege engines: battering rams smash doors, walls and towers; catapults hurl stones at towers
// from afar. The knights bring them along in later waves; a master with a forge builds his own
// (gold and wood) to break a fortress: they follow the attack flag and go for the gate and the
// knights' towers.
use crate::game::*;
use crate::map::*;
// cost of a ram and a catapult: gold, wood
pub const RAM_COST: (f32, f32) = (250.0, 15.0);
pub const CATAPULT_COST: (f32, f32) = (350.0, 25.0);
pub fn siege(k: EK) -> bool {
matches!(k, EK::Ram | EK::Catapult)
}
// how much harder a unit hits towers
pub fn tower_breaker(k: EK) -> f32 {
match k {
EK::Catapult => 2.5,
EK::Ram => 2.0,
_ => 1.0,
}
}
impl Game {
// the siege tool: a ram (0) or a catapult (1) rolls out on own ground (a forge is needed)
pub fn build_siege(&mut self, which: u8, x: i32, y: i32) {
if !inb(x, y) || !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != self.me {
self.hint(34, "hint.siege_where");
self.sfx_ui(Sfx::Error);
return;
}
if self.forge_tiles() == 0 {
self.hint(26, "hint.need_forge");
self.sfx_ui(Sfx::Error);
return;
}
let (kind, (gold, wood)) = if which == 0 { (EK::Ram, RAM_COST) } else { (EK::Catapult, CATAPULT_COST) };
if self.gold < gold {
self.msg("msg.no_gold", vec![]);
self.sfx_ui(Sfx::Error);
return;
}
if !self.spend_wood(wood) {
self.msg("msg.no_wood", vec![]);
self.sfx_ui(Sfx::Error);
return;
}
self.gold -= gold;
self.count(crate::stats::SIEGE, 1.0);
let id = self.spawn(kind, false, x as f32 + 0.5, y as f32 + 0.5, 1);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.side = self.me_side;
e.home = Some((x as f32 + 0.5, y as f32 + 0.5));
e.settled = true;
e.happy = 1.0;
}
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff40d0ff);
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
self.msg("msg.siege_built", vec![format!("@{}", st(kind).name)]);
}
// a master's own siege engine: no needs; it goes where the attack flag is and breaks the
// gate and the knights' towers there (a catapult shoots any knight tower in reach)
pub(crate) fn siege_ai(&mut self, e: &mut Ent, dt: f32) {
let s = st(e.kind);
// a knight tower in reach (the catapult from afar, the ram next to it)
if e.think <= 0.0 {
e.think = 0.5;
let reach = if e.kind == EK::Catapult { s.range + 0.5 } else { 2.5 };
let near = self
.towers
.iter()
.filter(|t| t.hero)
.map(|t| (t.id, (t.pos().0 - e.x).powi(2) + (t.pos().1 - e.y).powi(2)))
.filter(|&(_, d2)| d2 < reach * reach)
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|t| t.0);
if let Some(id) = near {
e.job = Job::AttackTower(id);
e.path.clear();
}
}
if let Job::AttackTower(id) = e.job {
if self.attack_tower(e, id, dt) {
return;
}
e.job = Job::None;
}
let Some(f) = self.flag else {
// no flag: wait at home
self.step_path(e, dt);
return;
};
let (fx, fy) = xy(f);
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
// the gate of the order near the flag
let (gx, gy) = self.gate_center();
if self.gate_hp > 0.0 && self.has_gate() && (fx - gx).powi(2) + (fy - gy).powi(2) <= 64.0 {
let dg = ((e.x - gx).powi(2) + (e.y - gy).powi(2)).sqrt();
if dg <= s.range + 1.6 && (s.range < 1.5 || self.los(e.x, e.y, gx, gy)) {
e.path.clear();
e.face = if gx > e.x { 1.0 } else { -1.0 };
e.working = true;
if e.cd <= 0.0 {
self.hit_gate(e);
e.cd = s.interval;
}
return;
}
}
let d2 = (e.x - fx).powi(2) + (e.y - fy).powi(2);
if d2 > 2.0 * 2.0 && (e.path.is_empty() || e.repath <= 0.0) {
e.repath = 1.5;
if let Some(p) = self.map.path_to(e.tile(), f) {
e.path = p;
}
}
self.step_path(e, dt);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_master_builds_a_catapult_that_shoots_knight_towers() {
let mut g = Game::new(6, 80, 64);
let (cx, cy) = g.map.core;
g.gold = 5000.0;
g.wood = 100.0;
// own ground with a forge
for y in cy - 6..=cy + 8 {
for x in cx - 2..=cx + 14 {
let t = &mut g.map.t[idx(x, y)];
if !matches!(t.kind, Kind::Core) {
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
}
g.build_rect(8, cx + 1, cy + 5, cx + 4, cy + 7);
g.build_siege(1, cx + 6, cy + 2);
let cat = g.ents.iter().position(|e| e.kind == EK::Catapult).expect("a catapult");
assert_eq!(g.ents[cat].side, 0);
assert!(g.gold < 5000.0 && g.wood < 100.0);
// a knight tower in reach
let i = idx(cx + 12, cy + 2);
g.map.t[i].kind = Kind::Tower;
g.map.t[i].owner = Owner::Hero;
let id = g.next_id;
g.next_id += 1;
let mut tw = Tower { id, kind: TowerKind::Arrow, tile: i, level: 1, hp: 1.0, cd: 9.0, aim: 0.0, flash: 0.0, hero: true, rune_final: false };
tw.hp = tw.max_hp();
let hp0 = tw.hp;
g.towers.push(tw);
for _ in 0..(15 * 20) {
g.update(0.05);
}
let hp = g.towers.iter().find(|t| t.id == id).map_or(0.0, |t| t.hp);
assert!(hp < hp0, "the catapult hit the tower");
}
}
+42 -1
View File
@@ -8,11 +8,52 @@ use macroquad::prelude::*;
pub struct SoundBank { pub struct SoundBank {
last: Vec<f64>, last: Vec<f64>,
next_rain: f64,
next_wind: f64,
next_spores: f64,
} }
impl SoundBank { impl SoundBank {
pub fn new() -> SoundBank { pub fn new() -> SoundBank {
SoundBank { last: vec![-1.0; SFX_ALL.len()] } SoundBank { last: vec![-1.0; SFX_ALL.len()], next_rain: 0.0, next_wind: 0.0, next_spores: 0.0 }
}
// background sounds where the camera looks: rain and wind over the land above ground (the
// samples fade in and out and overlap a little), the chime of a mushroom grove now and then
pub fn ambience(&mut self, g: &crate::game::Game, (cx, cz): (f32, f32), now: f64) {
use crate::map::{idx, inb, Kind};
let near = |r: i32, f: &dyn Fn(usize) -> bool| {
let (x, z) = (cx as i32, cz as i32);
let mut n = 0;
for dz in (-r..=r).step_by(2) {
for dx in (-r..=r).step_by(2) {
if inb(x + dx, z + dz) && f(idx(x + dx, z + dz)) {
n += 1;
}
}
}
n
};
if g.state != 0 {
return;
}
let outside = !g.map.sky.is_empty() && near(6, &|i| g.map.sky(i)) >= 6;
let w = &g.weather;
if outside && w.rain() > 0.05 && now >= self.next_rain {
self.next_rain = now + 1.25;
audio::play_sfx(Sfx::Rain.index(), 0.55 * w.rain(), 0.0);
}
let windy = if w.shown == crate::weather::STORM { w.level } else { w.fog() * 0.5 };
if outside && windy > 0.05 && now >= self.next_wind {
self.next_wind = now + 2.2;
audio::play_sfx(Sfx::Wind.index(), 0.45 * windy, (now.sin() * 0.4) as f32);
}
if now >= self.next_spores {
self.next_spores = now + 3.0 + (now * 7.3).sin().abs() * 3.0;
if near(5, &|i| matches!(g.map.t[i].kind, Kind::Grove)) >= 3 {
audio::play_sfx(Sfx::Spores.index(), 0.35, ((now * 1.7).sin() * 0.5) as f32);
}
}
} }
// minimum gap between two identical sounds (avoids a wall of noise) // minimum gap between two identical sounds (avoids a wall of noise)
+33 -3
View File
@@ -16,9 +16,18 @@ pub enum Spell {
Invisible, Invisible,
Plague, Plague,
Quake, Quake,
Freeze, // frost: cools lava into obsidian, chills enemies
} }
pub const SPELLS: [Spell; 8] = [Spell::Eye, Spell::CaveIn, Spell::Haste, Spell::Stoneskin, Spell::GoldRain, Spell::Invisible, Spell::Plague, Spell::Quake]; pub const SPELLS: [Spell; 9] = [Spell::Eye, Spell::CaveIn, Spell::Haste, Spell::Stoneskin, Spell::GoldRain, Spell::Invisible, Spell::Plague, Spell::Quake, Spell::Freeze];
// the icon of a spell (the first eight share one row of the atlas)
pub fn spell_icon(sp: Spell) -> u32 {
match SPELLS.iter().position(|&t| t == sp).unwrap_or(0) {
k if k < 8 => crate::gfx::I_SPELLS + k as u32,
_ => crate::gfx::I_FREEZE,
}
}
pub const EYE_TIME: f32 = 20.0; pub const EYE_TIME: f32 = 20.0;
pub const EYE_RADIUS: f32 = 8.0; pub const EYE_RADIUS: f32 = 8.0;
@@ -34,6 +43,7 @@ impl Spell {
Spell::Invisible => 50.0, Spell::Invisible => 50.0,
Spell::Plague => 80.0, Spell::Plague => 80.0,
Spell::Quake => 90.0, Spell::Quake => 90.0,
Spell::Freeze => 80.0,
} }
} }
// radius shown under the pointer // radius shown under the pointer
@@ -43,6 +53,7 @@ impl Spell {
Spell::CaveIn => 1.5, Spell::CaveIn => 1.5,
Spell::Plague => 2.0, Spell::Plague => 2.0,
Spell::Quake => 3.0, Spell::Quake => 3.0,
Spell::Freeze => 2.5,
Spell::GoldRain => 0.0, Spell::GoldRain => 0.0,
_ => 1.6, _ => 1.6,
} }
@@ -57,12 +68,13 @@ impl Spell {
Spell::Invisible => "invisible", Spell::Invisible => "invisible",
Spell::Plague => "plague", Spell::Plague => "plague",
Spell::Quake => "quake", Spell::Quake => "quake",
Spell::Freeze => "freeze",
} }
} }
} }
fn foe(e: &Ent) -> bool { fn foe(e: &Ent) -> bool {
e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != 0 e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != 0 && !crate::rogues::spell_proof(e)
} }
fn own(e: &Ent) -> bool { fn own(e: &Ent) -> bool {
@@ -80,7 +92,7 @@ impl Game {
} }
pub fn cast(&mut self, spell: Spell, wx: f32, wy: f32) { pub fn cast(&mut self, spell: Spell, wx: f32, wy: f32) {
let cost = spell.cost(); let cost = spell.cost() * self.talents.spell_cost();
if self.mana < cost { if self.mana < cost {
self.msg("msg.no_mana_cost", vec![(cost as i32).to_string()]); self.msg("msg.no_mana_cost", vec![(cost as i32).to_string()]);
self.sfx_ui(Sfx::Error); self.sfx_ui(Sfx::Error);
@@ -232,8 +244,26 @@ impl Game {
} }
self.sfx(Sfx::Cannon, wx, wy); self.sfx(Sfx::Cannon, wx, wy);
} }
Spell::Freeze => {
// lava turns to obsidian for a while; enemies are chilled and slowed
self.cool_lava_around(wx, wy, 2.5, crate::bridges::FREEZE_TIME);
let hit: Vec<usize> = (0..self.ents.len()).filter(|&j| foe(&self.ents[j]) && near(&self.ents[j])).collect();
for j in hit {
let e = &mut self.ents[j];
e.hp -= 20.0;
e.slow = e.slow.max(4.0);
e.flash = 0.15;
if e.hp <= 0.0 {
self.kill(j);
}
}
self.part(RING, (wx, wy, 0.05), (0.0, 0.0, 0.0), 0.6, abgr(170, 230, 255), 2.5);
self.sparkle(wx, wy, 24, abgr(200, 240, 255));
self.sfx(Sfx::Magic, wx, wy);
}
} }
self.mana -= cost; self.mana -= cost;
self.count(crate::stats::SPELLS, 1.0);
} }
// running spell effects on a unit (called every frame) // running spell effects on a unit (called every frame)
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more