Zemeroth v0.5: ggez, WASM, itch.io, visuals, AI, campaign, tests
2019-05-13Hi, folks! I'm happy to announce Zemeroth v0.5. Main features of this release are: migration to ggez, web version, itch.io page, campaign mode, AI improvements, visual updates, and tests.
Zemeroth is a turn-based hexagonal tactical game written in Rust. You can download precompiled v0.5 binaries for Windows, Linux, and macOS. Also, now you can play an online version (read more about it in the "WebAssembly version" section below).
The last release happened about a year ago. Since then the development mostly happened in irregular bursts, sometimes it even was completely stalled for weeks. But a year is a big period of time anyway, so there're still lots of changes.
Lots of text ahead, feel free to skip sections that you're not interested in particularly. Here's a table of contents:
- Migration to the
ggez
Game Engine - WebAssembly Version
- itch.io
- Visual Improvements
- Campaign Mode
- Hit Chances
- Armor
- AI Updates
- Bombs and Commutative Effects
- Other Game Rules Changes
- Gameplay Video
- SVG Atlas
- Assets Hash
- Tests
- Other Technical Changes
- Indikator
- Migrated This Devlog to Zola
- Roadmap
Migration to the ggez
Game Engine
An experiment with maintaining my own engine (even a simple and minimalistic 2D one) turned out to be too exhausting in practice: you have to fight a constant stream of reports about small corner case issues and deal with platform-specific tweaks and hacks (stuff like this, for example). It can consume surprisingly large amounts of time. But what's more important for a hobby project, it also sucks too much fun out of the development process.
And what made it worse in my case is that Häte2d intentionally wasn't a general-purpose game engine (to reduce the scope of work), so it was sad to know that all this work won't be reused by anyone. But converting Häte into a real general-purpose engine wasn't an option either, because it wouldn't have left any time for Zemeroth's development.
So I've surrendered and decided to give away some control over low-level parts of Zemeroth: Häte2d was discontinued and replaced by ggez, the most mature and actively developed Rust 2d game engine at that time.
häte
had some built-in basic
scene management
and GUI systems,
but ggez is minimalistic by design and has none of this.
So, two helper crates were extracted from Häte2d and rebuilt on top of ggez:
- ggwp-zscene
is a simple scene/declarative animation manager that provides:
- Sprites with shared data;
- Scene and Actions to manipulate sprites;
- Simple layers;
- ggwp-zgui
is a tiny and opinionated UI library:
- Provides only simple labels, buttons and layouts;
- Handles only basic click event;
- No custom styles, only the basic one.
Since Icefoxen asked not to use ggez-
prefix,
I used ggwp-
("good game, well played!") to denote that the crate
belongs to ggez's ecosystem, but is not official.
These helper crates are still tied to Zemeroth, not sure how helpful these libraries can be for a project that is not Zemeroth-like. But maybe someone will manage to extract some benefit from them.
These crates are still immature and aren't published on crates.io yet,
while the rancor
component library was renamed to zcomponents
and
is published.
Initially, I migrated to ggez v0.4 that was SDL2-based. But as soon as the first release candidate of winit-based ggez v0.5 became available I attempted to migrate to it. I've filed a bunch of mostly text-related issues in the process and tried to fix the most critical ones for Zemeroth: "Remove the generic argument from Drawable::draw", "Drawable::dimensions()" (big one!) and "Fix Text::dimensions height". These PRs took some time, but then I relatively easy ported Zemeroth to ggez v0.5.0-rc.0.
ggez v0.5 isn't released yet, so at the moment
Zemeroth uses ggez 0.5.0-rc.1
. It's stable enough for me.
Before the migration,
I was using cgmath, because it's a simple and straightforward library.
But ggez's "native" math library is nalgebra and
even though ggez v0.5 uses mint
types for all its public API,
I still migrated to nalgebra, because
of this.
One downside of the migration to ggez is that debug builds are much slower now because more code is pure Rust. Something like 3-5 FPS on my notebook. But it's ok, I don't need debug builds often, I prefer debugging through logs anyway. And when I really need a debug build to track down something extremely strange, I can use cargo's yet unstable feature "profile-overrides".
cargo-features = ["profile-overrides"] [profile.dev.overrides."*"] opt-level = 2
Another serious downside of the engine switch, though temporary (?), is that there's no native Android version of the game for now. But who really needs a native port when you have...
WebAssembly Version
After ggez v0.5-rc.0 was published, Icefoxen has posted "The State Of GGEZ 2019", where among other things he wrote that a web port is unlikely to happen soon because a lot of issues in dependencies need to be fixed first. It could be relatively easy to write a specialized web backend for ggez, but ggez's philosophy is against having multiple backends.
And that's where Fedor @not-fl3 suddenly comes in with his good-web-game WASM/WebGL game engine. He had been experimenting with 2d web prototypes (like this one) for some time and used a custom 2d web engine for this. The API of this engine was heavily inspired by ggez so he managed to write a partly ggez-compatible wrapper in a weekend.
Colors are slightly off and text rendering is a little bit different, but otherwise, it works nicely and smoothly, providing the same experience:
Zemeroth uses good-web-game for its web version as a quick-n-dirty immediate solution until a proper WASM support arrives to GGEZ (there're no plans of making good-web-game some kind of official GGEZ backend or anything like this). The currently implemented subset of ggez's API is quite limited and while it may be used for other games, it will probably require a lot of work to do.
You can't use crate renaming in Cargo.toml
to reuse a name on different platforms,
# Cargo.toml with these dependencies wouldn't build: [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ggez = "0.5.0-rc.1" [target.'cfg(target_arch = "wasm32")'.dependencies] ggez = { git = "https://github.com/not-fl3/good-web-game", package = "good-web-game" }
So the crate substitution hack is done in main.rs
using extern crate
items in main.rs
:
#[cfg(not(target_arch = "wasm32"))] extern crate ggez; #[cfg(target_arch = "wasm32")] extern crate good_web_game as ggez;
99.9% of code stays the same, but I had to use a separate main, because good-web-game has a different initialization API:
#[cfg(target_arch = "wasm32")] fn main() -> GameResult { ggez::start( conf::Conf { cache: conf::Cache::Index, loading: conf::Loading::Embedded, ..Default::default() }, |mut context| { let state = MainState::new(&mut context).unwrap(); event::run(context, state) }, ) }
Finally, a short helper script utils/wasm/build.sh
was added:
#!/bin/sh cp -r assets static cp utils/wasm/index.html static ls static > static/index.txt cargo web build
- cargo-web only packs a
static
directory (it's hardcoded), so the script copies the game's assets there; - the
index.html
template page is also copied there; - all assets should be listed in
index.txt
for good-web-game to be able to load them, so this file is created;
You can find a minimal example of good-web-game here.
itch.io
The web version needs to be hosted somewhere. itch.io is a nice place for this:
it has a nice and simple UI (for both developers and consumers), it's extremely easy to upload a web game there and it's a relatively known store for indie games that can provide some exposure by itself.
(Zone of Control also got an itch.io page)
Note an "Enter fullscreen" button in the bottom right corner of the game area:
As I've said in the ggez section above, the web version of the game seems to work fine on most mobile devices:
With a playable version only a click away I received a lot of fresh feedback: a lot of people that previously were only following the development now actually tried to play the game.
The most important things people want to see improved are:
- Improve the GUI: Replace text buttons with icons, show some tooltips, etc;
- Add a tutorial or at least a short guide;
- Randomness is too frustrating: missed attacks should result in some little positive effect, like pushing enemies back or reducing their stamina;
- The game lacks ranged attack units, like archers or knife throwers.
@Yururu even wrote a giant comment on the itch page! It's inspiring when a stranger from the internet breaks through the crude primitive interface, figures out game mechanics on a quite deep level, and writes a detailed review of their experience and thoughts.
I've created an itch.io list of Rust games. When I find a Rust game on itch.io I add it there.
Also, I've sent a request to itch.io folks to add Rust as an instrument, so now a more official list is available: itch.io/games/made-with-rust (you can edit a game's instruments here: "edit game" -> "metadata" -> "engines & tools"). Looks like my original list will be deprecated with time but it's still useful for now because only authors of the games can add an instrument to the metadata.
Visual Improvements
The initial draft of the new sprites looked like this:
Tiles are flattened now. It's less a schematic top-down view as it was before. "Camera" is moved to the side so the tiles and agents are shown using the same projection.
There're many gradients in the mockup image above. Later I decided to get rid of all the gradients and curvy lines and stick with "pseudo lowpoly" style.
Floating Eye and Insecto-snake agent types from the mockup haven't made it to the master yet.
All objects now have a shadow. It makes the image a little bit more tangible. Walk and especially throw animations feel better now.
Initially, shadow was an ellipse with gradient. Later it was replaced by two semi-transparent hexagons for style consistency.
Added blood splatters and weapon flashes to make attacks more dramatic:
The number of drops depends on the attack's damage. Blood slowly disappears into transparency in three turns, otherwise, the battlefield would become a complete and unreadable mess.
Every agent now has WeaponType
: "smash", "slash", "pierce", and "claw".
For now, they are just visual information.
They affect only what sprite is used during the attack animation.
Same as agent sprites, weapon flash sprites are not yet mirrored horizontally. That is most noticeable with curvy smash sprite.
Also, spearman's "pierce" weapon sprite is horizontal and it looks weird during vertical attacks. Either multiple sprites are needed or it should be rotated.
Added a Dust effect (for jumps and throws):
It is created by a simple function that just emits a bunch of half-transparent sprites and attaches position and color change actions to them. Sprites' size, velocity, and transparency are a little bit randomized.
Campaign Mode
A basic campaign mode was added. It's just a linear sequence of battles with predefined scenarios. After each battle, your survived fighters are carried over to the next battle. If you lose a battle - the campaign is over for you. If you win a battle, you're shown a transition screen with a list of your dead fighters, your current squad, and possible recruits:
The campaign is defined by a RON config file with this structure:
initial_agents: ["swordsman", "alchemist"],
nodes: [
(
scenario: (
map_radius: (4),
rocky_tiles_count: 8,
objects: [
(owner: Some((1)), typename: "imp", line: Front, count: 3),
(owner: Some((1)), typename: "imp_bomber", line: Middle, count: 2),
],
),
award: (
recruits: ["hammerman", "alchemist"],
),
),
(
scenario: (
rocky_tiles_count: 10,
objects: [
(owner: None, typename: "boulder", line: Any, count: 3),
(owner: None, typename: "spike_trap", line: Any, count: 3),
(owner: Some((1)), typename: "imp", line: Front, count: 4),
(owner: Some((1)), typename: "imp_toxic", line: Middle, count: 2),
(owner: Some((1)), typename: "imp_bomber", line: Back, count: 1),
(owner: Some((1)), typename: "imp_summoner", line: Back, count: 2),
],
),
award: (
recruits: ["swordsman", "spearman", "hammerman"],
),
),
]
Here's some real campaign scenario: campaign_01.ron
There's a known bug that you can exit from a battle that is not going well at any moment to start again. This will be forbidden - permadeath is the only way :) .
Hit Chances
In the previous version of Zemeroth the hit chance was always 50%.
Now, attack_accuracy
and dodge
stats were added to the Agent
component
to implement a basic hit chances math.
When you select an agent that can attack (has an attack point and enemies in range) a hit chance is shown over all available targets:
During the attack animation, a hit chance is shown near the attacker with a smaller font.
This was added in order for the player to see how dangerous enemy attacks are.
Also, wounded agents now become less accurate. Each lost strength point results in -10% hit chance penalty (up to -30%).
Missing strength points (wounds) are shown by almost transparent green dots:
This gameplay change has two game balance consequences:
- Now it's more important to wound enemies, finishing them off is a lower priority most of the time. Sometimes wounded enemies even can be helpful to the player, because they are not a real threat to player's fighters, but can block the path for other enemies;
- Alchemist's "heal" ability became important because your agents are less useful when wounded too.
Also, attacks with strength > 1 have additional hit chances - with reduced damage (each attack strength point gives 10% hit chance improvement). This emulates the situation when an attacker barely touches their target but still manages to make some damage to it.
Armor
A basic armor system was implemented.
Armor points are shown above the agent in one line with strength points
using the yellow dots.
Each armor point deflects one damage point on each attack.
Some weapons can break armor (the attack_break
parameter).
Fire and poison ignore armor.
Here's a little demo:
- an imp can't break armor so he can't deal any damage to the heavy swordsman;
- toxic imp can't deal any direct damage but he poisons the swordsman ignoring the armor;
- insecto-snake destroys the armor with a powerful attack.
In the current version of the game only imp summoners have armor, so be careful with them.
AI Updates
-
Now, enemies always act in order of remoteness from a player's fighters. This way melee imps don't trip over each other too much.
-
AI learned to avoid walking into spikes, fire and poison clouds.
-
AI now moves closer to its targets even if there's no direct path to them (full demo video):
-
Non-melee imps (bombers and summoners) are now trying to keep distance from the player's fighters in range. They need to avoid melee fights but still be able to throw bombs at a player's fighters or summon new imps near the frontline.
Summoner has a slightly greater min/max range than bomber.
During the debugging of the above-mentioned features,
I also wrote a simple helper function dump_map
that takes a closure and dumps required map data as a char
in the above GIF, pic 1 shows objects (A
- an agent, O
- a non-agent object)
and pic 2 shows available positions (X
).
Bombs and Commutative Effects
^ In the previous version of Zemeroth, each of these bombs would have exploded at the beginning of the next turn in order of their creation. But this order is hard to remember and it's not clear from the picture at all.
The order is very important as the explosions push back objects - if the first explosion is on the left, an agent will be damaged by the right bomb too.
Pushback is not the only possible effect suffering from this. Other possible examples of non-commutative effects: teleportation, armor-breaking acid, immunity to damage, etc. Anything where changing the order of application can change the final state.
I see two possible ways to solve this:
- "Into the Breach"-like approach with explicit numbers;
- Forbid non-commutative delayed effects.
ItB's approach means just adding this order information explicit in the game's interface. It looks like this:
Technically it's possible, but I don't think that it fits for Zemeroth because it's an extremely noisy interface feature, but it's not really a core mechanic.
So, I've taken the latter way: the "Bomb" ability was split into two abilities: instant "BombPush" and delayed "BombDamage".
The plan is to have three groups of objects with effects:
- Objects with immediate effects - can have non-commutative effects;
- General objects with timed effects - only commutative effects allowed;
- Special rare timed objects - can do whatever they want, but only allowed as unique boss abilities, work in their own special phase.
Other Game Rules Changes
-
Spike traps were added. They're almost like fire tiles, but permanent.
-
Updated "Poison" passive ability: it can’t, by itself, kill an agent anymore. “Poisoned” status is removed when a target’s strength is reduced to 1. This should make battles a little bit less frustrating and more dramatic.
-
Updated the "Summon" ability:
Each agent is now treated individually. The new code treats each summoned agent individually thus preventing summoning weird packs of same imp types.
Each use of the "Summon" ability now creates one more imp. It forces the player to play more aggressively.
Changed the summoning algorithm to prefer imp types that are under-presented on the map, not just random ones. Seems to work fine now - even with increased summon rate imp types are balanced in count: pic.
-
Randomly-placed agents are not created point-blank to their enemies anymore.
-
Also, randomly-placed objects are now created on different
Line
s. This prevents Imp Summoners from being created only a tile away from enemies and thus not having any chances to survive. Or Imp Bombers being instantly tied with a melee fight.A line is defined in the scenario files:
(owner: Some((1)), typename: "imp", line: Front, count: 4),
There're four possible values:
pub enum Line { Any, Front, Middle, Back }
Gameplay Video
So, putting these gameplay changes together:
This is a piece of a campaign mode playtest: battles 3, 4, and 5.
SVG Atlas
Back to more technical updates.
As git is bad at storing non-text files and it's practically impossible to use Git LFS with a free GitHub plan (because of the bandwidth limitations), it looks like a good idea to keep text source files, assets source files, and built assets in separate repositories to make "optimization history editing" (removing old commits) easier.
The main repo and the assets repo already existed, but I wasn't storing assets source files in any VCS.
So, during v0.5 development, I've created another repo for assets sources: ozkriff/zemeroth_assets_src. The two key files of this repo are: atlas.svg and export.py.
The atlas contains all sprites in one file. Each sprite is stored as a named group. As the game isn't that much art heavy, using one file for all visual assets looks fitting, because of it:
- simplifies sharing parts of sprites and mockups creation;
- avoids complications of linking/embedding multiple SVG files together (you need to manually edit XML do to this properly as far as I know).
The export script is quite simple, it just calls Inkscape using its CLI interface and tells what named group needs to be exported to PNGs. It boils down to:
for id in ['imp', 'imp_toxic', 'grass', ...]: subprocess.run([ 'inkscape', input_file_name, f'--export-id={id}', f'--export-png={out_dir_name}/{id}.png', ], check=True)
There's also a hack to avoid specifying exact sprite PNG sizes as raw numbers in the export script: each named group contains an invisible square (a rectangle for terrain tiles). It can be temporary made slightly visible for debugging purposes:
Assets Hash
Another technical assets-related update is that a md5 hash check was added. This should help to detect when someone who is building from source forgets to update the assets.
A small checksum.py python script is used to calculate the hash of all non-hidden files in the repo. CI uses it to check that the committed hashsum really corresponds to the committed assets.
The expected hash is hardcoded directly into main.rs. If the game is run with a wrong version of assets, now you get a clear error message about that:
Bad assets checksum abcdeabcdeabcdeabcdeabcdeabcdeab (expected 18e7de361e74471aeaec3f209ef63c3e)
Tests
One of the benefits of making a turn-based game is that you can relatively easy separate the logic from the visuals and cover the former with tests.
A few test scenarios were added.
They are completely deterministic.
Randomness is mitigated with special agent types with unrealistic stats
(for example, accuracy = 999
or strength = 1
),
that allows them to always pass required tests
(for example, always hits or always dies on the first hit),
and an additional no_random
flag in the game state, that causes a panic
if agent's stats during the "dice roll" may result in non-determined results
(basically, it checks that the coefficients are large or low enough
to shut off any dice value fluctuations).
"Exact objects" were added to the scenarios.
Test scenarios mustn't contain any randomly-placed objects,
otherwise the no_random
debug flag will cause a panic.
Basic test looks like this:
#[test] fn basic_move() { let prototypes = prototypes(&[ ("mover", [component_agent_move_basic()].to_vec()), ("dull", [component_agent_dull()].to_vec()), ]); let scenario = scenario::default() .object(P0, "mover", PosHex { q: 0, r: 0 }) .object(P1, "dull", PosHex { q: 0, r: 2 }); let mut state = debug_state(prototypes, scenario); let path = Path::new(vec![ PosHex { q: 0, r: 0 }, PosHex { q: 0, r: 1 }, ]); exec_and_check( &mut state, command::MoveTo { id: ObjId(0), path: path.clone(), }, &[Event { active_event: event::MoveTo { id: ObjId(0), path, cost: Moves(1), } .into(), // small formatting issue, see a note below actor_ids: vec![ObjId(0)], instant_effects: Vec::new(), timed_effects: Vec::new(), scheduled_abilities: Vec::new(), }], ); }
(I didn't use the builder pattern for event construction, even though most of the time two or three its fields are empty vectors, because I've faced some method chains formatting issues)
Test scenario consists of a list of commands and a list of expected events. Occasionally, it can check some parts of the state.
A prototypes list and a scenario are created from scratch (though, with some helper functions) for each test. It takes more lines of code than reusing a small set of multi-cases scenarios, but the idea is that this way the amount of objects and components in each test is minimized. This way it's easier to diagnose the bug and makes tests less likely to break on unrelated game logic change.
A "dull" enemy agent is required only for the scenario not to end instantly. Because the win condition is when no enemy agents are alive.
colin-kiegel/rust-pretty-assertions is a super-useful crate when you need to debug failing assert comparisons of big hierarchical objects (some of which may be many screens long in my case).
One peculiarity is that I had to replace all HashMap<ObjId, Vec<Foo>>
in events with Vec<(ObjId, Vec<Foo>)>
to preserve the order.
Otherwise pretty-assertion has been exploding.
Other Technical Changes
- Moved all crates to Rust 2018;
- Added a note about 'help-wanted' issues;
- Used
derive_more::From
for enums and errors; - Migrated to
std::time::Duration
and addedtime_s
shortcut/helper function; - Removed some data duplication
from the
.ron
config with objects descriptions using serde`s default annotations and helper init functions; - Added a
windows_subsystem
attribute to hide cmd window on Windows; - Fixed panic when a boulder is pushed into fire/spikes;
- Mergeed all 'line_height' consts and functions;
- Removed data duplication from
objects.ron
.
Indikator
Gave a presentation about Zemeroth at 8th Indie-StandUp at Indikator. It went pretty good, local indie devs seemed to like the project, especially considering that it's opensource and uses an interesting tech. At least one of the devs has visited our local rustlang meetup afterward. 🦀
It's unrelated, but Zemeroth was mentioned on Amit's page about hex math.
Migrated This Devlog to Zola
During the preparation for this Zemeroth release, I've finally switched the static site generator behind this devlog from Python-based Pelican to Rustlang-based Zola.
Here's a twitter thread with some migration notes.
TLDR is that I've mostly automatically converted all RestructuredText post sources into Markdown, replaced Disqus comments with direct links to reddit/twitter/etc, set up redirects from old URLs.
Roadmap
What's next? Some things I hope to implement for v0.6 release are:
- Replace the text buttons with icons;
- Reduce text overlapping;
- Add sound effects and music;
- Add fighters upgrade trees;
You can find a slightly more detailed roadmap in the project's README.
That's all for today, thanks for reading!
If you're interested in this project you can follow @ozkriff on Twitter for more news.
Also, if you're interested in Rust game development in general, you may want to check @rust_gamedev twitter account that I've started recently.