|
2 months ago | |
---|---|---|
group_b | 2 months ago | |
.envrc | 2 months ago | |
LICENSE | 2 months ago | |
README.org | 2 months ago | |
prototype.py | 2 months ago | |
shell.nix | 2 months ago |
README.org
Group B Pace Notes
- Prototype Game Loop
- Dev Environment Setup and running the prototype
- Player Actor
- Deck Abstractions
- Late Night Notes
- NEXT how many game systems/resources are reasonable to balance?
- 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
- NEXT consider whether this idea could extend to rally raid events like dakar or baja 1000
- NEXT [#C] Plan Project tasks
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..
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!")
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.
{ pkgs ? import <nixpkgs> {}, ...}:
with pkgs;
let
myPython = python3.withPackages( pp: with pp; []);
in
mkShell {
packages = [
myPython
isort
black
];
}
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
CLOCK: [2023-04-07 Fri 14:28]
Okay the logic of the loop and the deck management happens in this PlayerState
class. It has an /rrix/group-b/src/branch/main/(ActionDeck) and a /rrix/group-b/src/branch/main/(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
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
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.
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",
])
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.
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
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 Fisher-Yates shuffle is used to shuffle the cards in shuffle
's /rrix/group-b/src/branch/main/(fisher-yates) below.
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)
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…. 😈
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,
)
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 /rrix/group-b/src/branch/main/(StageCardWeight), and the straights' lengths are weighted toward being shorter /rrix/group-b/src/branch/main/(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.
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
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".
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
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.
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
Late Night Notes
But maybe it's a single-player deckbuilding roguelite: 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