group-b/README.org

570 lines
22 KiB
Org Mode

:PROPERTIES:
:ID: e7ae9b78-7d75-426f-bd19-7f986bc3c6d0
:END:
#+TITLE: Group B Pace Notes
#+filetags: :Project:Icebox:
#+ARCOLOGY_KEY: garden/dumb-ideas/group-b
#+ARROYO_DIRENV_DIR: ~/org/group-b
#+AUTO_TANGLE: t
I've been thinking about a rally card-game, either a 52-card deck or a custom art deck, called =Group B= or =pacenotes= or something, two players, a co-driver will draw a hand of cards and play them with a driver, who will react to the pacenotes, managing speed, turbo, reaction energy and try to get to the end of a stage.
or maybe it's a roguelite deckbuilder... Let's try that:
* Prototype Game Loop
I haven't actually beat this yet. That doesn't mean you can't? In theory? I don't know what happens when you run out of stage cards is what I am saying 😇
Some notes:
- the longer the straight is, the more you'll accelerate. Probably worth making the longest straight 50 meters or so or injecting a braking-zone card between long straight and hard turns to make sure the player has the right resources
- card values are all fucked up, this is just a first playtest..
#+begin_src python :tangle ~/org/group-b/prototype.py
from group_b.action_deck import ActionDeck, ActionCard
from group_b.player import PlayerState
import os
p = PlayerState()
os.system('cls' if os.name=='nt' else 'clear')
print("Stage start. Ready?")
while len(p._pace_note_queue) > 0:
print(p.resources())
print()
print(', '.join([str(c) for c in p._pace_note_queue]))
print("you can:", [f"{idx}: {str(c)}" for idx, c in enumerate(p.hand())])
choice = int(input("action index: "))
os.system('cls' if os.name=='nt' else 'clear')
card = p.hand()[choice]
p.perform_action(card)
p.draw()
p.check_damage()
print("You've beat the stage!")
#+end_src
Once it's possible to complete a stage the next logical step would be to add (speed*distance) timing to the stage so that you can see how fast you progress through and race against known seeds?
* Dev Environment Setup and running the prototype
Naturally I'm using Nix for this because i have the brain worms.
#+begin_src nix :tangle ~/org/group-b/shell.nix :mkdirp yes
{ pkgs ? import <nixpkgs> {}, ...}:
with pkgs;
let
myPython = python3.withPackages( pp: with pp; []);
in
mkShell {
packages = [
myPython
isort
black
];
}
#+end_src
This is just a python thing. It only uses what's in =stdlib=. Clone this repo, set up a python or use the system one, and run =python prototype.py=. [[roam:Enjoy Your Gaming]].
* Player Actor
:LOGBOOK:
CLOCK: [2023-04-07 Fri 14:28]
:END:
Okay the logic of the loop and the deck management happens in this =PlayerState= class. It has an [[(ActionDeck)]] and a [[(StageDeck)]] where the cards are managed and it tracks the hand of the player. A player will draw four cards to start with based on the =START_HAND= const-ish and will play one per turn by calling =perform_action= and passing the card in.
There is, frankly, too much card logic in the =perform_action= function, it will be some work and diagramming to figure out how to design a state machine that'll behave better. It really does feel to me like an Elixir pattern matching model + a data struct will go a long way here? unsure.
But basically action cards are used to progress through stage cards until the stage card deck is empty.
A player can see =NOTE_LOOKAHEAD= cards in to the future. there is a difficulty lever here: having more future steps lets you plan better, but in "hardcore mode" you will only see the most-future prediction so having more steps actually could be harder since you have to internalize what the coming track looks like
#+begin_src python :tangle ~/org/group-b/group_b/player.py :noweb yes
from .action_deck import ActionDeck, ActionCard
from .stage_deck import StageDeck, StageDeckEmpty, StraightCard
class PlayerState():
START_HAND = 4
NOTE_LOOKAHEAD = 3
MAX_HAND_SIZE = 5
def __init__(self):
self._action_deck = ActionDeck()
self._action_deck.shuffle()
self._stage = StageDeck()
self._resources = PlayerResources()
self._hand = [self._action_deck.draw() for _ in range(PlayerState.START_HAND)]
self._pace_note_queue = [self._stage.draw() for _ in range(PlayerState.NOTE_LOOKAHEAD)]
def hand(self):
return self._hand
def resources(self):
return self._resources
def draw(self):
assert(len(self._hand) <= self.MAX_HAND_SIZE)
new_action = self._action_deck.draw()
self._hand.append(new_action)
return new_action
def perform_action(self, action: ActionCard):
# no cheating!
assert(action in self.hand())
current_stage_note = self._pace_note_queue.pop(0)
self.apply_road_friction()
for modifier, value in action.modifiers.items():
if type(current_stage_note) == StraightCard:
# TKTKTK partial value or the ability to play two cards
# on long straight? hard to balance 70 meter straight in
# to heavy zone
if modifier != "focus":
value = current_stage_note.modifier * value
value += value * self._resources.momentum
self.maybe_modify_resource(modifier, value)
if not current_stage_note.can_pass_with(self, action):
# maybe lol
damage = int(self._resources.momentum + self._resources.speed)
print(f"{self._resources.speed} is too fast for a {current_stage_note}! you've taken {damage*5} car and {damage} tire damage")
self._resources.car_health -= damage * 5
self._resources.tire_health -= damage
current_stage_note.discard()
action.discard()
self.hand().remove(action)
try:
self._pace_note_queue.append(self._stage.draw())
except StageDeckEmpty:
pass
def check_damage(self):
if self._resources.car_health <= 0:
raise Exception("you've crashed")
def apply_road_friction(self):
rf = self._resources.road_friction
self._resources.speed -= self._resources.momentum * rf
self._resources.momentum -= self._resources.momentum * rf
# TKTKTK this will spend resources until one cannot be spent , need to check all before spendign any
def maybe_modify_resource(self, modifier, value):
if modifier == "focus" and self._resources[modifier] + value <= 0:
print(f"{modifier} {value} {self._resources[modifier]}")
raise Exception("you've lost focus and crashed!")
if modifier == "speed" and self._resources[modifier] + value >= self._resources.top_speed:
self._resources.speed = self._resources.top_speed
elif self._resources[modifier] + value >= 0:
self._resources[modifier] += value
else:
self._resources[modifier] = 0
#+end_src
A player has a few visible and invisible resources. The =speed=, =focus= and =momentum= are modified by the cards the player is able to play from the hand. The others are affected by the stage cards, and used to modify the others; for example =road_friction= will be set to a higher value on gravel stages causing player to lose more speed when coasting etc. This =DictyDataClass= is a useful little thing that lets me define a =dataclass= but still access it as though it is they are dictionaries.
#+begin_src python :tangle ~/org/group-b/group_b/player.py
from dataclasses import dataclass
class DictyDataClass():
def __getitem__(self, item):
return getattr(self, item)
def __setitem__(self, item, val):
return setattr(self, item, val)
@dataclass
class PlayerResources(DictyDataClass):
speed: int = 0
focus: int = 10
momentum: int = 0
car_health: int = 100
tire_health: int = 100
# only relevant in meta-progression
soft_tires: int = 4
hard_tires: int = 4
# hidden values attached to the car, stage, etc
tire_wear: int = 1
road_friction: float = 0.1
top_speed: int = 15
def __str__(self):
return "".join([
"speed: ", str(self.speed), "\n",
"focus: ", str(self.focus), "\n",
"momentum: ", str(self.momentum), "\n",
"car: ", str(self.car_health), "\n",
"tire: ", str(self.tire_health), "\n",
])
#+end_src
* Deck Abstractions
The =Card= and =Deck= exist to make some common interfaces for the different types of cards.
** Base classes
A Card is pretty simple, basically it just exists to be extended by ActionCard and StageCard to embed metadata about how the card behaves, and to integrate with the Deck class below.
Higher-level classes should be able to load modifiers and actions from files on disk. =discard= is a silly little helper that I hope I don't regret adding since the =self.deck= makes this all feel kind of spaghetti-ish.
#+begin_src python :tangle ~/org/group-b/group_b/deck.py :mkdirp yes
class Card():
def __init__(self, name, deck):
self.name = name
self.deck = deck
def discard(self):
self.deck.discard(self)
return None
def __str__(self):
return self.name
#+end_src
The deck base class is pretty simple, it really only exists to manage two lists of cards, a draw pile and a discard pile. A really simple [[https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle][Fisher-Yates shuffle]] is used to shuffle the cards in =shuffle='s [[(fisher-yates)]] below.
#+begin_src python :tangle ~/org/group-b/group_b/deck.py :mkdirp yes
from random import randint
from typing import List
class Deck():
def __init__(self):
self._cards = self.load_cards()
self._discard = []
def load_cards(self) -> List[Card]:
raise NotImplemented()
def shuffle(self):
self._cards.extend(self._discard)
self._discard = []
# (ref:fisher-yates)
for old_slot, card in enumerate(reversed(self._cards)):
new_slot = randint(0, len(self._cards)-1)
self._cards[old_slot], self._cards[new_slot] = (
self._cards[new_slot], self._cards[old_slot]
)
def draw(self) -> Card:
if len(self._cards) == 0:
print("shuffle...")
self.shuffle()
return self._cards.pop()
def discard(self, card: Card):
self._discard.append(card)
def depth(self) -> int:
return len(self._cards)
#+end_src
** Action Deck
An =ActionDeck='s would be defined by the contents of the car that the player is working with. This would be used to abstract the sort of differences between an Audi Quattro, Rally1 and Mini Cooper, the spread and strength of the various cards. Players could also be granted specialty action cards in the service park or as part of meta-progression. By default a player has 10 cards spread between "go faster" and "slow down". I'll want to make this load from JSON or YAML files soon so that I can use Babel to generate them.... 😈
#+begin_src python :tangle ~/org/group-b/group_b/action_deck.py
from .deck import Deck, Card
class ActionDeck(Deck):
def load_cards(self):
cards = [ActionCard(name="accelerate", deck=self)] * 3
cards.extend([ActionCard(name="gun it!", deck=self)] * 2)
cards.extend([ActionCard(name="brake", deck=self)] * 5)
cards.extend([ActionCard(name="coast", deck=self)] * 2)
cards.extend([ActionCard(name="find flow", deck=self)] * 2)
return cards
class ActionCard(Card):
def __init__(self, name: str, deck: ActionDeck):
super().__init__(name, deck)
self.modifiers = ActionCard.load_modifiers(name)
@classmethod
def load_modifiers(cls, name):
if name == "accelerate":
return dict(
speed=0.5,
focus=-1,
momentum=0.25,
tire_wear=-0.25,
car_health=0,
)
elif name == "brake":
return dict(
speed=-1,
focus=0,
momentum=-1,
tire_wear=0,
car_health=0,
)
elif name == "coast":
return dict(
speed=0,
focus=1,
momentum=-0.25,
tire_wear=0,
car_health=0,
)
elif name == "gun it!":
return dict(
speed=1,
focus=-2,
momentum=0.5,
tire_wear=-0.5,
car_health=-0.05,
)
elif name == "find flow":
return dict(
speed=0,
focus=4,
momentum=0,
tire_wear=-0.5,
car_health=-0.05,
)
#+end_src
speed should not be set by the cards but calculated from momentum... The user is managing the momentum and focus to get the most speed with the least damage/wear.
** Stage Deck
The Stage Deck is what defines a rally stage. This version of it is procedurally generated where there are 2x as many turns as straights [[(StageCardWeight)]], and the straights' lengths are weighted toward being shorter [[(StraightCardWeight)]]. You could imagine a =StageDeck= class that pulls its list of cards from a rough take on famous stages and could be paired with art of them.
#+begin_src python :tangle ~/org/group-b/group_b/stage_deck.py
from .deck import Deck, Card
from .action_deck import ActionCard
# from .player import PlayerState
from dataclasses import dataclass
from enum import Enum
from random import randint, choice, choices
class StageDeck(Deck):
DEFAULT_DECK_SIZE=25
def load_cards(self):
cards = list()
cards.extend(StageCard.generate(self, cnt=self.DEFAULT_DECK_SIZE))
return cards
def draw(self) -> Card:
if len(self._cards) == 0:
raise StageDeckEmpty()
return self._cards.pop()
@dataclass
class StageCard(Card):
# turn w/ numeric modifier (max speed you can take the turn)
# straight w/ distance modifier (would it scale the accelerate cards?)
# hazards (jump, gate, narrow, don't cut, over crest, unseen)
def __init__(self, name: str, deck: StageDeck):
super().__init__(name, deck)
def can_pass_with(self, player, action: ActionCard) -> bool:
raise NotImplemented()
@classmethod
def generate(cls, deck: StageDeck, cnt=1):
weights = [10, 5] # (ref:StageCardWeight)
cls_list = choices([TurnCard, StraightCard], weights=weights, k=cnt)
return [real_cls.generate(deck) for real_cls in cls_list]
class StageDeckEmpty(BaseException):
pass
#+end_src
=Card= sub-classes are expected to implement =can_pass_with= which take a =PlayerState= and an =ActionCard= to evaluate whether it is safe for a Player to progress through. For example, the =TurnCard= compares the speed, health, etc of the user to ensure they aren't taken a corner too quickly.
The shape of the stages being procedurally generated, the names of the =StageCard='s are inspired by actual pace notes; eventually these will have hazard modifiers like "long", "narrow", "over crest" but for now this works out. You end up with some fun things like reading out "flat out right into hairpin left".
#+begin_src python :tangle ~/org/group-b/group_b/stage_deck.py
class CardShape(str, Enum):
RIGHT = "right"
LEFT = "left"
ESSES = "esses"
class TurnCard(StageCard):
shape: CardShape
modifier: int
def __str__(self):
if self.modifier == 1:
return f"sharp hairpin {self.shape}"
if self.modifier == 2:
return f"hairpin {self.shape}"
if self.modifier == 8:
return f"fast {self.shape}"
if self.modifier == 9:
return f"flat out {self.shape}"
return f"{self.shape}-{self.modifier}"
def can_pass_with(self, player, action: ActionCard) -> bool:
# TKTKTK use certain action cards as pass?
if int(player._resources.speed) > self.modifier:
return False
if player._resources.focus <= 0:
return False
if player._resources.tire_health <= 0:
return False
if player._resources.car_health <= 0:
return False
return True
@classmethod
def generate(cls, deck: StageDeck):
mod = randint(1,9)
shape = choice(list(CardShape))
card = cls("", deck)
card.modifier=mod
card.shape=shape
return card
#+end_src
Well, the naming mostly works out. For straight cards, if there are multiple back-to-back it can be a little weird. "into and 100" could/should/coalesce or be displayed to the player in a better fashion than it is right now.
#+begin_src python :tangle ~/org/group-b/group_b/stage_deck.py
class StraightCard(StageCard):
# decameters lol
modifier: int
LENGTH_WEIGHTS = [10, 10, 7, 5, 5, 2, 2, 2, 1, 1]
def __str__(self):
if self.modifier == 1:
return f"into"
if self.modifier == 2:
return f"and"
return f"{self.modifier*10}"
@classmethod
def generate(cls, deck: StageDeck):
mod = randint(1,10)
shape = choices(range(10), weights=cls.LENGTH_WEIGHTS, k=1)[0] # (ref:StraightCardWeights)
card = cls("", deck)
card.modifier=mod
return card
def can_pass_with(self, player, action):
return True
#+end_src
* Late Night Notes
But maybe it's a single-player deckbuilding roguelite: [[id:20230406T231710.508310][this game Death Roads: Tournament has me thinking about "roguelite deckbuilder" style take on stage Rally Racing]]
there's a realism version of this where you're trying to model an actual rally weekend, and there is a gonzo version of this where you're trying to build god's own rally team. prototype is just about getting to the point where I can play with systems and not about the energy of the game.
- the goal is to compete in stage rallies, 10-15 levels for each rally with a season-long game-loop progression, and some sort of meta-progression built on top of that
- meta-progression: upgrading your team, car, training mechanics, moving to a new car class?
- in-rally-progression: getting more fine-tuned setup of the car, making tire choice and making sure you don't over-extend yourself, deck restructuring, random events between stages and on stages, etc
- players manage a handful of competing resources like focus, speed, momentum, car health, tire wear to have the shortest time through each stage and survive the rally
- manage a deck of cards containing actions that they use to transit the stage safely. simple cards like accelerate/break/turn in, specialized cards like cut the apex, flash of insight, etc
game loop is basically:
you start at time control, your codriver reads out the next 2-3 pace notes and you're off; this can be abstracted by having a stage deck and an action deck; the stage deck can have negative effects mixed in like gravel on the stage, surprise ice, spectator on stage, etc. in easy modes the pace notes would be listed on the side but in harder difficulties you'd have to remember them yourself as you move through the stage. pace notes are similar to the real notes where
so basically imagine you are going to high speed through some left-6, right-6, 30 meters straight, so you build up a lot of speed and momentum but you start to drain focus because you are going so fast on a narrow road. now the co-driver reads a "right hairpin" - the way this would work is that you would see "right hairpin" when you are progressing through the right-6 so you would say "oh i have three cards, a speed up card, a late-braking card, and 'do it like takumi' special card that lets me turn with less momentum loss than otherwise normal brake and turn cards would let me" so i would play late braking so that i go through the 30 meter straight without scrubbing too much speed. at this point, i am coming up to the hairpin and my codriver reads "in to left 4" signaling a quick medium-speed left hander, so i then play 'do it like takumi' to get through the hairpin with enough momentum to accelerate in to the left-4 with the proper amount of speed.
the part of that deckbuilding game that i linked to in the post above is that your deck's contents is determined by the parts you have attached to the car. in my system that could be as simple as fitting hard vs soft compound tires; conserve softs for later rounds but have cards that cost more focus to use or less grip (is grip a modifier?), fit a different turbocharger to have different speed/momentum tradeoffs, or you could get a whole car upgrade from a mini to an esky to a lancia delta etc that would have different stats and card spread entirely
** NEXT how many game systems/resources are reasonable to balance?
there's like 5 or 6 in the stage and another couple in the rally progression...
** NEXT when do draws happen?
** NEXT how do we handle difficulty curve?
** NEXT there is a whole motorsport manager that could be built as the meta-progression lol -- is this my racing dynasty simulator too?
** NEXT consider bolstering/replacing the focus mechanic with an actual time pressure placed on the player
if you only had a few seconds to play each hand the stress of rallying could actually be represented pretty well here, heh. maybe a focus bonus/buff that would come in to play if you are playing against the clock
** NEXT consider whether this idea could extend to rally raid events like dakar or baja 1000
multiple classes like unsupported motorcycles to trucks
maintenance and parts management
fuel water etc resources
* Original Spec :noexport:
Originally Spec'd: [2020-11-11 Wed 21:25]
** Some Ideas to throw at the wall
- you're managing two resources, attention and speed.
- Every turn you are accelerating and gain one speed, by default
*** turn flow
- co-driver draws and reads a card, a pace-note
- co-driver places it face down
- if there are three cards face down, co-driver reveals the last pace-note
- driver makes an action
- resolve
*** Actions
**** accelerate
gain 1 speed, 0 focus
**** spot the apex
gain 1 focus, 0 speed
**** a flash of insight
look at the face down cards, lose 1 focus
*** resolution
- each corner has a cost associated with it, like "left 3" "right 4 narrow"
- if the car is slower than the corner cost, resolves cleanly
- if the car is faster than the corner cost, driver can spend 2 focus for 2 speed
*** what's going on here
this isnt' much of a game. single-player at best. what is the skill? there isn't a strategy here except for memory.
try to implement a crappy version of this in python or something.
* NEXT [#C] Plan Project tasks
:PROPERTIES:
:ID: e335d6d7-41b7-48c0-a3fc-95b534841a77
:END: