~fkfd/shenzhen_solitaire

im bored so im writing a minigame in rust
I forgot hanzi is two columns wide
Drop flipped dragons when free dropping
Turns out I had false memories about dragons card collection

clone

read-only
https://git.sr.ht/~fkfd/shenzhen_solitaire
read/write
git@git.sr.ht:~fkfd/shenzhen_solitaire

You can also use your local clone with git send-email.

#Shenzhen Solitaire

I've been playing Shenzhen I/O these days and find the minigame entertaining. As an excuse, I'm writing this in Rust, which I've been learning for less than a month.

#Rules

The rules are explained on Shenzhen I/O Fandom: Shenzhen Solitaire.

#Terminology

Screenshot of game with names of UI elements

#Implementation

As a Rust beginner who transitioned from C++, the architecture is pretty messy. Here's how it works:

  • main() calls ui()
  • In each game, ui() creates mut state: GameState, a struct defined in game.rs
  • ui() begins an event loop, where for each frame (30 ms[a]):
    • render (render.rs) renders state with ratatui
    • Is an animation going on?
      • If so, GameState::animate (animate.rs) mutates animation-related attributes of state
      • If not:
        • If user won, start "free drop" animation (game.rs GameState::check_if_player_won)
        • If a free-to-move card can be collected, set it in "fly" animation (collect.rs GameState::collect)
        • Turn on/off indicators to tell user which suit(s) of dragons can be collected (collect.rs GameState::find_exposed_dragon)
    • Handle keyboard events
    • If no animation is going on, handle mouse events (mouse.rs handle_mouse)
      • Figure out what player interacted with (enum UIElement in layout.rs)
      • If player clicked a button, return corresponding MouseResult to event loop
      • If player clicked an indicator, mutate state and collect dragons
      • If player clicked a free-to-move slot, drag cards (mouse.rs GameState::drag)
      • If player is holding mouse, update mouse position in state
      • If player released the mouse, drop cards (mouse.rs GameState::drop)
    • If player starts a new game, overwrite state with a fresh GameState
    • If player quits, break out of loop and exit

[a] Might be less than 30 ms

#Data Structure & Layout

A major hassle in development is the separation of Slots and UIElements. A Slot holds cards. A UIElement is just a rectanglular area on the screen, so given coordinates, we know what the player is interacting with; and given an element, we know where to render it. However UIElement is useful in game logic, so it's used pretty much everywhere, even when the code is not really UI-related.

#Drag & Drop

What GameState::drag does is:

  • Make sure the clicked card (and all cards below) are draggable
  • Pop these cards from slot
  • Move cards to state.dragged_cards
  • Set state.dragged_from to a UIElement

What GameState::drop does is:

  • Check if the destination accepts these cards
    • If so, move cards into destination slot
    • If not, move cards back into state.dragged_from

#Animation

GameState does not have access to ratatui or crossterm APIs. Therefore it cannot directly animate the screen. Instead, it can update internal state (the flying_cards and free_dropping_cards attributes), so the render function can calculate the position of each animated card.

We will discuss flying_cards as an example.

When GameState::collect_{flower, numbered} find a card to collect, it calls GameState::fly with these arguments:

  • card: Card
  • from: UIElement
  • to: UIElement

GameState::fly will push a new Animation to its flying_cards: Vec<Animation>.

Then, for each frame, the event loop will call GameState::animate, to advance every animation one frame. When an animation finishes, GameState::animate places the animated card in the slot where it belongs, and removes the animation from the Vec. At this point, this animation is complete.

free_dropping_cards is similar. It is played when player wins. The cards fall below the bottom edge and are discarded instead of landed.

#Debugging

This is an ncurses-like program, and is harder to debug. If the game panics, the backtrace will be an absolute mess, because it shut down without leaving ncurses mode. If you need the One True Debugger, my workaround is to write eprintln!, redirect it to a file, and reset the terminal:

RUST_BACKTRACE=1 cargo run 2> /tmp/bt; reset; cat /tmp/bt

If you supply an argument, the program will treat it as a filepath and initialize cards in the stack slots from the file. An example file is sorted.txt. First row is stack_slots[0] and so on. It doesn't check how many cards are there.