Revengate development log
Weekly progress reports about the Revengate development.
I started working on saving games. Godot custom resources are somewhat similar to Python pickles: they can both save arbitrary native and user-defined types, and they both suffer from arbitrary code execution vulnerabilities. For Revengate, the latter not a big deal because it's hard enough to upload files inside the Android sandbox that no player is ever going to try using saved games that they got from shady places on the internet, and even if they did, the malicious code would not be able to do anything outside the sandbox since Revengate requests no Androids permissions at all. As long as we don't have a desktop version of the game, that's a productivity shortcut that I'm very comfortable taking.
Another big pro of custom resources is that new fields can have a default value, which provides some level of forward compatibility. It's not perfect, but it covers many cases. For the rest, I can only offer the player to delete their old saved games because Android won't let them downgrade the game even though I publish all the old APKs (well, it's doable, but it's really hard).
Custom resources work amazing for flat data, but you need an extra twist to make them deal with data trees. Thankfully, Godot also has
PackedScenes for that. Basically, that's how the editor saves your scenes as
.tscn files. And that is also where the similarity with Python pickles end because there is a fair bit of magic with how Godot decides what nodes should be part of a packed scene. Thankfully, I found this very helpful diagram. Basically, the
owner of a node has to be inside the PackedScene or has to be in its parent chain. Fair enough, but Godot will also void the owner field for various reasons, including when you reparent nodes, which is how the hero moves to between levels and how actors pick up items in Revengate. So long story short, I think that the best way to do it is to recursively set the owner to the root node of what I'm saving just before the actual save. That seems too work, but I'm aware that I might be forgetting about a few edge cases. And the beauty here is that everything is included: poisoning conditions, bot goals, memory of who offended whom... Everything!
In any case, I'm way further on this than I thought I would be after one week, which is really good for my motivation!
Next: figure out how to tell the rest of the game about a restored state. That is, reconnect all the UI signals and refresh the view.
Swarming works! I tried to come up with a demo gif, but since most of the magic happens out of perception range, you'll have to take my word for it.
Swarming monsters seek each others. If they have enough connectedness, they might seek other actions, such as exploring the level. Only one in the swarm that is exploring is enough to move the whole swarm. If a higher priority action kicks in, such as kicking the hero's ass, the first the monster to see you will stop swarming, but the swarm will try to stay with it and soon the whole swarm will be after you. This is very different from, for example, giant bees in Nethack or spiders in Caves RL where they all start in the same room, but will readily diffuse after you wake them up.
JT paired with me on this one and I'm happy with how simple it turned out: if you can get closer to the peers that you can perceive, that's what you do, unless you feel adequately surrounded already. I was afraid it would end up as a jumbles mess that requires cross communication between actors, but we found a good way to reduce it to fully autonomous decisions.
I spent the rest of the week running Monte Carlo simulations and play testing until the polish and the balanced felt right. And since I got there yesterday, I pushed v0.11.0 on Google Play. It's properly git-tagged and should therefore appeared on F-Droid in a few days.
This release adds:
- daggers with a longer range;
- new building: church;
- new dungeon: crypt under the church;
- new weapon: Mjölnir;
- new monsters: cherub, skeleton, sentry scarab, nochort, plasus rat, Algerian giant locust;
- new monster strategy: swarming;
- new item: carrot.
Bug that were fixed:
- can no longer chat across walls and closed doors;
- more consistent clearing of action highlight markers (the red and green squares around actors);
- monster strategies now obey perception (with select rare exceptions).
Player experience changes:
- single tap on distant actors pop a context menu: (get closer, inspect, ...);
- added a health bar;
- flash a message on with the reason why a travel command aborted (path block, under attack, ...);
- Quick Attack button is available as soon as you've attacked someone in range, even a friendly actor.
Next: items descriptions and saved games.
I paired with JT Wright who made a really cool Monte Carlo simulator for the furnishing deck builders. Revengate populates new game boards with monsters, items, and vibe by making a "card" deck of what could go there based on rules, what was previously generated, and budget. Then we draw cards until we meet our budget for the level (or the deck is empty).
The new simulator runs 1000 whole dungeons and then outputs a statistical summary of what has been drawn. For example:
Summary of depth 1 (c) desert centipede: 9.05% | p10: 0 p50: 0 p90: 1 (k) kobold: 29.09% | p10: 0 p50: 1 p90: 3 (l) labras: 15.17% | p10: 0 p50: 1 p90: 2 (r) rat: 11.24% | p10: 0 p50: 0 p90: 1 (s) sahwakoon: 11.65% | p10: 0 p50: 0 p90: 2 (o) sewer otter: 13.04% | p10: 0 p50: 1 p90: 2 (f) Sulant tiger: 10.77% | p10: 0 p50: 0 p90: 1 [...] Summary of depth 6 (c) desert centipede: 11.38% | p10: 0 p50: 1 p90: 2 (𝔤) ghost: 9.62% | p10: 0 p50: 1 p90: 1 (k) kobold: 11.20% | p10: 0 p50: 1 p90: 2 (l) labras: 19.81% | p10: 0 p50: 1 p90: 2 (r) rat: 9.26% | p10: 0 p50: 0 p90: 1 (s) sahwakoon: 10.13% | p10: 0 p50: 1 p90: 2 (o) sewer otter: 15.30% | p10: 0 p50: 1 p90: 2 (f) Sulant tiger: 13.29% | p10: 0 p50: 1 p90: 2 [...] Summary of depth 10 (c) desert centipede: 13.95% | p10: 0 p50: 1 p90: 2 (𝔤) ghost: 3.69% | p10: 0 p50: 0 p90: 1 (k) kobold: 4.51% | p10: 0 p50: 0 p90: 1 (l) labras: 23.33% | p10: 0 p50: 2 p90: 3 (P) pacherr: 3.22% | p10: 0 p50: 0 p90: 1 (r) rat: 8.46% | p10: 0 p50: 0 p90: 2 (s) sahwakoon: 9.36% | p10: 0 p50: 1 p90: 2 (o) sewer otter: 18.29% | p10: 0 p50: 1 p90: 3 (f) Sulant tiger: 15.19% | p10: 0 p50: 1 p90: 2 [...]
It reads as follow: kobolds represented 29.09% of the monsters generated at depth 1. In the 10% time with the fewer kobolds, there were none, the median number was 1 at this depth, and 10% of the times there were 3 or more.
As you go deeper, you start to see ghosts. Since ghosts have an extra rule that makes them very rare (just 3 per dungeons), you are less likely to see them at depth 10 since we don't have any ghost cards left at this point. This was not previously the case as the rarity of ghosts was entirely controlled by their high spawn cost, but the simulator allowed us to fine tune the generation with the extra card rule.
I added a few monsters and put then in the new deck for the crypt dungeon from last week: plasus rats, skeletons, and the scary cherubim. I used the deck simulator to make sure that the scary cherubim were adequately rare.
I tried to make the giant locusts move as a swarm, but my naïve approach is not cutting it. The applicability of the strategy seems too rigid and the locusts fall out of swarming too easily. I want them to pass the control to other strategies for attacking and exploring, otherwise the swarming strategy needs to re-implement everything in the context of swarming. I really like the idea of swarm emergence, so I will experiment more on this one.
Next: maybe items descriptions, maybe I polish what I have and push a new release out. I'll see how I feel tomorrow morning.
The new crypt under the church is a series of long corridors with small chambers on each sides. This is strikingly more organized than traboules and more claustrophobic than the surface since it's so easy to have your escape path blocked off.
PreFabs can now emit cards that will go into the level furnishing deck (Actors, Items, Vibe). Cards can be either mandatory (you really want a priest in the new church) or probabilistic (might smell of incense, or maybe not). The spawn cards now have an optional
spawn_rect field to restrict where they can appear.
I helped Victor Pernet to add his game (Pirate Solitaire) on F-Droid. He got the whole thing to build clean, we are now waiting for the F-Droid team to audit his code to make sure that it is as Open Source as he claims. That will make Pirate Solitaire the second Godot-4 game on that app store.
Roguelike Celebration was awesome! The talks have not been posted online yet, so I will probably repost this when they are. My favourite ones were:
- Fireside chat about the development of NetHack by Jesse Collet, Keni
- Audible Geometry: Coordinate Systems as a Resource for Music Generation by Paul Hembree
- Backpack Hero - Player Upgrades and Progression by Jasper Cole
I also really enjoyed the unconferencing.
I tried to emulate the focus mode that I had near the Merced by removing the wifi password from my laptop. This way, I can only access the internet when tethering with one of my phones. When I want to focus, I put all the phones in a different room. It's too early claim any kind of victory, but I like how simple that was to implement.
Next: add the right vibe cards to the church, populate the crypt with undeads and scary angels.
I managed to do a few things while camping:
- added a health bar
- church prefab
- single-tap on a distant actor now pops a context menu, there is a new command to get closer (within action range) to them
- Traveling multi-turn command flashes a message on why it aborts (attacked, path blocked, ...)
- quick-attack button is available on friendly actors you've previously attacked
- action highlight are properly cleared after a conversation, now using the
is_valid()predicate of the Talk and Attack commands.
- Healing monster strategy is now triggered by health-% rather than health points
- TribalTerritorial monster strategy is taking perception into account
magicalis now an Effect tag rather than a field
I felt really productive even though my coding was timebox to only a few late afternoons. It's weird, sitting there at a wobbly picnic table that was the wrong height, without my mechanical keyboard or my external monitor, code came easier than when I have a perfectly ergonomic desk setup. It took me a few attempts to get there. It seems like best coding spots were the ones where I didn't get any cell service. The calming sound of the a river helped too.
Yosemite is full of potential distractions: climb something, watch people struggle on the Generator Crack, refill my water jugs at Fern Spring, hang out with a beer in El Cap meadow while watching big wall climbers with binoculars. All those, however, have a fairly high activation cost, which puts then in big contrast with checking the news or going down a Wikipedia rabbit hole. I can obviously put my laptop in airplane mode when I decide to work on the game, but it's so trivial to override that commitment that I don't think it's going to work as well as sitting by the Merced river. It's also possible that the legend is true and that there is something magical about the Fern Spring water, so just to be safe I stocked up on several litters of the stuff.
I made up my mind on what should go in v0.11 (you might have to scroll down, gitlab is weird). In short, it's mostly more work on UX but you still get a few new monsters, including the very scary cherub. I don't want to add new quests until it's possible to save and restore games, the cherub will therefore sit in a purely optional side-dungeon.
I upgraded my DialogueManager plugin for Godot. My speech bubble was doing integration based on the plugin internals rather than the doc, so I was afraid things would break. It did, but the public API now exposes all the signalling I need so it was well worth doing this cleanup.
The threat of a government shutdown tricked me into changing my camping reservation in Yosemite. I lost a few good climbing days, but that gave me time to fix all (known) bugs in Revengate v0.10.0 and to push it to Google Play. It should also show up on F-Droid in a few days.
This includes the new quest and a few significant UX improvements.
I made it possible to keep playing after failing a chapter. If you don't succeed a non-blocking quest, the quest NPC is a little pissed and you don't get a quest reward, but you can keep playing. This required decoupling the victory logic from the victory and game over screens. It was also the right opportunity to move "quests", which were mostly scattered conditions, as their own self-contained entities.
JT and I added a collection of surface areas to reach the new quest. This is pretty much a nice city stroll with lots of friendly NPCs and just a few rats and thieves. It's good to take a break from the non-stop action. We had fun writting the rumors and chatter you hear around town.
I'm heading out camping for a few weeks, so probably no more updates until after Roguelike Celebration. I was hoping to get the next build out before camping, but that won't happen.
You can now offend someone from a conversation and someone who recalls being offended becomes hostile. This decays over time based on the severity of the offense. The new quest has variable rewards depending on if you stick to beating up the informant or lose control and kill them. Better go with a weak weapon if you happen to hit pretty hard.
There is a new monster: the yarohu.
Worked with JT on balancing the new monsters and NPCs for the new quest. The Monte Carlo simulator was great! It still has a few rough edges, but it already can summarize hours of test play in just a few minutes.
I fixed the scrolling to the hero after a quest victory and after entering a new board.
The game finally landed on F-Droid. The build recipe is pretty straight forward: build Godot for Linux, then cross compile the export templates for arm. The tricky part is that Godot is missing a bunch of import metadata until you start the editor. Since I can't do that in the gitlab CI/CD, I export the game twice and that seems to solve all the problems.
I worked on placement constraints: for the next quest, I need to place two NPCs as far apart as possible to give the player a chance to prevent them from meeting. I got the region based constraints working (I can tell the monster deck generator that some monsters should always spawn on the North side of the map, for example). I should get the distance constraints working tonight.
I watched the preview event for Roguelike Celebration and the interview with the Diablo lead dev was so good!
Revengate got it's first 5-stars review on Google Play!
Thank you, kind stranger.
It's now more obvious when you started a multi-turn travel. It's should also be more obvious how to cancel if a fat-finger-tap is what started the action. Maybe a bit of blur under the text would help with legibility.
Drag-to-pan is no longer super fast when zoomed-in.
Working on the new quest: Le Grand Salapou, the master mind clown spy is about to meet with an informant and you have to stop the encounter before it happens. Salapou is neutral, but easy to anger and his entourage tends to follow his lead.
A few new mechanics were introduced to make this quest possible. The global cross-faction sentiments are not variable. The Clockwork Automata have a guarding strategy: they follow Salapou around without getting in his way. They immediately retaliate if the person they are guarding is attacked. The informant can seek someone (Salapou in this case) and they will yield if you are convincing enough (that is, if you beat them up). JT wrote those last two.
I discovered ripgrep. Wow! It's awesome! It does a perfect job of only grepping my Godot files without looking at any of the auto-generated stuff. Makes me want to do a ton of refactoring now.
Next up: work on the dialogue and conversation triggers for the new quest.
The quick attack button shows how many weapons you have when you equipped from a stack. The button icon is updated whenever the active weapon changes.
This fixed several usability issues in one go. That was time really well spent!
I fixed the pan: it's no longer super fast when zoomed it. The board is pretty much sticky under your finger, which is what Material Design recommends. It's not quit there yet when doing a two fingers pan. I may or may not spend more time to get that to work as well.
I got the game to build on the F-Droid infra. It's in review now and it should get accepted. Fingers crossed!
The recipe is actually very straigthforward: build the Godot editor for X86, then the Android export templates for ARM (both the C++ and the Java part). If you've never launched Godot in interactive editor mode, it will complain that a bunch of files have never been imported and it will ignore them. Compiling the APK twice in a row fixes that.
There was a lot of fiddling because different parts of Godot want to learn about what you are trying to do from different sources (command line flags, env vars, config files). I will probably open issues with Godot to normalize that a little bit. At least the Godot configs are standard .ini files that the Python
configparser module knows how to deal with, for the most part.
There are just a few more minor usability issues to tackle this release, then I add the new monsters.
I submitted Revengate to F-Droid. F-Droid is a very interesting app-store for Android that only catalogues open-source packages. They are so serious about this mission that you can't send them your signed binaries like you do on Google Play – you have to provide build instructions so they can build it from source themselves, including all your dependencies. In the case of Revengate, that includes building Godot as well. Thankfully, their build environment is Ubuntu, which is also what I run on my dev machine. I'm running slightly different version of the OS, but it's close enough that I feel good about what I submitted even though I didn't run the build pipeline on their Docker images. Revengate also seem to be the first Godot 4 game on F-Droid.
UX is my least favourite part of game development, but seeing a few testers struggle with their first exposure to Revengate motivated me to spend the next two releases on smoothing things out a little bit.
I did my best to identify and quantify the current interface problems, then I scheduled the most promising solutions in my roadmap. Of course I can't only do UX – that would be a reliable way for me to burn out – so I also plan on adding a few new monsters.
I started working on the quick attack button with JT Wright.
It indeed streamlines combat! It's the same number of taps as tapping on the monster, but the new button is always right under your thumb and you don't need to aim.
The monsters Tracking strategy is now triggered by perception: a monster will not start tracking a foe until they can perceive them. They will keep tracking for a while after loosing perception, but they stop tracking if when it's been too long since they sensed their prey. If you have better perception then them, you could retreat ASAP when you sense them and thereby avoid them entirely.
Most monsters are roaming, so you still bump into then, but at a lower frequency. This feels really good and it pushes the player to balance the trade-offs of side exploration vs direct path to the next dungeon level.
I smoothed most of the rough edges of items grouping with JT Wright.
I published v0.9.3 on Google Play. It includes this, the ranged combat from last week, and the healing spell.
Next up? I think it's time to add a new quest.
Similar items in the inventory screen are now grouped together.
There are still a few edge cases to take care of, like activating an item that is part of a stack. Presumably, you don't want to light the fuse on all your dynamites, so we should probably make it obvious which one is now lit so you can drop only that one.
Wildfires got really bad in Spokane and I got evacuated to a level 3 alert (go now). The fire got to about 1km of my airbnb and I was mentally prepared to lose what I had left behind. I was able to come back and all my stuff is fine. I'm still under a level-1 evacuation watch (be ready), that is: go-bag by the door. That adventure messed with my focus, but it got me to think about what is important in life: mechanical keyboard or water filter?
I was out camping in Yellowstone this week, so not much progress, but I feel greatly energized!
I added tile highlight to show where there is a default action for something. The default action is activated by a single tap: attack an enemy or talk to a friendly NPC.
JT Wright implemented ranged combat.
JT was really eager to push the improvement, so he pressed
Enter as soon as he heard me say
git reset --hard, before I had a chance to mention the name of the file to reset. He lost the first implementation of ranged combat, but he didn't let that get his morale down and he re-implement the whole thing right away. What a trooper! We only support ranged weapons that are their own ammunition (ex.: daggers) right now. Next stop, ranged weapons with separate ammunition supplies (ex.: bows, guns).
I paired with JT to make a lightning spell. The spell stuns, which is scary effective! We still need a sound effect for that one and there is a magical algebraic spell that we need to unlock to stretch the zapping bolt from the caster to victim.
I added a healing spell.
The visual effect for this one is a Godot particle system, no custom shader code. The sound effect is me singing. I pitch shifted several copies of the clip to simulate multiple voices before adding some reverb. Audacity has a reverb preset for "cathedral", which seems like an appropriate venue for a choir of angels to be singing in.
Adding the Monte Carlo simulator went really well. With great foresight, I had written the main loop without assuming that one of the actors would be controlled by the player. I was therefore able to call that loop unchanged in the simulator. Furthermore, all the monsters were really happy to beleive that they would be unseen if there was no hero at all in the game (rather than the hero being out of perception range), which means that they just didn't do their animations in simulation mode. Sounds and a few minor animations were missing that perception gating, but that was loudly obvious and easy to fix.
Then it was just a matter of making the main loop restartable and to add a few more combat signals to make the simulation stats more detailed.
The performance turned out to be better than I was expecting, between 1000 and 2000 combat turns per second for most monsters. I tried the Godot profiler for the very first time to see if there was something obvious that could be improved.
The Godot profiler is interesting. The overhead is pretty small, about 20%, but there is unfortunately no call graph. The stats are aggregated per rendering frame and you can go back in time. So if there is one frame out of 20 that is slow to draw, you can click on the graph where the drawing time spikes and bam! You see the per-function time break down from back when this frame was drawing. So cool!
For my combat code, there are plenty of small gains to be made, but they would all require some non-trivial changes so I'm resisting the great urge to fiddle and not optimizing anything right now. Tuning the balance is more urgent than speeding up combat calculations.
A simulation run looks like this:
Starting a batch of 100 sims for AverageJoe, Rat, Rat2, Rat3... Ran 100 sims in 1.26 seconds (421.64 turn/s) Median encouter lasted 5 turns Victory: 99.00% median gladiator health: 85.00% Defeat: 0.00% Draw: 1.00% median gladiator health: 100.00% median challengers health: 100.00% AverageJoe's hit rate: 67.76% Rat's hit rate: 76.07% Rat2's hit rate: 72.85% Rat3's hit rate: 78.28%
You can think of the "gladiator" as the hero, but it's controlled by the sim rather than by the player. It's a perfectly average human in this case and he's not great at hitting rats, but he kills them fast when that happens. That fits what I wanted to model: rats are weak, but small and hard to hit. (I don't know what happened during that sim that resulted in a draw.)
Then there is this one:
Starting a batch of 100 sims for AverageJoe, Ghost... Ran 100 sims in 1.36 seconds (1221.98 turn/s) Median encouter lasted 17 turns Victory: 0.00% Defeat: 100.00% median challengers health: 81.08% Draw: 0.00% AverageJoe's hit rate: 11.74% Ghost's hit rate: 86.67%
Ghosts are meant to be elusive and hard to hit, but this is obviously too hard. Thank you simulator, now I know where to do some tuning!
Next stop, auto-tuning...
Revengate v0.9.0 is on Google Play (for some countries). This build has the magic from last week, silver weapons, and a second quest!
Building the second quest was a lot of fun. I had to rewire a few things to distinguish between a chapter victory from a game ending victory. The new set of dialogues is starting to give some personality to the NPCs, and the quest reward feels great! There were a few UI changes and usually I find all sorts of excuses to procrastinate when it comes to UI, but for those the motivation of seeing the new quest was strong enough to get me to do UI first thing in the morning!
Tuning the balance has been very time consuming. I don't mind playing the game, but doing it non-stop just to move a few numbers is too inefficient. It's time to bring back the Monte Carlo Simulator!
The old Python version of the game had a perfect separation between calculations and presentation, so it was really easy to make a simulator for it. With Godot, presentation is coupled more tightly, but I think that the work I did on perception and visibility should be enough to run combats faster than game-speed (monsters that you can't perceive move without animations).
In any case, getting that working is my priority for next week.
Revengate has magic!
This is pushing what GIFs can do, here's a better encoding of the demo.
I kept pushing back on this one, partly because I was not sure about the magic system (scroll all the way down), which is a bit of a fusion between the systems in Wheel of Time and American Gods with a steampunk twist. At some point, I figured that it's probably better to have lots of an OK magic system than just a tiny bit of a great one.
OK, I went a bit wild with the summoning spell. If you squint, you can probably guess how it works. The key is picking good looking decals to start with, then it's just a bit of pixel shifting with some colorization.
About half of the magic rules are implemented (the phantruch are bound to a vital assemblage and there is a channeling device to decrease your mana cost). Only monsters can do magic for now – I need a few more spells before I can make up my mind on how to expose the whole thing to the player.
You can open and close doors. There are new "vibe" nodes, things you feel while walking around the dungeon.
Those are not used by the level generator yet; they will probably come in a late pass and be tied to active pre-fabs and to whichever monster cards were drawn for the level.
Added end-of-game stats: easy stuff stuff like the number of turns and the list of victims.
Added silver weapons: better to-hit against ethereal monsters.
async vs sanity
My brain almost melted trying to figure out why items sometimes didn't reappear after being stepped over. This was a really rare thing, maybe 1% of the times. Playing with the duration of the actor move animation and the item fadeout animation allowed me to repro about 25% of the time. I was happy about that and I added a bunch of print statements to see what was going on. Just like that, the problem was gone! The time it took for Godot to print was slowing things down enough to perfectly resynchronize everything.
Change of strategy: sprinkle asserts without printing anything in hope of catching the problem and getting a stack trace when the problem happens. It turns out that there can be quite some time between then the first and when the last handler for a signal are called.
When an actor is still in running their animations, they can end their turn and let other actors play. This increases the game pace and is more fun to watch. It order to keep the game consistent, however, that actor can't start their new turn until they are done with their animations. So one signal handler on
Tween.finished decrements the active animations counter and then fires another signal to let the game loop know if the next turn can start for this actor. If the animation is a move, there is another handler on
Tween.finished to do more internal cleanup.
So once in a blue moon, when the game loop sees that the actor is good to go, the next turn starts for that actor, the actor begins moving, and about half way through their move, the original move cleanup handler for the previous turn fires. Damn! That messes the internal state of the active move and the item on the floor suddenly thinks that someone is still stepping on it.
So moral of the story is that you can keep adding stuff to
Tween.finished, but if you depend on a reliable timing, then all the important work has to be done in the first handler that you register.
JT Wright made a really cool particle system for the dynamite.
You now perceive monsters only when you are close enough.
A direct line of sight allows you to perceive further, but it seems just fair that you'd be able to smell or hear monsters hiding just around a corner. There is a new potion of booze that will increase your regeneration for a little while but it mess with your perception (among other things). Items are always visible, this is a conscious design decision to torment the player with known potential rewards vs the unknown risk of getting beaten up.
All of this plus last week's progress seemed like a substantial enough increment so I pushed v0.8.0 to Google Play.
Revengate has an overworld map.
While crossing a traboule, stairs and gates mostly move you towards the cardinal point where they are located, but the map is disabled underground to keep the experience slightly disorienting.
I'm now smarter with where stairs are placed by maximizing multiple Dijkstra minimums. Stairs used to be placed randomly and that makes a good level on average, but there were too many cases where up and down staircases would be right next to each others, allowing the player to completely bypass the exploration of that level.
Now after placing a cross-level connectors (stairs and gates), I measures all the distances from it using the Dijkstra algorithm. Each subsequent placement picks a cell where the minimums of previous Dijkstra metrics is maximal. This is probably easier to understand with a picture.
Here the gate on the right is placed first since it has a stronger constraint: it must lead to a location East of here. The Dijkstra metrics from it are in the first matrix. The stairs going down are easy to find: distance 41 at top left. We make another set of distance measurements starting from there.
Things start to be interesting when trying to place the upstairs. For each position on the board, we find the minimum from both Dijkstra matrices at that position. The optimal placement is where those minimums is the largest: dist=25. This is also in the top left, but quite the detour away from the downstairs. Ties are typically numerous, especially with large rectangular rooms. Those are resolved by picking a random maxi-min coordinate.
I have pinch to zoom!
This was not as hard as I was expecting it to be and the improvement to the player experience is hard to overstate. My old zoom slider bar was either hard to hit or it took too much of the precious mobile screen real estate. It really paid of that I was scared by this change and that I sat down to flesh up the whole plan on paper before even looking at the code.
Godot does recognize some multi-touch gestures in the internal API that it uses to build the editor, but unfortunately none of that is exposed in GDScript. Godot exposes touch events with an
index attribute that is unique to each finger and you need to keep track of previous events to decide for yourself if you are inside a gesture. When you receive a tap (
index=2 (third finger on the screen), there is no information in GDScript to tell you if the other fingers are still on the screen nor where they were last time you saw them. Hopefully, you've been recording that back when those events fired. Another "interesting" quirk of the Godot API is that Buttons are blind to touch events. Buttons just don't see taps at all. You can enable mouse-emulation, but then you receive both a mouse event and a touch event every time a finger does something on the screen. The only way I found to take care of this was to detect the platform and ignore mouse events in the gesture recognizer on mobile. This is OK, but it means that a player who paired a bluetooth mouse with their tablet will be stuck to do all the gestures on the touch screen.
With something like Google Maps, zooming and panning is the same multi-touch gesture. If your fingers are moving but keep the same relative distance, then it's a pan, if their distance change but that the centroid of all fingers stays in the same spot, then it's a zoom, if both happen at the same time, no problem: zoom + pan. A multi-touch gesture is two or more fingers on the screen, and thinking too hard about how to track the motion of each fingers can easily melt your brain. I think that the key to stay sane is to only track the centroid of all the fingers and the average finger distance to the centroid.
So that's what I did and I almost shed a tear of joy when it just worked (ignoring the velocity tuning).
I added prefabs with a definition string telling me where and what. For example, adding a river (
r) on the East side of a new game board
E will be:
Er. That should make it easy to define the Lyon surface in terms of its major features in a 10x10 matrix of prefab strings. Prefabs all act on a rectangle and they return the sub-rect that was not fabbed. Multiple passes of procgen can therefore easily avoid stepping on each others toes.
PyPI, the Python Package Index, recently deprecated GPG. Reading the really well written annoucement and it's associated links sent me a bit down a rabbit hole. In the end I could only conclude that it was not just me who it not smart enough to remember how to manage my GPG keys, it's that the whole thing sucks. The usability problems will be obvious to anyone who tried to do anything with GPG, but the crypto happens to be a problem as well. It has not been broken, but the security guarantees provided by GPG is incredibly weak compared to something more modern like Signal that will have one key pair per person you communicate with and that will change it every single message. A compromised GPG key would be the failure of the whole system while a compromised Signal key is no more than one compromised message.
Of course, Signal is not a solution for code signing, but I was really happy to discover that OpenSSH can do that part and that it's been integrated with git. It's a little weird that they call the ssh signing
gpg.format, but the UX is perfectly transparent otherwise and both GitHub and GitLab will show those ssh-signed commits as "verified" like they do for gpg-signed commits.
Things get really cool when you add age to the mix. With
age, you can take the public ssh key that GitHub and GitLab displays for my commits and encrypt a message for me with it. You have a really good sense that the key belongs to me since I signed all this code with it, so if you can't meet me in person for a key exchange, you still have a solid option for an initial contact.
My GPG key is expiring in 11 months and I don't think I will bother to extend it or create a new one after that.
My laptop battery died: it's all poofed up like a bag of chips. Thankfully I use a System 76 computer and all the service manuals are online. It was really easy to take it out while I'm waiting for the replacement and doing it myself does not void the warranty. I'm stuck staying plugged in the mean time.
Revengate v0.7.0 is on Google Play. It features a new quest that is starting to show some steampunk vibes.
Levels are populated with monsters and items using card decks. Last week focused on teaching the deck generator about world locations so that the quest item and boss would be generated when you end up in the right part of town.
Now that the above is out of the way, I fell ready to tackle my nemesis: multi-touch gestures. Let's see if I can get pinch-to-zoom to work better that the magnification slider-bar.
I moved to Lyon, France and I'll be here for another seven weeks.
Revengate is set in Lyon and I was at the point where I was designing the surface levels when it dawned on me that Google Maps was not the right way to immerse myself in the vibe of the city. So in what could be the greatest yak shaving of the decade, I packed my bags and came to see the traboules in person.
Unsurprisingly to many of you, this exploration probably won't have much influence on the level design: the low graphics style is meant to let your imagination run wild rather than depicting things in great details. The influence on the quests and stories, however, will be profound.
I had greatly underestimated how much mystery and intrigue was hiding around every corner of those narrow twisty streets. Secret societies, black magic, knights templar: they all had a well documented representation in the city for centuries. More on that in future updates once I start integrating those ideas in the storyline. For now, the real Jacquard loom completely blew my mind. This programmable machine will receive a steampunk twist earlier in story line with punch cards being commonly used to represent hidden messages in addition to weaving patterns.
I played with the tile atlas to depict a cleaner surface world and darker and older subterranean world. Most of my work during the past few weeks went into eating cheese, err, I mean allowing the procgen to know what level design to use depending on where you are in the world. I want to have parametric prefabs to decorate the obvious features like the two major rivers, but for now the transition between the two vibes is working well. A traboule dungeon correctly links two parts of the surface city.
I added a maze generator.
It's reasonably tunable with various biases: should corridor be long and straight or short and twisty, how often do you see dead ends, is there more than one path between any two points? It also supports masking, which is another way to say that I can tell it to ignore arbitrary areas of the board.
I had a tunable generator in Python based on the Recursive Backtracker algorithm. For this one, I went with the Growing Tree algorithm. Growing Tree is a only slightly more complicated, but it enables the bias for more dead ends, which was impossible to control with the Recursive Backtracker. Since one is recursive and the other is iterative, I didn't review my Python code and just started typing in Godot. I was pleasantly surprised that it worked on the first try.
Even though I didn't look back at the Python code, writing it really helped me get a good intuition on which biases gave a good return on investment. It's weird, but there is a part of my mind now that thinks like a maze generator. When I look at a street map, I see how it would be like with different bias parameters.
Both algorithms and many more are covered in Jamis Buck's excellent book.
I finished reading Shadow of Self. What an ending!
I added a dynamite item.
This was a big refactor since it's my first item with an expiration time. I standardized how things age and now I expose the same API for Strategies, Items, and Conditions. That allows me to age everything without knowing their type. I also disabled animations for things that are not on the active board, so before returning to a previously visited level, I can quickly refresh all the conditions and blow up any lit dynamite you left behind before showing you the level. Disabling animations is going to come very handy when I resurrect the Monte Carlo simulator.
I couldn't find a good sound effect for the explosion, so I ended up voice acting it.
Turns out Godot did not enable Android gestures in 4.0. What got enabled is a limited Kotlin API that is use by the Godot editor on Android. Long story short, I will have to implement gestures in GDScript. I'm saving pinching for later, but I was able to do the long tap in just a few lines and a Timer. I used it to implement a context menu. Feel free to force yourself to attack the quest NPC. He's got good loot.
I started playing with shaders and I'm really happy with how the water surface and shockwave VFX came out.
Godot shaders are 95% GLSL. There is a tiny bit of syntax extension to help you tell Godot what kind of data your shader is expecting, which in turn lets the editor give you a fancy point-and-click UI to supply the shader data. Also, rather than having
main() be the entry point for both the vertex and the fragment shader, they start with
fragment() respectively, which means that you can put them both in the same file. Nice! The editor does an excellent job of reloading the shader preview as you type – you don't even need to save – which makes experimentation very dynamic.
I finished implementing the parametric card deck rules and I can now compose them as draggable Godot nodes to specify what goes inside a dungeon. It works for both monsters and items. It does not have cross-cards dependencies yet, which is something I would like to add at some point: it just seems fair that you would not encounter ghosts until at least one silver weapon has been generated.
Multi-turn commands can now be cancelled.
Revengate 0.5.0 is available on Google Play and for manual Android install. This version speeds up the game play by having more animation overlap. Rats are more interesting: they mostly follow walls and will generally leave you alone, but if you bother them, they can either attack back or run away.
I paired a bunch with JT Wright on the rats behavior. We started working on more interesting deck based generation for monsters and items to support more rules than the current "spawn cost", like floor limit and uniqueness. Most of it is still too rough for this release, but it should make it into 0.6.0.
Minor UI changes include messages flashing briefly over the game screen and an hour glass overlay letting you know when it's not your turn yet. There are now in-dialogue actions, so be sure to have a chat with the barber – he might have something for you.
This felt like a good streak since the last release. The only time I really started procrastinating was when I was trying to style narrations. I was able to recognize what was happening early so I dropped those from this release and went back to working on bot strategies, which is always a great source of joy. Having blockers for news sites really helped me to see that I was procrastinating more than usual. Blockers are easy to disable, but disabling them is just hard enough to bring a sense of realization.
Godot 4.0 came out. There's nothing in this release that I was not already using from the latest Release Candidate, but it's really good to see that we finally hit that milestone. I'm already extensively leveraging the new TileMap editor and I'm looking forward to start playing with the new 2D lights in 4.0.
I finished reading Mortal Engines. The air battles were quite good! I started reading Shadow of Self, the second book in the steampunk cycle of Sanderson's Mistborn series. It's really good! There's a deeper detective twist to the story and the two sidekicks get their own perspective with a better character development. The whole thing is satisfyingly wrapped in metal-base magic, with a broader showcase of abilities. Nice!
Monsters are now generated using a "probabilistic deck". Rather than storing multiple copies of the monster cards, we only store the number of copies, which can be a float (you need >=1 for a draw). This is faster and more compact. It could be tuned a tiny bit to keep track of cumulative weights, then a draw can be done in O(log n).
I managed to wrap my head around how the Dialogue Manager plugin tracks the conversation flow and got conversation options to work. Actors also remember where you left off and will potentially start right there if you talk to them again. Conversation actions, like giving someone a potion of regeneration work!
Rats now have a strong bias towards traveling by hugging the walls. This was consuming me all week: how to do it cheaply without completely blowing up my A* implementation. I played with different metrics on paper. I found one that seemed promising, implemented it, then it just worked! It's a slight bias against diagonals and a bigger penalty for going somewhere with fewer walls, only counting 4 of the 8 adjacent cells. It turns out that Manhattan distance is a really good way to estimate the diagonals bias, so I can use that as my heuristic in A* and keep most of the A* bad paths pruning properties.
I worked with someone to add a FlightOrFight strategy on rats. It's pretty good already, but it needs a bit of tuning before players can start exploiting rats as decoys.
I revisited my nemesis: animations overlap. I stopped waiting for animations to be done before moving the turn counter. This really speeds up the game play! There are still a few cases that explicitly wait for anims, like an actor has to finish moving before they can take their next turn action.
Strategy.act() returns a bool rather than an animation; the few places that need to sync on the anims have to poke the Actor state machine. There is room for more tuning, but it feels really good right now and I don't plan on playing more with it for a while.
Godot released 4.0 RC4 (then RC5 two days later). This is a big deal for Revengate because many of the Android gestures are now recognized by the engine. I should be able to replace the stupid zoom slider bar with pinching soon.
I played a few games of One Deck Dungeon, a table top roguelike. It's hard! The solo mode is pretty fun. It's also probably longer that the advertised 30 mins for most players. I don't think that there are any mechanics that would port to Revengate, unfortunately. I like the anti-grinding design: any action costs time, which brings you closed to a deeper dungeon floor with more challenging encounters. This is a lot more fun than hunger.
Revengate 0.4.0 went out to Google Play (also available for manual side-loading). This release includes the monsters stat page, monsters who remember what happened to them, non-aggressive rats, dialogues, and a skills system that is not exposed to the players yet, but which already reduces the number of misses during the typical encounter.
The Godot Dialogue Manager plug-in is excellent! It makes it really fun to write the dialogues and it has all sorts of plug points to let the conversations change the global game state. Integrating it is some work, however, and I have yet to take advantage of all the features.
Actors remember stuff that happened to them. This allows more interesting monster strategies, like self-defense (done) or fight-or-flight (todo).
The 3-stage skill system can be an excellent basis for character progression and it would map well into a skill tree (ex.: get Advanced with Blades before you can unlock Rapiers OR Sabers). For now it only decouples agility and to-hit, which results in fewer misses during the typical encounters. I much prefer those shorter and more eventful encounters.
I could not find spooky enough sound effects for the ghosts, so I started recording my own. I'm quite happy with the results! I used Audacity with very minimal manipulation of the voice acted samples. With a dozen takes in a row, one is bound to be good.
I worked with a contributor to revamp the behavior of rats. Mostly, they don't care about you unless you bug them. There is a lot more to come, like the difference between them spotting you from a distance vs being startled when you surprise them.
I upgraded to Godot 4.0-RC2. It's mostly about stability and polish now. The engine is more enjoyable every week.
Since the release, I also added multi-attacks. The result is brutally effective. Some tuning will be needed with this one.
I finished reading Allow of Law: so good! The book is completely action packed with a very interesting and very steampunk magic system (the Wild West flavor of Steampunk).
I started reading Mortal Engines. The steampunk concept is pushed much deeper in Mortal Engines. It's yet another steampunk story that is happening in London, but this is a much much different take on London. It's generally enjoyable, but nowhere close to be as good at Allow of Law. I don't think I will go through the whole series.
We have a bestiary!
I paired with a new contributor to implement monster spawning based on a per-level budget and spawning decks. This keeps the per-level difficulty equal whether there are lots of easy monsters or just a handful of really hard ones. That is of course assuming that we can get the difficulty metric right, which is probably not true right now, but I'm confident that we can get there with the Monte Carlo simulator.
We currently have spawning decks with duplicate "cards" of easy monsters. I want to reimplement those with single instances and the probability value instead. Those will come in handy for many other aspects of procgen.
Lots of minor loose ends to tie this week for the v0.3.0 release. This release features an actual quest that felt surprisingly well balanced despite most of the key numbers coming from wild guesses. It's pretty hard the first few times, then you figure which items are worth picking and it becomes probably winnable 2/3 of the times.
The highlight of the week was implementing healing, both from innate regeneration and from items. Regular items won't heal you more than your full health level, but magical one can go above your max (ahem quest hint ahem). The items design fully embraces the Godot node composition paradigm and quest designers can drag and drop a bunch of effects on their new fancy items.
Next week, the work on the real fun stuff starts: bestiary and/or dialogues.
This was another good week. The highlight is definitely that after installing Godot 4-beta13, I discovered that deploying to Android automatically starts the remote debugger, which makes it ridiculously easy to inspect and manipulate the scene tree of the app running on the phone.
I don't know for sure if it was already there, but I know I will take advantage of it a whole lot more from now on.
I added effects and conditions for damage over time, like poison and being on fire.
I added cheat codes, like teleporting and printing the internal data for a given location, but with the newly discovered power of the Godot remote inspector, those won't be as useful as I originally thought they would be.
I added victory conditions, so you can finally win the game rather than infinitely bashing monsters. I have a few things to tidy up, then I will probably push a v0.3 build to Google Play next week.
I started reading The Alloy of Law by Brian Sanderson. That's is a really interesting take on steampunk magic. As expected from Sanderson, the magic system is very elaborate with obvious limitations. Without spoiling it, some people are born with the ability to influence metals. In medieval time, that was probably cool but not that useful, in the middle of the industrial revolution, that's obviously game changer.
Items: items fade away when stepped over. They can be picked up and dropped. I'm still working on the inventory dialogue. I have something working, but it's kind of clumsy.
Godot supports buttons in tree-view records and it does a good job at dispatching input events to them, but it only allows sprite buttons, so that's a whole new asset class to maintain. I suspect that it's possible to extract the textures from regular buttons to make the whole thing follow the lovingly crafted UI theme, but I have yet to unlock that spell.
Items don't do anything yet, but I have a fairly good idea on how to do that part. It will be very similar to what I had in the Python implementation of Revengate.
Next week: victory conditions; maybe: items for progressions and damage over time.
Revengate 0.2.0 is ready for play testing on Android. That's my first public build of the Godot reimplementation! It doesn't do much, but you can beat up monsters and go infinitely deep in the dungeon. Google Play will probably take a few more days to show the latest version, but the manual APK install is ready for action.
What level did you reach?
As I was making zooming and panning more mobile friendly, I got really confused by how events were processed. It just didn't seem to match the way it was described in the doc, so I set up a few experiments with lots of logging to really understand how Godot handle user input. Here's what I found.
All input events propagate through 3 main phases:
Node_input()in reverse traversal order for all nodes in the scene;
Control._gui_input()for the control (widget) where the click was or for the control with focus in the case of keyboard events;
Node._unhandled_input()in reverse traversal order for all nodes in the scene.
Here, "reverse traversal order" means that we start at the bottom of the screen tree as rendered in the editor and go up one line at a time.
Disabled controls still get
_unhandled_input(). Invisible controls pass
_gui_input() to their parent, they still receive
_unhandled_input(). The SubViewportContainer receives the
_gui_input() for the Viewport.
Control.accept_event() has the same effect: it immediately stops the propagation of an input event. They can be called from any of the
_*input() methods and they do not need a reference to the event. Accepting in the
_gui_input() of a control will block the built-in behavior of that control. For example, calling
MyButton._gui_input() will prevent the
pressed signal from being emitted.
z_index property of a node can make it to show on top of other graphical elements, but it has no effect on which control will receive the
_gui_input. Only the order in the scene tree can change that.
I also ran into an interesting problem: SubViewports do no remap user-defined InputEvents. That forced me to understand Transform2D, a 2x3 matrix that is a property of all Godot Node2D objects. A position vector can be multiplied with it to translate, scale, and rotate it, but since it's the transform to render a Node2D, inverting the matrix is often required depending on which coordinate system you want to remap to.
While pulling my hair (figuratively) to figure in what order I had to apply the above transform, I adopted following convention in all my logging messages:
- pixel coordinates are noted "(x, y)"
- tile coordinates are noted "[x:y]"
This really helped!
I got rid of spacial audio for now, but I like the concept. I will definitely revisit it later.
I started reading Leviathan Scott Westerfeld and Keith Thompson. Now that's one damn good steampunk novel! The main premise of steampunk is that some kind of technological advance came in early during the Victorian era, but everything else about social dynamics is pretty much the same. In Leviathan, you are greeted by bioengineering and Gundam-style mechas during the 19th century. Wow!
I implemented zooming and panning on desktop.
This is a done by combining a viewport with a camera node. The viewport does a good job of remapping the mouse and touch events to its children nodes, but it's not easy to move it around. The camera is easy to move around and it has a zoom property, but it does not offer any support for remapping events, so I only use it to change the center of the screen. The HUD (health bar and other status info) is outside of the dungeon area viewport and is therefore not affected by zooming and panning. This was a beautiful example of where the Godot node tree metaphor works really well.
I though that it would be a trivial matter to have those on Android given the refactor that Godot just merged in for gestures, but for some reason I don't get any of those gesture events with my Android builds. It not too hard to hard to make a gesture detector in GDScript, but that would also involves fine tuning what counts as one gesture rather than another. I'm not going down that rabbit hole right now.
I merged the Godot implementation back into the main git repo. There are still a few design docs that should be updated to clarify if they refer to the Python or to the Godot code.
I added the art nouveau steampunk boot splash. Unfortunately, it does not show on Android, which is probably caused by this issue. The game starts really fast on Android anyway so I probably don't need a boot splash. I will find another way to showcase the artwork without making the players wait for purely aesthetic considerations.
I got a fnatic ministreak keyboard with Kailh silver switches. I took the time to lubricate and install o-rings on all the main keys. I knew this would not be main keyboard since I grew heavily reliant on a trackpoint, but that seemed like a cheaper way to see if I like the silver switches.
In short, I don't like silvers. I like the short travel and the ease of actuating them. For prose, that's actually really awesome. For code, I end up making a lot of typos as my fingers feel the board while searching for non-letter keys like the arrows. That means I end up looking at the board more, which defeats the purpose for me.
Specific to the ministreak, the keyboard does not support multiple layout layers and is not reprogrammable on Linux. That's a show stopper for me. The build is solid otherwise, but spacebar is also too wide for my liking, which forces an exaggerated wrist twist to get the control and alt keys.
This was a really good week. I finished connecting the dungeon levels via stairs. The bugs came from non-active levels still having active physics layers. It turns out that in Godot, to make something disappear, you have to make it invisible and deactivate all of its physics layers. Using the full Godot collision system for Revengate is obviously overkill since everything in the game is a trivial square, but the API is really nice to use, so I'm keeping it for now.
I added all the pre-game screens. The Godot GUI editor is really good! The styling is particularly well done. It's a bit of a click fest, but there is a preview screen and you can click on a control than in will instantly populate all the stylable properties for that control on a new editor pane. This is so much better than editing
.kv files for Kivy UIs.
The Godot layouts play fairly well with multiple resolutions. While Kivy forces you to nest everything into dynamically resizable containers, Godot has a simpler but equally effective approach. Since most controls in a game are around the perimeter of the screen, Godot offers 8 anchor points around the edge and then let's you offset in pixels from there. As it turns out, this resizes beautifully with multiple resolutions and in Revengate's case, it keeps the controls where they should be on a tiny mobile screen. Controls can still be nested into resizable containers, but you don't have to most of the time.
I made the first Android build. It's nice that Godot puts that configuration outside of the main project file. That way, the APK signing key does not end up in git. On the other hand, there are some configs for the Android build that would be nice to have under source control. I haven't figured out yet how to decouple them from the secrets.
After a bit of configuration and 2.5 GBs of dependencies downloaded, I have a one-click option to deploy to any Android phone with USB debugging enabled. Nice!
On this special beautiful cold winter day, may the RNGs bring you all lots of shiny loot.
I worked on connecting the multiple dungeon levels together via stairs. Turns out that Godot 4 has a really good API for attaching data to tiles, but nothing to attach data to cells. It Godot terminology, that means that I can have new arbitrary fields on all the stair cases, but there is nothing to help me record the destination of a specific set of stairs. So back to storing all of that in an internal dictionary like in Godot 3.
I got it working, but alls monsters are behaving weird on freshly created levels. I think they might be colliding with the ghosts of monsters on the upper levels. I need to dig into that this weekend.
I played some Marvel Snap. It's a deck builder by one of the designers of Blizzard Hearthstone. It's pretty good, very fast paced, with a good mobile UI. There are way too many internal resources and currencies, typical of modern free-to-play games, but the deck building part is excellent. That got me thinking: what could deck building games bring to a roguelike (in the dungeon exploration sense, not in the Slay the Spire sense)?
I feel like one key part of what makes deck building fun is that you need to sacrifice. All your cards have big pros and some minor cons, but you can only take 12 of them with you. You need to make an agonizing choice. Having a limited number of slots for armors and rings encapsulates some of that, but most of the time, it's really obvious which helm is the best, so the choice is not agonizing at all. In many games, there is even a green arrow to help you identify which of your helms are better than the one you are currently wearing.
In a true steampunk fashion, I started thinking of a utility belt where you can store tools and crafted clockwork gadgets. The utility belt has limited slots and all the gadgets can go in any of the slots. You can have as many gadgets as you want in your inventory, but only a limited number of active ones on the belt. Gadgets would have a random chance to trigger during combat, then they have a cool down (rewinding?) period. Besides selecting the gadgets, you don't have any control on how they activate, that makes the pre-selection important. Gadgets abilities can be mild (+20% regen for 10 turns) or strong (disable a random foe's electrical abilities for 100 turns). Quick like that, that sounds really fun. It would not be too hard to implement, but the UI component would be critical if you want people to know that their utility belt did the work. On a tiny mobile screen, that could actually be fairly hard. I'm putting this design concept on the back burner for now and I commit to deleting Marvel Snap before the end of next weeks.
I added taps as controls to move and attack. I also implemented the Travel To command.
Like in the Python implementation of Revengate, Travel To creates a Strategy on the Hero and passed the control to it. As long the strategy
is_valid() the game loop calls it rather than querying for input. I really banged my head on my desk to get this to work properly with Kivy and even then, the build I have on Google Play still has the nasty bug that some monster attacks are not detected, causing the hero to keep trying to push his way against a steadfast foe who will invariably beat him to a pulp.
Implementing it with Godot was a breeze. I was careful to have all my signals and state diagrams in front of me and I had the basic case working in less than an hour. I took another day to add the rerouting and cancellation upon being attacked. I cleaned up the main loop a bit in the process and almost completely decoupled the animation synchronization from the turn logic, which should really help when time comes to implement the Monte Carlo simulator.
The new anim synchronization probably does not have enough overlap between actor move animations, which gives a fairly slow feel to the game play. This will need tuning.
I should move the state diagrams to Graphviz. Paper is nice, but it's not very git-friendly.
I saw the new Tex Shura keyboard up for pre-order. I find this one very tempting and I spent some time de-mapping some of the keys on my bigger Shinobi to see what it would feel like to type with a minimal board. It's really not bad, having Home and End right on the home row actually feels quite liberating, even if as a two-key combo.
I added a death animation and integrated the attacks in the monster strategies. I'm playing with some level of overlap between the animations when possible. Before attacking an enemy, I completely wait for them to stop moving otherwise the lunge animation looks off, but for moving around and attacking a stationary enemy, I think it speeds up the game play and it looks really good to have many actors moving all at once.
The tricky part is finding the right level of overlap and Godot Tween animation API is not completely helpful here. I can
await for the
finished signal on an animation and I can query the animation to know how long it's been running, but I can't know much longer it's supposed to take before it's finished. That make's it hard to do something like "overlap 20% of the animations", so I will probably settle for something like "overlap 100ms all the time".
On the plus side, it's awesome to be able to
await anywhere, which passes the control back to the engine for drawing pixels. This is way less convoluted than using callbacks, which is what I had to do most of the time with Kivy.
I typed the story of the encounter with a ghost and a cherub (scroll to "What Lurks in the Crypt", Gitlab is not very good with anchors). This will probably be used as a "cut scene" at the start of a campaign. I tried to hint at silver being a prime choice for fighting ethereal enemies without spelling it out too much.
I figured how to take advantage of inheritable scenes in Godot. They follow a different inheritance tree than classes, which is a bit confusing, but it does make sense after you do a complete mental switch to the Node composition architecture.
I implemented the two-roll combat with most of the animations. I still need a death animation and a way to turn animations off when running the Monte Carlo simulator.
Godot 4.0-beta5 fixed the move animation flickering. Beta6 just came out, I plan on trying it later today.
I wrote a few stories of encounters with ghosts with a bit of a steampunk twist. I have not typed them yet and I still don't know if they will be featured in the game. I feel like coding when sitting in front of a keyboard, so I'm surfing the motivation while it lasts rather than typing handwritten notes.
I can now procedurally generate levels with Godot.
The excellently revamped TileMap editor in Godot4 has a cool API to paint a whole area with a given terrain and it picks tiles of that terrain according to the probabilities that you configured in your
TileSet. It supports transition tiles to connect different terrains, but I have not started playing with that yet.
Next up: stairs and keeping track of previous boards, then randomly placing monsters on a new board.
I feel really good about that Godot rewrite.
Moved to Godot 4. The latest GDScript has a per-element array comparison like Python tuples, which is exactly what I needed to implement multi-metric path finding. The 3-to-4 project importer does not do a very good job, but I had very little code to port so that was not a big deal. I re-implemented Dijkstra and A* in Godot and added path finding to monsters.
Godot has an A implementation, but since everything in my game is grid based, it seemed more natural to have distance discovery inside the algo than having to register all position with Godot's built-in before launching a path finding run, especially since the goal of A is to avoid having to process all the board positions.
There is a bit of animation flickering that started appearing on Godot 4. I'm assuming that this is a remaining bug that will be fixed before 4.0 gets out of beta and I'm ignoring it for now.
I really like the tile map editor in Godot 4. It makes is really easy to have multiple floor and wall tiles and to paint with the tile type, letting the editor randomize the tiles with configurable probabilities. It also makes it easier to attach data to tile types.
I'm ready to start working on procedural level generation next week.
I'm still working on a Godot proof of concept. This was a good week! Turn logic is working. I played with the TileMap editor and got collisions to work, but monsters are still ignoring them.
I still have to get path finding to work properly. Godot as an A* implementation, but its API is a very verbose and you have to register all the positions with distance metrics with it. I think I will re-implement it to take advantage of the simplicity of square tiles. That will also allow me to keep the same API for the Dijkstra level metrics.
Godot has released the last alpha of 4.0. It looks like the final 4.0 could be out before the end of the month. There are two things that I'm really excited about 4.0.
Looks like pinch-to-zoom is going to be almost free after all!
Next week: progen the game boards.
Spent the week exploring Godot again. Got some simple grid-based movement working. I reimplemented some functions from the Python random module in GDScript. The biased choice (like
random.choices()) was surprisingly easy. Having a built-in binary search that tells you the index where the search failed really helped!
The latest Kivy build is still on Google play. You might have to be part of the testing group to see it.
I'm now fairly confident that the above the last Kivy release. I'm going to push hard for something playable with Godot soon, even if it does not quite get to feature parity with the Kivy implementation.
The build from last week is still on Google play. At least one tester confirmed that the Game Over Screen crash is no longer happening. You might have to be part of the testing group to see the test builds since I didn't release the public yet.
I really don't like pushing to Google Play since the builds don't show up until someone from Google goes through the game in depth, which takes about 4 or 5 days. They really play the hell out of this. The last report they game me contained almost 500 screenshots with annotations on which messages were not accessible with a screen reader.
I've been playing with Godot this week and I really like it! I didn't really want to learn a new language and possibly have to rewrite everything, but GDScript is insanely close to Python. It's basically Python without a mark-and-sweep GC and with fixed size numerics to make it easier to pass data to shaders. It's a little more complicated than that, but still very expressive and it feels just right if you happen to like Python.
I got a tiny game going in a little over 2h by following a video tutorial and to my delight, it was a breeze to make it run on Android. It was just a matter to telling Godot where to find the Android libs and what key to use to sign packages. The tiny game takes only 8s to build and deploy on my phone. Nice!
So now I'm putting the Kivy implementation on hold while I actually do a legit rogue-like proof of concept on Godot. If it goes well, I will probably do a rewrite right away rather than pushing for a complete game with Kivy.
Wrote the second long narration: the initiation to steampunk magic. It's a bit too long to be an on-screen narration, so I'm not sure how to use it yet, but I really like the vibes that it sets.
Fixed the game over crash. This one was a segfault, so very little useful info to help me nail it down. It turns out that Kivy does not support changing screens while animations are in progress. There is no easy way to synchronize async animations so the only thing I could come up with is sleeping for a little bit.
I started learning about Godot. It's a bit of a click fest, but the amount of eye candy you can get for free is really appealing. I'm fairly convince that I will want to reimplement the game on top of it, but I want to make a few proof of concepts first.
The Google Play page is finally live! You might have to join the testers group to see it since the app in still in pre-lauch mode. The build that made its way through the approval process is fairly buggy. I will upload something better in the next day or two.
If anyone tries it despite my above warning, there is a feedback survey.
What should be the icon for loot/pick-up? Ideally it would be a Material Design Icon.
Wrote the intro narration on how the hero gets recruited by Lux Co. It's missing a few paragraphs, but the key elements are there.
Besides learning how Google Play actually works, I fixed a few crashed and I worked on items as progression. There are no XPs in Revengate and I really like it that way. My goal is to provide plenty of items that will boost your stats in various ways, with quest items being more significant than regular loot. This should discourage grinding while still keeping a sense of accomplishment.
The game over screen bug keeps eluding me. I am pretty sure I will have to move away from Kivy sooner or later. Current contenders for the replacement include Godot and Qt, but I want to have at least v0.4 (two full campaigns) up before I start a tech stack change.
I spent most of the week chasing a weird bug where the game-over popup sometimes does not disappear. I think that this is a Kivy bug, because I tried pretty much everything and it still happens about 25% of the time. I'm officially giving up: Game Over will be a separate screen for now.
Because I spend too much time on the Game Over bug, I didn't get to clean things up to do an actual play test, but I pushed a tech demo build to Google Play.
Let me know if you try it out!
I got a new laptop and I actively procrastinated by setting it up and fine tuning everything.
I did a few minor changes to the Revengate mobile experience, like adding a scroll bar for the monster descriptions. I will be uploading the first play test to Google play as soon as I find out why the Game Over popup does not disappear after you click OK, sometime later today or tomorrow. I will update this post with the link once this is done.
Plan for next week
- scroll bars for monster description
- bug: selection sometimes triggers the travel-to command
- upload a play test on Google Play
I got caught in a wave of low motivation when I started re-aligning stuff on Android. I can repro some of those by shrinking my window on desktop, but for the most part I need to push a new build to my phone, which takes about 30 seconds, just enough for me to zone out. There was also an ugly bug where one move animation from the previous turn would get mixed up with the attack animation in the current turn and the retreat from the attack would place the attacker in-between tiles. I already knew that about myself, but it was made very obvious in the past two weeks: I really enjoy working on combat mechanics, fine tuning pixel placements, not so much.
I the end I overcame the slump by booking a pair coding session to work on the healing animations with my friend Angel Hudgins. That fuel me with enough energy to work on some the pixels stuff in the second half of this week.
The game is still fairly rough, but I'm happy uploading for a broader play test sometime next week. I will probably fix a few easy bugs before that, but I really want to have something out before the end of the week.
Last two week's summary
- wrote an online survey for play testers
- re-aligned labels on mobile
- healing animation: absorbing HP numbers rather than having them fly off
- bug fix: animating anything related to an already dead actor would crash the game
- better control of the overlap between movement animations of NPCs through explicit synchronization. Currently set to 70% overlap.
- resolved the traffic jam problem using Dijkstra walkability metrics
First time posting here. For the past several months, I've been working on Revengate, a rogue-like for Android written in Python. The game is set in a steampunk version of mid-19 century France with deep conflicts between magic and technology.
There are many unique monsters that are going to be featured with low resolution images or ascii characters during the normal game play. To increase the immersion without significantly increasing the development complexity, there is also a comprehensive bestiary with more detailed depictions of the monsters, something along the lines of the D&D Monster Manual or the Pathfinder Bestiary.
The game code is free and open source software under the GNU GPL v.3. The artwork uses a variety of GPL-compatible licences, mostly Creative Commons.
There are still many rough edges, but the game should be playable for a single dungeon in a week or two. Actual campaigns will come later.
2022-07-11 – 2022-07-15
-[x] travel-to command -[x] Dijkstra metrics
Travel-to is implemented using the same strategies that already control NPCs. When starting a multi-turn action like travel-to, I give a strategy to the player character. The engine is perfectly happy to control any actors who are equipped with valid strategies. It's like magic! But I probably need to add a pauses because things happen a bit too fast when I let the engine take care of everything. Path finding is done with A*.
I had a problem with wandering NPCs causing permanent traffic jams if two of them were trying to reach their way points in opposite directions in a narrow corridor. I figured that the best way to solve that would be to discover a new waypoint that is guaranteed to be reachable. I'm doing that with the Dijkstra algorithm, which was fun to implement and will come handy for a for other things.
Here are few highly connected mazes followed by their Dijkstra metrics. The number is the least significant digit of the distance to the origin (bottom left). Note how diagonals correctly cost only one. The Revengate maze generator can be biased for more or less twistiness as well as for reconnecting corridors to decrease the maze difficulty.
.....................................▓...............................▓...................................▓.............▓ .▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓▓▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓.▓ .................▓...▓...........▓.▓.▓.▓...............▓.▓.▓.........▓...▓.▓.....▓.............▓...▓.▓.▓.▓...........▓.▓ .▓▓▓▓▓▓▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓▓▓.▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓▓▓.▓.▓.▓.▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓ .........▓.......▓.▓.▓.▓.....▓.▓.▓.▓.▓.............▓.▓.▓.▓.▓.......▓.....▓.▓.▓.....▓.▓...▓.▓...▓.▓.▓...▓.▓.▓...........▓ .▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓.▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓▓▓.▓.▓▓▓▓▓.▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓▓▓.▓.▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓ .▓.................▓...▓.▓.▓.▓...................▓.▓.▓.......▓...▓.▓.....▓.▓.▓.......▓...▓.▓.▓...▓.....▓.▓.▓.▓.......▓.▓ .▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓▓▓.▓.▓.▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓▓▓.▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓▓▓.▓.▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓.▓.▓ .▓.▓.....................▓.▓.▓.▓.▓.▓...........▓.▓.▓.........▓.........▓.▓.▓.▓.▓.........▓.▓.......▓...▓...........▓.▓.▓ .▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓.▓.▓.▓.▓.▓ .▓.▓.▓.....................▓.▓.▓.............▓.▓.▓.▓.......▓.......▓...▓.▓.▓.▓...▓...▓...............▓.........▓.▓.▓.▓.▓ .▓.▓.▓▓▓.▓▓▓▓▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓▓▓▓▓.▓▓▓▓▓▓▓.▓▓▓.▓.▓.▓.▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓ .▓.▓.........▓.▓.....▓.......▓.▓...........▓.▓.▓.▓.▓.▓...▓.......▓...▓...▓.▓.......▓.▓.▓.......▓.....▓.▓.........▓.▓.▓.▓ .▓.▓▓▓.▓.▓▓▓.▓.▓.▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓.▓.▓.▓▓▓▓▓.▓▓▓.▓.▓▓▓.▓▓▓.▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓.▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓ .▓.▓.▓.▓...▓.▓.▓.▓.▓.▓.▓.....▓...............▓.▓.▓.▓.▓.▓.▓.▓.......▓.▓...▓...▓.▓.....▓.▓.......▓.▓.▓.▓.▓.........▓.▓.▓.▓ .▓.▓.▓.▓▓▓▓▓.▓.▓.▓.▓.▓.▓▓▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓.▓▓▓▓▓.▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓▓▓▓▓▓▓.▓▓▓.▓.▓ .▓.▓.▓.....▓.▓.▓.▓.▓.▓.....▓.▓...............▓.▓.▓...▓...▓...........▓.▓.▓.▓.▓.▓...............▓.▓.▓.▓.▓.......▓.....▓.▓ .▓.▓.▓▓▓▓▓.▓.▓▓▓.▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓ .▓.▓.....▓.▓.....▓.▓.▓.....▓.▓.▓...........▓.▓.▓.▓.▓...▓.....▓...▓.....▓.▓...▓...................▓.▓.▓.................▓ .▓.▓.▓.▓▓▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓.▓.▓.▓▓▓.▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓.▓▓▓.▓.▓.▓▓▓.▓▓▓.▓.▓▓▓▓▓▓▓▓▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓▓▓ .▓.▓.▓.▓...▓.▓.....▓.▓.▓.▓.▓.▓.▓...▓.........▓.▓.▓.▓.▓.▓.▓.....▓...▓...▓.▓.........................▓.▓.....▓.......▓...▓ .▓.▓.▓.▓.▓.▓.▓▓▓▓▓.▓.▓.▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓▓▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓.▓▓▓.▓▓▓.▓ .▓.▓.▓.▓.▓.........▓.▓.▓...▓.▓.................▓.▓.▓.▓.▓.........▓...▓.▓.............▓.......▓.......▓.▓.............▓.▓ .▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓▓▓▓▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓▓▓▓▓▓▓▓▓.▓▓▓▓▓▓▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓ ...▓.................▓...........................▓...▓.................▓.......................▓.......................▓ 4456789012345678900999012345678901234▓2109876543210987778901234567890▓77890123456789012345678901234567890▓5567890123455▓ 3▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓8▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓4▓2▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓6▓8▓0▓2▓▓▓▓▓▓▓▓▓6▓▓▓0▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓6▓▓▓▓▓▓▓▓▓6▓8▓0▓4▓▓▓▓▓▓▓▓▓▓▓4▓ 22345678889012345▓778▓43210987655▓3▓5▓3▓334567890123456▓9▓1▓334567890▓655▓1▓09877▓4321098777890▓334▓7▓9▓1▓33456789012▓3▓ 1▓▓▓▓▓▓▓7▓▓▓▓▓▓▓5▓6▓▓▓4▓▓▓▓▓▓▓▓▓4▓2▓6▓▓▓2▓▓▓▓▓▓▓▓▓▓▓4▓6▓0▓2▓▓▓▓▓▓▓▓▓0▓▓▓4▓2▓0▓▓▓6▓4▓2▓▓▓▓▓8▓▓▓0▓2▓4▓8▓0▓2▓2▓4▓▓▓▓▓▓▓▓▓2▓ 001234567▓1098766▓5▓9▓5▓55678▓5▓3▓1▓7▓1123456789012▓5▓7▓1▓3▓5567890▓11234▓3▓1▓87655▓3▓112▓9▓211▓1▓3▓990▓3▓1▓33456789012▓ 9▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓4▓8▓6▓4▓6▓8▓4▓2▓0▓8▓0▓▓▓▓▓▓▓▓▓▓▓2▓6▓8▓▓▓2▓4▓▓▓▓▓0▓▓▓▓▓▓▓4▓2▓▓▓▓▓▓▓4▓0▓▓▓0▓2▓▓▓0▓2▓▓▓▓▓4▓0▓2▓▓▓▓▓▓▓▓▓2▓ 8▓99012345678901234▓877▓3▓7▓9▓4321099901234567890▓3▓7▓9901234▓778▓1▓87655▓5▓3▓0987655▓099▓1▓3▓099▓11234▓5▓9▓1▓3345678▓3▓ 7▓8▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓8▓▓▓2▓8▓0▓4▓2▓0▓0▓▓▓▓▓▓▓▓▓▓▓0▓4▓8▓▓▓▓▓▓▓▓▓6▓▓▓2▓▓▓▓▓4▓6▓4▓0▓▓▓▓▓▓▓▓▓8▓2▓4▓▓▓8▓0▓▓▓▓▓6▓8▓0▓2▓▓▓▓▓8▓4▓ 6▓7▓432109876543210999012▓9▓1▓5▓3▓1▓11234567890▓1▓5▓990123456▓654333456▓3▓7▓5▓1▓432109877▓3▓5567890▓556▓77890123456▓9▓5▓ 5▓6▓4▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓0▓2▓6▓4▓▓▓▓▓▓▓▓▓▓▓▓▓0▓2▓6▓▓▓▓▓▓▓4▓6▓▓▓▓▓▓▓▓▓6▓2▓8▓6▓2▓4▓▓▓▓▓▓▓6▓4▓▓▓▓▓▓▓▓▓4▓6▓▓▓▓▓▓▓2▓4▓6▓0▓6▓ 4▓5▓5▓211123432109876543211▓3▓7▓5567890123456▓1▓3▓7▓9987655▓7789012▓877▓1▓9▓7▓334▓334▓876555678901234▓778901233▓5▓7▓1▓7▓ 3▓4▓6▓▓▓0▓▓▓▓▓2▓▓▓▓▓▓▓▓▓▓▓▓▓4▓8▓▓▓▓▓▓▓▓▓▓▓▓▓6▓2▓4▓8▓8▓▓▓▓▓6▓▓▓▓▓▓▓2▓▓▓8▓0▓0▓8▓▓▓▓▓2▓4▓8▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓8▓▓▓▓▓▓▓▓▓6▓8▓2▓8▓ 2▓3▓778901234▓3▓77890▓0987655▓9▓09876543211▓7▓3▓5▓9▓7▓990▓7789012▓334▓990▓1▓9999012▓3▓9▓7789012▓55655▓9▓432109877▓9▓3▓9▓ 1▓2▓▓▓8▓0▓▓▓4▓4▓6▓▓▓0▓0▓▓▓▓▓6▓0▓▓▓▓▓▓▓▓▓▓▓0▓8▓4▓6▓0▓6▓8▓0▓8▓▓▓▓▓2▓▓▓4▓0▓▓▓2▓▓▓8▓▓▓▓▓2▓0▓6▓▓▓▓▓▓▓4▓▓▓4▓0▓4▓▓▓▓▓▓▓▓▓0▓4▓0▓ 0▓1▓3▓9▓112▓5▓5▓5▓3▓1▓1▓09877▓112345678901099▓5▓7▓1▓5▓7▓1▓9▓6543334▓5▓112▓334▓7▓99012▓1▓6543211▓3▓7▓3▓1▓556789011▓1▓5▓1▓ 9▓0▓2▓0▓▓▓▓▓6▓6▓4▓2▓2▓2▓▓▓8▓8▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓0▓6▓8▓2▓4▓6▓▓▓0▓▓▓▓▓▓▓▓▓6▓2▓2▓4▓4▓6▓8▓▓▓▓▓2▓▓▓▓▓▓▓0▓2▓6▓2▓2▓6▓▓▓▓▓▓▓0▓▓▓6▓2▓ 8▓9▓1▓11234▓7▓7▓3▓1▓3▓21099▓9▓432109876543211▓7▓9▓334▓655▓11234567877▓3▓3▓5▓5▓5▓876543334567890▓1▓5▓1▓3▓7789012▓09877▓1▓ 7▓8▓0▓▓▓▓▓4▓8▓▓▓2▓0▓4▓▓▓▓▓▓▓0▓4▓▓▓▓▓▓▓▓▓▓▓▓▓2▓8▓0▓4▓▓▓▓▓4▓▓▓▓▓▓▓▓▓▓▓▓▓4▓4▓6▓6▓4▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓0▓4▓0▓4▓▓▓▓▓▓▓▓▓▓▓▓▓8▓0▓ 6▓7▓99012▓5▓99012▓9▓5▓77890▓1▓5▓65433345678▓3▓9▓1▓5▓778▓33211▓877▓87655▓5▓777▓4321099901234567890▓3▓9▓55678901234567890▓ 5▓6▓8▓0▓▓▓6▓0▓▓▓▓▓8▓6▓6▓▓▓0▓2▓6▓6▓▓▓2▓▓▓▓▓▓▓4▓0▓2▓6▓6▓8▓2▓▓▓0▓8▓6▓▓▓6▓▓▓6▓8▓▓▓▓▓▓▓▓▓8▓▓▓▓▓▓▓▓▓▓▓▓▓2▓8▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓8▓▓▓ 4▓5▓7▓1▓877▓1▓09877▓5▓5▓5▓1▓3▓7▓778▓210987655▓1▓3▓7▓5▓9▓1▓21099▓655▓778▓7▓9901234567890123456789012▓7▓11234▓7789012▓990▓ 3▓4▓6▓2▓8▓8▓0▓▓▓▓▓6▓4▓4▓4▓2▓4▓8▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓2▓4▓8▓4▓0▓0▓▓▓▓▓▓▓▓▓4▓▓▓8▓8▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓6▓▓▓▓▓▓▓6▓0▓▓▓4▓6▓8▓▓▓2▓▓▓0▓ 2▓3▓5▓3▓9▓990123456▓3▓3▓433▓5▓87654321098765433▓5▓9▓3▓1▓098765433▓433▓9▓9901234567890▓2109877▓1123456▓9▓6555678901234▓1▓ 1▓2▓4▓▓▓0▓▓▓▓▓▓▓▓▓▓▓2▓2▓▓▓▓▓6▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓6▓0▓2▓2▓▓▓▓▓▓▓▓▓2▓4▓2▓0▓▓▓▓▓▓▓▓▓▓▓▓▓0▓▓▓▓▓▓▓▓▓0▓▓▓▓▓▓▓8▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓2▓ 012▓43211123456789012▓210987654321098765432109877▓112▓33456789012343211▓21098765432111234567890▓43210987654321098765433▓
.....................▓.....▓.....▓...▓...▓...............▓...▓...▓...▓.......▓...▓...............▓.......▓.............▓ .▓▓▓▓▓.▓▓▓▓▓.▓.▓▓▓.▓.▓.▓▓▓.▓.▓.▓.▓.▓.▓.▓▓▓.▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓.▓.▓▓▓.▓.▓.▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓.▓ .▓...▓...▓.........▓.▓...▓.▓.▓.▓.▓.▓.▓.......▓.....▓.....▓.▓.▓.▓...▓...▓...▓...▓.......▓...▓.▓.....▓...▓.▓.▓.....▓.▓...▓ .▓.▓.▓▓▓▓▓.▓▓▓.▓▓▓▓▓.▓▓▓.▓.▓.▓.▓.▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓.▓.▓▓▓.▓▓▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓▓▓.▓.▓▓▓▓▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓ .▓.▓...▓...▓...▓...▓.....▓.▓.▓.▓...▓...▓.▓.........▓...▓.▓.▓...........▓.▓...▓.▓...▓.▓.▓.......▓.▓.▓.....▓.......▓.....▓ .▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓▓▓.▓▓▓.▓▓▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓.▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓.▓ .▓...▓...▓.▓.....▓...▓...▓...........▓.▓.▓.......▓...▓.▓.▓.......▓.▓.▓...▓.▓.▓.▓...▓.▓.▓.▓.....▓...▓.......▓...▓.....▓.▓ .▓▓▓.▓▓▓▓▓▓▓.▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓.▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓▓▓.▓▓▓.▓.▓.▓▓▓.▓▓▓.▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓▓▓▓▓.▓.▓▓▓.▓.▓ .▓.▓.........▓...▓.▓.▓.................▓.........▓.....▓.▓.▓.....▓.▓...▓...▓.....▓...▓.....▓...▓...▓...▓.....▓.▓.▓.....▓ .▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓.▓.▓▓▓.▓▓▓.▓▓▓.▓.▓▓▓.▓▓▓▓▓.▓.▓▓▓.▓▓▓.▓▓▓▓▓.▓.▓▓▓▓▓▓▓.▓▓▓▓▓.▓.▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓ .▓.....▓.......▓.....▓.....▓.▓.▓.....▓.........▓...▓.▓.▓...▓...▓.▓...▓.....▓.▓.....▓...▓.....▓...▓...▓.▓.▓.▓.▓.▓.......▓ .▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓▓▓.▓.▓.▓.▓.▓▓▓.▓▓▓.▓.▓▓▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓.▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓.▓.▓.▓▓▓▓▓▓▓.▓ ...▓.▓.▓.▓.............▓...▓.▓.▓...▓.....▓...▓...▓.▓.....▓.....▓...▓...▓.▓.▓...▓.▓...▓.▓.......▓.▓.▓...▓.......▓.▓.....▓ .▓.▓.▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓▓▓▓▓.▓▓▓▓▓.▓▓▓.▓.▓.▓.▓.▓▓▓.▓▓▓▓▓.▓▓▓▓▓▓▓.▓.▓.▓▓▓.▓.▓.▓▓▓.▓.▓▓▓.▓.▓▓▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓.▓▓▓.▓.▓▓▓.▓ .▓.▓.▓.▓.▓...▓.▓.▓...▓.▓...▓...........▓.▓.▓.▓.▓.......▓.........▓.▓.....▓...▓...▓.....▓.▓...▓...▓.▓.......▓.....▓.▓...▓ .▓.▓.▓.▓.▓.▓.▓.▓.▓.▓▓▓.▓.▓.▓▓▓.▓▓▓.▓▓▓▓▓.▓▓▓.▓.▓.▓▓▓.▓.▓▓▓▓▓▓▓.▓▓▓▓▓▓▓.▓.▓▓▓▓▓▓▓.▓▓▓▓▓.▓▓▓.▓.▓.▓▓▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓▓▓.▓ ...▓.▓.▓...▓.▓...▓...▓.▓.▓.....▓...▓.........▓.▓.▓...▓.▓...▓...▓.................▓.........▓...▓...▓.....▓.▓.....▓.....▓ .▓▓▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓▓▓▓▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓.▓.▓ .▓.....▓.....▓...▓.....▓...▓...▓.........▓.▓...▓.▓.▓.▓...▓.......▓.▓...▓.......▓.......▓...▓.....▓.......▓.▓.▓.....▓.▓.▓ .▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓.▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓▓▓.▓▓▓.▓.▓▓▓.▓▓▓.▓.▓.▓.▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓▓▓.▓▓▓.▓ .▓.........▓...▓.▓.▓.......▓...........▓.▓.▓.▓...▓...▓.......▓.....▓...▓.▓.▓.▓.▓.▓.▓...▓.▓...▓.▓...▓...▓.▓.........▓...▓ .▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓▓▓▓▓.▓▓▓.▓▓▓.▓.▓.▓.▓.▓.▓.▓.▓.▓.▓.▓.▓.▓▓▓.▓▓▓.▓.▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓▓▓.▓.▓ ...........▓.▓.▓.▓...▓...▓.....▓.....▓...▓.▓.▓.▓...▓.....▓.▓.▓.....▓.▓.▓.▓...▓.▓.▓.▓.▓...▓...▓.▓...▓.▓.▓.......▓...▓.▓.▓ ▓▓▓▓.▓▓▓▓▓▓▓.▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓▓▓▓▓▓▓.▓.▓.▓▓▓.▓.▓.▓.▓.▓▓▓.▓.▓.▓.▓.▓.▓▓▓▓▓.▓.▓▓▓.▓.▓.▓▓▓.▓▓▓▓▓.▓.▓.▓ .....▓.........▓.......▓.....▓...▓...........▓...▓.............▓.....▓...▓.....▓.....▓.▓.......▓.....▓.....▓.........▓.▓ 990123456789012345678▓77890▓43334▓112▓334▓876555654321099▓778▓556▓990▓3345433▓099▓099901234567890▓0987655▓9901099901234▓ 8▓▓▓▓▓4▓▓▓▓▓0▓2▓▓▓6▓8▓6▓▓▓0▓4▓2▓4▓0▓2▓2▓▓▓8▓▓▓4▓▓▓▓▓▓▓▓▓8▓6▓8▓4▓6▓8▓0▓2▓▓▓4▓2▓0▓8▓▓▓8▓▓▓2▓▓▓6▓▓▓0▓▓▓8▓▓▓4▓8▓▓▓▓▓8▓0▓▓▓4▓ 7▓099▓556▓211123456▓9▓655▓1▓5▓1▓5▓9▓1▓2109990▓43211▓55678▓5▓9▓3▓777▓112▓655▓211▓8777890▓334▓7▓21112▓990▓3▓7▓09877▓1▓655▓ 6▓0▓8▓▓▓▓▓2▓▓▓2▓▓▓▓▓0▓▓▓4▓2▓6▓0▓6▓8▓0▓▓▓0▓▓▓▓▓▓▓▓▓0▓4▓▓▓8▓4▓0▓2▓▓▓6▓▓▓▓▓6▓▓▓▓▓▓▓▓▓6▓8▓0▓▓▓4▓6▓▓▓▓▓2▓▓▓▓▓2▓6▓▓▓▓▓6▓2▓▓▓▓▓ 5▓1▓877▓433▓433▓433▓11234▓3▓7▓9▓778▓099▓1▓334567890▓334▓9▓3▓11234567890▓7▓334▓1▓556▓9▓1▓6555678▓7▓3▓99012▓6555556▓33455▓ 4▓2▓▓▓6▓4▓4▓▓▓▓▓4▓2▓▓▓▓▓▓▓4▓▓▓8▓▓▓▓▓▓▓8▓2▓2▓▓▓▓▓▓▓▓▓2▓4▓0▓2▓▓▓▓▓▓▓6▓8▓▓▓6▓2▓4▓0▓4▓▓▓0▓2▓6▓▓▓▓▓8▓6▓4▓8▓▓▓▓▓▓▓4▓4▓▓▓▓▓▓▓4▓ 3▓334▓655▓5▓87655▓211▓556▓55678901234▓7▓3▓2109877▓112▓3▓1▓2109877▓7▓9▓556▓1▓5▓9▓433▓1▓3▓7▓99099▓655▓8765556▓433▓65433▓3▓ 2▓▓▓4▓▓▓▓▓▓▓8▓▓▓▓▓2▓0▓4▓▓▓▓▓▓▓▓▓▓▓2▓▓▓6▓▓▓▓▓▓▓▓▓6▓0▓▓▓2▓2▓2▓▓▓▓▓6▓8▓▓▓4▓▓▓0▓6▓8▓▓▓2▓▓▓4▓6▓8▓▓▓0▓6▓6▓▓▓▓▓4▓▓▓▓▓2▓6▓▓▓2▓2▓ 1▓1▓554321099▓334▓3▓9▓43210987654333456▓990123456▓99012▓3▓3▓33456▓9▓334▓990▓77890▓112▓55678▓211▓777▓112▓33211▓1▓5▓43211▓ 0▓0▓▓▓▓▓▓▓▓▓▓▓2▓4▓▓▓8▓4▓▓▓▓▓8▓6▓▓▓▓▓4▓6▓8▓0▓▓▓4▓▓▓8▓▓▓2▓4▓▓▓2▓▓▓▓▓0▓2▓▓▓8▓▓▓8▓▓▓▓▓0▓2▓▓▓▓▓▓▓2▓▓▓▓▓8▓0▓2▓2▓▓▓0▓0▓4▓▓▓▓▓0▓ 9▓09990▓7789012▓55678▓55678▓9▓7▓87655▓778901234▓778▓7▓3▓556▓211▓7▓112▓77890▓9▓77890▓333▓99012▓211▓990▓3▓1▓9▓9▓9▓4321099▓ 8▓▓▓8▓0▓6▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓8▓0▓8▓8▓▓▓▓▓▓▓▓▓0▓▓▓4▓6▓8▓6▓4▓▓▓6▓▓▓0▓6▓▓▓▓▓6▓▓▓0▓▓▓6▓▓▓0▓2▓2▓8▓▓▓▓▓2▓0▓▓▓▓▓4▓0▓8▓8▓8▓▓▓▓▓▓▓8▓ 777▓7▓1▓5▓8765432109877▓099▓1▓9▓990▓65556▓112▓556▓9▓65556▓77890▓556▓556▓5▓1▓556▓5▓112▓1▓8765433▓9▓7▓655▓0987778▓7▓99877▓ 6▓6▓6▓2▓4▓▓▓▓▓4▓2▓▓▓▓▓6▓▓▓▓▓2▓▓▓▓▓0▓▓▓4▓6▓2▓2▓6▓▓▓0▓▓▓▓▓6▓▓▓▓▓▓▓4▓6▓4▓▓▓4▓2▓4▓▓▓4▓2▓▓▓0▓8▓▓▓▓▓▓▓8▓6▓6▓▓▓▓▓▓▓6▓▓▓6▓8▓▓▓6▓ 5▓5▓5▓3▓3▓099▓5▓3▓556▓5▓099▓33432111234▓7▓3▓3▓7▓2111234▓778901234▓7▓43334▓334▓433▓21099▓9▓334▓778▓5▓7789012▓55655▓7▓655▓ 4▓4▓4▓4▓2▓0▓8▓6▓4▓4▓▓▓4▓0▓8▓▓▓4▓▓▓2▓▓▓▓▓8▓▓▓4▓8▓2▓▓▓2▓4▓▓▓▓▓▓▓2▓▓▓▓▓▓▓2▓4▓▓▓▓▓▓▓2▓▓▓▓▓8▓▓▓2▓4▓6▓▓▓4▓▓▓▓▓▓▓2▓4▓▓▓4▓6▓▓▓4▓ 334▓3▓5▓211▓7▓655▓333▓3▓1▓87655▓433▓210987655▓9▓3▓433▓5▓990▓433▓77890123456789012▓877789012▓556▓334▓43211▓3▓43334▓65433▓ 2▓▓▓2▓▓▓▓▓▓▓6▓6▓▓▓2▓2▓2▓2▓▓▓▓▓6▓4▓▓▓▓▓▓▓▓▓▓▓▓▓0▓4▓4▓▓▓6▓8▓0▓▓▓▓▓6▓▓▓▓▓2▓▓▓▓▓▓▓0▓2▓▓▓6▓▓▓▓▓▓▓▓▓▓▓2▓4▓▓▓▓▓0▓4▓4▓2▓▓▓▓▓4▓2▓ 1▓43211▓33456▓777▓21112▓334▓877▓556789012▓3▓211▓5▓5▓9▓778▓1123456▓1▓433▓5543211▓3345678▓877▓43211▓5567890▓5▓5▓11234▓5▓1▓ 0▓▓▓▓▓0▓2▓▓▓▓▓8▓6▓2▓0▓▓▓▓▓4▓8▓▓▓▓▓▓▓▓▓0▓2▓2▓2▓▓▓6▓6▓8▓▓▓8▓▓▓▓▓▓▓▓▓0▓4▓▓▓4▓▓▓2▓2▓▓▓4▓▓▓8▓8▓6▓4▓2▓0▓▓▓▓▓▓▓0▓6▓▓▓0▓▓▓4▓▓▓0▓ 9▓778901211▓099▓5▓3▓0987655▓99012343211▓3▓1▓3▓877▓778▓0999012▓99990▓556▓3▓7▓3▓3▓3▓5▓099▓9▓655▓3▓099▓099▓1▓778901234▓990▓ 8▓6▓▓▓▓▓▓▓0▓0▓▓▓4▓2▓0▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓2▓2▓4▓0▓4▓8▓▓▓▓▓8▓▓▓0▓▓▓2▓8▓8▓0▓4▓6▓2▓6▓4▓4▓2▓6▓0▓▓▓0▓▓▓6▓4▓▓▓8▓0▓8▓▓▓▓▓▓▓▓▓2▓▓▓8▓0▓ 87655567890▓1▓5▓3▓211▓655▓21099▓65433▓334▓9▓5▓9▓334▓99011▓7▓3▓77890▓3▓7▓1▓655▓5▓1▓7▓1▓211▓877▓5▓778▓1▓7▓4321112▓334▓7▓1▓ ▓▓▓▓4▓▓▓▓▓▓▓2▓4▓2▓▓▓▓▓6▓4▓2▓▓▓8▓6▓▓▓▓▓▓▓▓▓8▓6▓0▓2▓4▓▓▓▓▓▓▓6▓4▓6▓▓▓0▓2▓8▓0▓6▓▓▓6▓0▓8▓0▓2▓2▓▓▓▓▓6▓6▓▓▓2▓6▓4▓▓▓0▓▓▓▓▓4▓6▓2▓ 01234▓876543334▓2109877▓43334▓877▓65432109877▓112▓5567898765556▓21112▓990▓77877▓09990▓3▓2109877▓65433▓65556▓098765556▓3▓
...............▓.......▓.........▓.................▓.......▓.......▓.......▓.............▓.▓.........................▓.▓ .▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓.▓▓▓.▓.▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓▓▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓▓▓.▓.▓.▓.▓ ...▓...▓.....▓...▓...▓.....▓.▓.▓...▓...▓.....▓...▓...▓...▓...▓...▓...▓...▓...▓.▓.....▓.........▓...▓...▓...▓...▓...▓...▓ .▓.▓.▓.▓.▓▓▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓.▓.▓.▓▓▓.▓.▓▓▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓▓▓.▓▓▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓▓▓.▓▓▓.▓ .▓...▓...▓...▓...▓...▓.▓.......▓.▓...▓...▓...▓.▓...▓...▓.....▓.....▓...▓...▓.....▓...▓...▓...▓...▓...▓.......▓.....▓...▓ .▓▓▓▓▓▓▓▓▓.▓▓▓.▓.▓▓▓.▓.▓.▓▓▓.▓▓▓.▓▓▓▓▓▓▓▓▓▓▓.▓▓▓▓▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓.▓▓▓▓▓▓▓.▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓.▓▓▓▓▓.▓.▓.▓▓▓▓▓.▓.▓ .▓...▓...▓...▓...▓.......▓.....▓.........▓...▓.........▓...▓...▓.▓...▓...▓...▓...▓.....▓...▓...▓.......▓...▓.......▓...▓ .▓.▓.▓.▓.▓▓▓.▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓▓▓.▓▓▓▓▓.▓.▓.▓▓▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓.▓.▓.▓▓▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓ ...▓...▓...▓.▓...▓...▓.▓...▓.....▓...........▓...▓...▓.......▓...▓...▓...▓.▓...▓.▓.▓.▓...........▓.▓...▓...▓.▓.▓.▓.....▓ .▓▓▓▓▓▓▓.▓.▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓.▓▓▓.▓▓▓.▓.▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓.▓.▓▓▓.▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓▓▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓▓▓▓▓ ...▓.....▓...▓...▓...▓...▓...▓.▓...▓...▓...▓.▓...▓...▓.▓.......▓...▓.........▓...▓...▓.▓...▓...▓...▓...▓.▓...▓...▓...▓.▓ ▓▓.▓.▓▓▓.▓▓▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓.▓▓▓.▓▓▓.▓.▓.▓.▓▓▓▓▓.▓.▓.▓▓▓▓▓.▓▓▓▓▓▓▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓.▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓.▓▓▓.▓▓▓.▓.▓ ...▓...▓...▓...▓.▓...▓...▓...▓.....▓...▓...▓.......▓.▓...▓...▓.......▓.....▓.▓.▓.▓.....▓...▓.▓...▓.▓.....▓...▓...▓.▓...▓ .▓▓▓▓▓.▓▓▓.▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓▓▓.▓▓▓.▓.▓▓▓▓▓.▓▓▓.▓.▓▓▓.▓▓▓.▓▓▓.▓.▓.▓.▓▓▓.▓.▓.▓.▓.▓▓▓▓▓.▓▓▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓▓▓.▓.▓.▓▓▓.▓ ...▓...▓...▓...▓...▓.......▓...▓...▓...▓...▓...▓...▓...▓.▓.....▓...▓.▓.....▓.▓...▓...▓...▓.▓.....▓...▓.....▓.........▓.▓ ▓▓.▓.▓▓▓.▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓▓▓.▓▓▓▓▓.▓▓▓▓▓.▓▓▓▓▓.▓.▓.▓▓▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓▓▓.▓▓▓.▓.▓▓▓.▓.▓.▓▓▓.▓▓▓.▓▓▓▓▓.▓▓▓▓▓▓▓▓▓.▓ ...▓.▓.▓...▓.....▓.....▓...▓...▓...▓...▓.......▓.......▓.....▓.......▓...▓...▓...▓...▓...▓.▓.▓...▓.▓...........▓...▓...▓ .▓▓▓.▓.▓▓▓.▓.▓▓▓▓▓▓▓.▓▓▓.▓.▓.▓▓▓▓▓.▓▓▓.▓▓▓▓▓▓▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓.▓.▓.▓.▓▓▓.▓▓▓▓▓.▓.▓.▓▓▓.▓.▓.▓▓▓.▓.▓▓▓.▓.▓.▓.▓▓▓ .▓...▓...▓...▓...▓...▓...▓...▓...▓...▓...▓...▓...▓...▓.....▓...▓...▓.....▓.▓.▓.▓.▓...▓...▓.....▓...▓...▓.▓...▓...▓...▓.▓ .▓.▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓▓▓.▓▓▓▓▓▓▓.▓▓▓▓▓.▓▓▓.▓.▓.▓▓▓.▓▓▓.▓▓▓.▓.▓.▓▓▓.▓▓▓.▓▓▓▓▓.▓▓▓.▓.▓▓▓.▓.▓▓▓.▓▓▓▓▓.▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓.▓ .......▓...▓...▓.....▓...▓.....▓...▓...▓...▓...▓...▓...▓.▓.........▓...▓.▓.......▓...▓...▓.▓...▓...▓...▓.▓...▓...▓.....▓ .▓▓▓▓▓▓▓▓▓▓▓.▓▓▓▓▓▓▓.▓▓▓.▓.▓▓▓▓▓.▓.▓▓▓.▓▓▓▓▓▓▓.▓.▓.▓.▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓.▓.▓▓▓▓▓▓▓.▓▓▓▓▓.▓▓▓.▓.▓.▓▓▓.▓▓▓▓▓.▓.▓.▓.▓▓▓▓▓.▓ .▓.....▓.....▓...▓...▓...▓...▓...▓...▓...▓.......▓.▓...▓.....▓...▓.▓.▓.▓...▓...▓...▓...▓...▓.▓.......▓.....▓...▓...▓...▓ ▓▓.▓▓▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓▓▓.▓.▓.▓▓▓▓▓.▓.▓.▓.▓.▓▓▓.▓▓▓.▓▓▓.▓▓▓.▓.▓.▓.▓.▓.▓▓▓▓▓.▓.▓.▓▓▓.▓.▓▓▓.▓.▓▓▓.▓.▓▓▓.▓▓▓▓▓▓▓▓▓▓▓.▓.▓.▓ ...▓.....▓.....▓.......▓...▓...............▓...▓.........▓.........▓.▓.......▓.......▓...▓.....▓.....................▓.▓ 778901234567890▓4321099▓654333334▓43210987655567890▓0987778▓0987655▓8765556▓8765555567890▓3▓5567890123456789012345678▓1▓ 6▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓4▓▓▓0▓8▓▓▓4▓2▓2▓4▓▓▓▓▓▓▓▓▓▓▓4▓6▓▓▓0▓▓▓▓▓6▓8▓▓▓▓▓6▓4▓▓▓▓▓4▓6▓▓▓6▓4▓4▓▓▓▓▓0▓2▓4▓▓▓8▓0▓2▓▓▓▓▓8▓0▓▓▓4▓6▓8▓0▓ 556▓877▓43211▓655▓990▓87655▓1▓1▓334▓778▓11234▓778▓112▓556▓990▓556▓433▓334▓778▓7▓33456▓211123456▓990▓334▓778▓112▓556▓990▓ 4▓6▓8▓6▓4▓▓▓0▓6▓▓▓8▓▓▓▓▓▓▓▓▓0▓0▓2▓4▓6▓8▓0▓2▓▓▓8▓8▓▓▓2▓4▓▓▓0▓0▓4▓▓▓▓▓2▓2▓▓▓▓▓8▓▓▓2▓▓▓6▓2▓▓▓▓▓▓▓6▓8▓▓▓▓▓4▓6▓▓▓▓▓2▓▓▓6▓▓▓0▓ 3▓778▓655▓990▓778▓877▓5▓2109990▓1▓556▓990▓211▓9▓990▓334▓99990▓43211▓112▓778▓99012▓877▓334▓778▓778▓877▓5567890▓33456▓211▓ 2▓▓▓▓▓▓▓▓▓8▓▓▓8▓8▓▓▓6▓4▓2▓▓▓8▓▓▓0▓▓▓▓▓▓▓▓▓▓▓0▓▓▓▓▓0▓▓▓▓▓8▓8▓▓▓▓▓2▓0▓0▓▓▓6▓8▓▓▓▓▓▓▓8▓▓▓▓▓4▓6▓8▓8▓▓▓▓▓6▓▓▓▓▓8▓0▓2▓▓▓▓▓2▓2▓ 1▓877▓433▓877▓999▓8765433▓09877▓990123456▓990▓990111234▓778▓112▓3▓099▓555▓990▓334▓99012▓556▓999▓3345678▓099▓1123456▓333▓ 0▓8▓6▓4▓2▓▓▓6▓0▓▓▓8▓▓▓4▓▓▓0▓▓▓6▓8▓0▓▓▓▓▓6▓8▓▓▓8▓▓▓▓▓2▓4▓6▓▓▓0▓2▓▓▓▓▓8▓4▓4▓▓▓0▓2▓4▓0▓▓▓2▓4▓▓▓▓▓▓▓2▓4▓▓▓8▓0▓▓▓▓▓2▓4▓▓▓▓▓4▓ 099▓655▓112▓5▓112▓990▓5▓211▓55678▓11234567890▓777▓433▓5567890▓334▓778▓333▓5▓112▓5▓1▓5▓33456789012▓5▓099▓112▓5▓3▓5▓87655▓ 0▓▓▓▓▓▓▓0▓2▓4▓▓▓2▓▓▓0▓▓▓2▓▓▓4▓6▓▓▓2▓▓▓4▓6▓▓▓0▓6▓6▓4▓▓▓▓▓▓▓8▓0▓▓▓4▓6▓▓▓2▓2▓4▓▓▓▓▓6▓2▓4▓▓▓4▓▓▓8▓▓▓▓▓4▓0▓▓▓▓▓2▓4▓4▓▓▓8▓▓▓▓▓ 112▓21099▓334▓433▓211▓433▓334▓7▓112▓655▓778▓1▓556▓556▓3▓0999012▓556▓211123456▓877▓334▓7▓556▓990▓334▓112▓5▓334▓556▓990▓3▓ ▓▓2▓2▓▓▓8▓▓▓▓▓4▓▓▓2▓▓▓4▓▓▓2▓▓▓8▓0▓▓▓6▓▓▓8▓8▓2▓4▓▓▓▓▓6▓2▓0▓▓▓▓▓2▓▓▓▓▓▓▓0▓▓▓▓▓6▓8▓▓▓▓▓4▓6▓▓▓6▓▓▓0▓2▓▓▓▓▓2▓4▓▓▓4▓▓▓6▓▓▓0▓2▓ 433▓334▓877▓655▓3▓211▓556▓211▓99012▓778▓999▓3345678▓7▓211▓099▓3345678▓99011▓7▓9▓3▓65556▓877▓5▓112▓7▓43334▓655▓877▓1▓112▓ 4▓▓▓▓▓4▓▓▓6▓6▓▓▓2▓▓▓0▓▓▓6▓▓▓0▓▓▓▓▓2▓▓▓8▓0▓▓▓▓▓4▓▓▓8▓6▓▓▓2▓▓▓8▓▓▓4▓6▓8▓8▓▓▓0▓8▓0▓2▓6▓▓▓▓▓8▓▓▓4▓▓▓▓▓6▓4▓▓▓▓▓6▓▓▓8▓8▓0▓▓▓2▓ 556▓655▓556▓778▓112▓0987778▓099▓433▓099▓112▓655▓099▓655▓3▓77890▓556▓9▓77890▓9▓112▓778▓099▓1▓43334▓655▓09877▓099999012▓1▓ ▓▓6▓6▓▓▓4▓▓▓▓▓8▓0▓2▓▓▓▓▓▓▓▓▓▓▓8▓4▓▓▓0▓▓▓▓▓2▓▓▓▓▓0▓▓▓▓▓4▓4▓6▓▓▓0▓▓▓▓▓0▓6▓▓▓▓▓0▓▓▓2▓▓▓8▓0▓▓▓0▓4▓2▓▓▓6▓▓▓0▓▓▓▓▓0▓▓▓▓▓▓▓▓▓0▓ 556▓7▓7▓433▓09990▓33456▓334▓778▓556▓112▓4333456▓9901234▓55678▓1123211▓655▓211▓112▓099▓112▓9▓5▓211▓7▓21112321112▓556▓990▓ 4▓▓▓8▓6▓▓▓2▓0▓▓▓▓▓▓▓4▓▓▓2▓4▓6▓▓▓▓▓6▓▓▓2▓▓▓▓▓▓▓6▓8▓0▓▓▓▓▓▓▓▓▓8▓0▓2▓2▓▓▓▓▓4▓2▓2▓0▓▓▓0▓▓▓▓▓2▓8▓6▓▓▓0▓8▓2▓▓▓2▓2▓▓▓2▓4▓6▓8▓▓▓ 3▓099▓556▓211▓556▓655▓112▓556▓990▓778▓334▓778▓778▓112▓65433▓990▓333▓11234▓3▓3▓9▓1▓112▓433▓87778▓999▓333▓3▓334▓334▓778▓9▓ 2▓0▓▓▓4▓6▓▓▓▓▓4▓6▓▓▓▓▓0▓▓▓▓▓▓▓8▓▓▓▓▓8▓▓▓4▓6▓8▓▓▓8▓▓▓2▓▓▓4▓2▓0▓▓▓2▓▓▓0▓▓▓▓▓4▓▓▓8▓0▓▓▓2▓4▓▓▓8▓▓▓▓▓8▓▓▓▓▓2▓4▓4▓4▓▓▓▓▓▓▓▓▓8▓ 2111234▓778▓334▓77890▓099▓55678▓877▓990▓556▓999▓990▓334▓5▓211123211▓099▓9▓5567890▓433▓556▓9▓334▓778▓112▓5▓555▓990▓09877▓ 2▓▓▓▓▓▓▓▓▓▓▓2▓▓▓▓▓▓▓0▓▓▓8▓4▓▓▓▓▓8▓6▓▓▓0▓▓▓▓▓▓▓8▓0▓0▓4▓4▓▓▓▓▓▓▓▓▓▓▓0▓0▓8▓8▓6▓▓▓▓▓▓▓4▓▓▓▓▓6▓▓▓2▓4▓6▓▓▓0▓▓▓▓▓6▓6▓8▓0▓▓▓▓▓6▓ 3▓33456▓99012▓655▓211▓778▓433▓099▓655▓112▓5567890▓1▓555▓99012▓556▓9▓1▓7▓877▓099▓655▓112▓778▓1▓5567890▓33456▓778▓112▓556▓ ▓▓2▓▓▓6▓8▓▓▓▓▓6▓4▓2▓▓▓6▓▓▓4▓2▓0▓▓▓▓▓4▓2▓2▓4▓6▓▓▓0▓▓▓4▓▓▓8▓▓▓2▓4▓6▓8▓2▓6▓▓▓▓▓0▓8▓6▓▓▓0▓2▓▓▓8▓0▓▓▓6▓8▓▓▓2▓▓▓▓▓▓▓▓▓▓▓2▓4▓6▓ 012▓87778▓09877▓4333456▓655▓210987654333334▓778▓112345678▓433345678▓3▓6543211▓8777890▓334▓99012▓778901234567890123334▓7▓