Add card_game_dsl_example.rs
This commit is contained in:
506
card_game_dsl_example.rs
Normal file
506
card_game_dsl_example.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
// stack data structure
|
||||
#[derive(Component, Clone, Debug)]
|
||||
pub struct TriggerStack {
|
||||
/// LIFO queue of pending triggers
|
||||
pub stack: Vec<TriggerObject>,
|
||||
/// Current player with priority
|
||||
pub priority_player: PlayerId,
|
||||
/// Players who have passed priority
|
||||
pub passed_priority: HashSet<PlayerId>,
|
||||
/// State snapshot for rollback (AI simulation, undo)
|
||||
pub snapshot_history: Vec<GameStateSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TriggerObject {
|
||||
pub id: TriggerId,
|
||||
pub source_entity: Entity,
|
||||
pub trigger_type: TriggerType,
|
||||
pub controller: PlayerId,
|
||||
pub priority: TriggerPriority,
|
||||
pub parameters: TriggerParams,
|
||||
pub state: TriggerState, // Pending, Resolving, Resolved, Counteracted
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TriggerType {
|
||||
/// "When you play a card" - Slay the Spire
|
||||
OnCardPlayed(CardId),
|
||||
/// "When a creature enters battlefield" - MTG
|
||||
OnEntersBattlefield(EntityType),
|
||||
/// "When damage is dealt" - Hearthstone
|
||||
OnDamageDealt { source: Entity, amount: u32 },
|
||||
/// "At start of turn" - All games
|
||||
OnTurnStart(PlayerId),
|
||||
/// "When health drops below X" - Conditional
|
||||
OnHealthThreshold { entity: Entity, threshold: u32 },
|
||||
/// Custom game-specific triggers
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
|
||||
pub enum TriggerPriority {
|
||||
/// State-based actions (checked before priority)
|
||||
StateBased = 0,
|
||||
/// Replacement effects (modify what happens)
|
||||
Replacement = 1,
|
||||
/// Triggered abilities (go on stack)
|
||||
Triggered = 2,
|
||||
/// Activated abilities (player chooses)
|
||||
Activated = 3,
|
||||
/// Spells (highest level)
|
||||
Spell = 4,
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Stack Resolution System, APNAP Order
|
||||
pub fn resolve_trigger_stack(
|
||||
stack: &mut TriggerStack,
|
||||
game_state: &mut GameState,
|
||||
) {
|
||||
loop {
|
||||
// Check state-based actions first (don't use stack)
|
||||
check_state_based_actions(game_state);
|
||||
|
||||
// If all players passed priority and stack is empty → next phase
|
||||
if stack.stack.is_empty() && stack.passed_priority.len() == game_state.player_count {
|
||||
advance_phase(game_state);
|
||||
return;
|
||||
}
|
||||
|
||||
// If all players passed priority, resolve top of stack
|
||||
if stack.passed_priority.len() == game_state.player_count {
|
||||
if let Some(trigger) = stack.stack.pop() {
|
||||
// Clear passed priority for next resolution
|
||||
stack.passed_priority.clear();
|
||||
stack.priority_player = trigger.controller;
|
||||
|
||||
// Execute trigger, may add new triggers to stack
|
||||
execute_trigger(trigger, stack, game_state);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Current player with priority can:
|
||||
// 1. Add spell/ability to stack
|
||||
// 2. Pass priority
|
||||
// For AI/simulation, we auto-resolve
|
||||
if game_state.is_ai_game() {
|
||||
auto_resolve_priority(stack, game_state);
|
||||
} else {
|
||||
// Wait for player input (network message in multiplayer)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// APNAP Order for simultaneous triggers
|
||||
pub fn add_simultaneous_triggers(
|
||||
stack: &mut TriggerStack,
|
||||
triggers: Vec<TriggerObject>,
|
||||
active_player: PlayerId,
|
||||
player_order: &[PlayerId],
|
||||
) {
|
||||
// Group by controller
|
||||
let mut by_controller: HashMap<PlayerId, Vec<TriggerObject>> = HashMap::new();
|
||||
for trigger in triggers {
|
||||
by_controller.entry(trigger.controller).or_default().push(trigger);
|
||||
}
|
||||
|
||||
// APNAP: Active Player first (lowest on stack), then Non-Active in turn order
|
||||
// Last added resolves first (LIFO)
|
||||
let mut ordered = Vec::new();
|
||||
|
||||
// Active player's triggers go on stack first (bottom)
|
||||
if let Some(ap_triggers) = by_controller.remove(&active_player) {
|
||||
ordered.extend(ap_triggers);
|
||||
}
|
||||
|
||||
// Non-active players in turn order
|
||||
for player in player_order {
|
||||
if *player != active_player {
|
||||
if let Some(nap_triggers) = by_controller.remove(player) {
|
||||
ordered.extend(nap_triggers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to stack (last in = first to resolve)
|
||||
stack.stack.extend(ordered);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// mbf
|
||||
#[derive(Component, Clone, Debug)]
|
||||
pub struct ModifierStack {
|
||||
/// All active modifiers on this entity
|
||||
pub modifiers: Vec<Modifier>,
|
||||
/// Cached computed values (for performance)
|
||||
pub cached_values: HashMap<ModifierType, i32>,
|
||||
/// Dirty flag for recalculation
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Modifier {
|
||||
pub id: ModifierId,
|
||||
pub source_entity: Entity,
|
||||
pub modifier_type: ModifierType,
|
||||
pub value: i32,
|
||||
pub priority: u32, // Higher priority applies first
|
||||
pub layer: ModifierLayer, // For same-type modifiers
|
||||
pub timestamp: u64, // For timestamp order within layer
|
||||
pub duration: Option<u32>, // Turns/rounds remaining
|
||||
pub is_cumulative: bool, // Stack with same modifiers?
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ModifierType {
|
||||
Attack,
|
||||
Health,
|
||||
MaxHealth,
|
||||
Cost,
|
||||
Damage,
|
||||
Healing,
|
||||
DrawCount,
|
||||
CardTargetCount,
|
||||
// Game-specific
|
||||
SpellPower, // Hearthstone
|
||||
Power, // MTG
|
||||
Strength, // Yu-Gi-Oh
|
||||
EnergyCost, // Slay the Spire
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ModifierLayer {
|
||||
/// Base value (card's printed stats)
|
||||
Base = 0,
|
||||
/// Characteristic-defining abilities (MTG)
|
||||
CharacteristicDefining = 1,
|
||||
/// Control-changing effects
|
||||
Control = 2,
|
||||
/// All other effects (timestamp order)
|
||||
General = 3,
|
||||
/// Setting to specific value (overwrites all)
|
||||
Set = 4,
|
||||
/// Multiplying/dividing
|
||||
Multiply = 5,
|
||||
/// Additive (most common)
|
||||
Additive = 6,
|
||||
}
|
||||
|
||||
// mbf resolution system
|
||||
pub fn compute_modified_value(
|
||||
entity: Entity,
|
||||
modifier_type: ModifierType,
|
||||
base_value: i32,
|
||||
modifiers: &ModifierStack,
|
||||
game_state: &GameState,
|
||||
) -> i32 {
|
||||
if !modifiers.dirty {
|
||||
return *modifiers.cached_values.get(&modifier_type).unwrap_or(&base_value);
|
||||
}
|
||||
|
||||
let mut value = base_value;
|
||||
|
||||
// Get all modifiers of this type, sorted by priority → layer → timestamp
|
||||
let mut applicable: Vec<&Modifier> = modifiers
|
||||
.modifiers
|
||||
.iter()
|
||||
.filter(|m| m.modifier_type == modifier_type)
|
||||
.collect();
|
||||
|
||||
applicable.sort_by(|a, b| {
|
||||
a.priority.cmp(&b.priority)
|
||||
.then(a.layer.cmp(&b.layer))
|
||||
.then(a.timestamp.cmp(&b.timestamp))
|
||||
});
|
||||
|
||||
// Apply in order
|
||||
for modifier in applicable {
|
||||
value = match modifier.layer {
|
||||
ModifierLayer::Set => modifier.value,
|
||||
ModifierLayer::Multiply => value * modifier.value,
|
||||
ModifierLayer::Additive => value + modifier.value,
|
||||
_ => value + modifier.value, // Default to additive
|
||||
};
|
||||
}
|
||||
|
||||
// Cache result
|
||||
modifiers.cached_values.insert(modifier_type, value);
|
||||
modifiers.dirty = false;
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
// Example: "Give all minions +2/+2" (Hearthstone)
|
||||
pub fn apply_buff(
|
||||
commands: &mut Commands,
|
||||
target_query: &Query<Entity, With<MinionComponent>>,
|
||||
source: Entity,
|
||||
attack_buff: i32,
|
||||
health_buff: i32,
|
||||
duration: Option<u32>,
|
||||
) {
|
||||
let timestamp = get_game_timestamp();
|
||||
|
||||
for entity in target_query.iter() {
|
||||
commands.entity(entity).with_modifiers(|mods: &mut ModifierStack| {
|
||||
mods.modifiers.push(Modifier {
|
||||
id: generate_modifier_id(),
|
||||
source_entity: source,
|
||||
modifier_type: ModifierType::Attack,
|
||||
value: attack_buff,
|
||||
priority: 100,
|
||||
layer: ModifierLayer::Additive,
|
||||
timestamp,
|
||||
duration,
|
||||
is_cumulative: true,
|
||||
});
|
||||
mods.modifiers.push(Modifier {
|
||||
id: generate_modifier_id(),
|
||||
source_entity: source,
|
||||
modifier_type: ModifierType::Health,
|
||||
value: health_buff,
|
||||
priority: 100,
|
||||
layer: ModifierLayer::Additive,
|
||||
timestamp,
|
||||
duration,
|
||||
is_cumulative: true,
|
||||
});
|
||||
mods.dirty = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ecs core components
|
||||
#[derive(Component, Clone, Debug)]
|
||||
pub struct CardComponent {
|
||||
pub card_id: CardId,
|
||||
pub card_type: CardType,
|
||||
pub cost: u32,
|
||||
pub owner: PlayerId,
|
||||
pub controller: PlayerId,
|
||||
pub zone: CardZone, // Hand, Deck, Battlefield, Graveyard, Exile
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug)]
|
||||
pub struct StatsComponent {
|
||||
pub base_attack: i32,
|
||||
pub base_health: i32,
|
||||
pub current_attack: i32, // Computed from MBF
|
||||
pub current_health: i32, // Computed from MBF
|
||||
pub damage_taken: i32,
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug)]
|
||||
pub struct TriggerComponent {
|
||||
pub triggers: Vec<TriggerDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TriggerDefinition {
|
||||
pub event_type: TriggerType,
|
||||
pub condition: Option<TriggerCondition>,
|
||||
pub effects: Vec<EffectDefinition>,
|
||||
pub is_mandatory: bool,
|
||||
pub once_per_turn: bool,
|
||||
}
|
||||
|
||||
// ============ PLAYER COMPONENTS ============
|
||||
#[derive(Component, Clone, Debug)]
|
||||
pub struct PlayerComponent {
|
||||
pub player_id: PlayerId,
|
||||
pub hand: Vec<Entity>,
|
||||
pub deck: Vec<Entity>,
|
||||
pub graveyard: Vec<Entity>,
|
||||
pub exile: Vec<Entity>,
|
||||
pub health: i32,
|
||||
pub max_health: i32,
|
||||
pub resource: u32, // Mana/Energy
|
||||
pub max_resource: u32,
|
||||
}
|
||||
|
||||
// ============ GAME STATE ============
|
||||
#[derive(Resource, Clone, Debug)]
|
||||
pub struct GameState {
|
||||
pub current_player: PlayerId,
|
||||
pub phase: GamePhase,
|
||||
pub turn_number: u32,
|
||||
pub player_order: Vec<PlayerId>,
|
||||
pub stack: TriggerStack,
|
||||
pub is_multiplayer: bool,
|
||||
pub authority: AuthorityType, // Server/Client
|
||||
}
|
||||
|
||||
|
||||
|
||||
// dsl atomic commands
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum EffectCommand {
|
||||
// Damage/Healing
|
||||
DealDamage { amount: u32, target: TargetSelector },
|
||||
Heal { amount: u32, target: TargetSelector },
|
||||
|
||||
// Card Operations
|
||||
DrawCards { count: u32, player: PlayerId },
|
||||
DiscardCards { count: u32, player: PlayerId },
|
||||
AddCardToHand { card_id: CardId, player: PlayerId },
|
||||
|
||||
// Resource
|
||||
GainResource { amount: u32, player: PlayerId },
|
||||
SpendResource { amount: u32, player: PlayerId },
|
||||
|
||||
// Modifiers
|
||||
ApplyModifier { modifier: Modifier, target: TargetSelector },
|
||||
RemoveModifier { modifier_id: ModifierId, target: TargetSelector },
|
||||
|
||||
// Movement
|
||||
MoveCard { entity: Entity, from: CardZone, to: CardZone },
|
||||
SummonMinion { card_id: CardId, player: PlayerId, position: BoardPosition },
|
||||
|
||||
// Trigger Management
|
||||
AddTrigger { trigger: TriggerDefinition, target: Entity },
|
||||
RemoveTrigger { trigger_id: TriggerId, target: Entity },
|
||||
|
||||
// Game State
|
||||
SetPhase { phase: GamePhase },
|
||||
EndTurn,
|
||||
WinGame { player: PlayerId },
|
||||
LoseGame { player: PlayerId },
|
||||
|
||||
// Conditional
|
||||
IfCondition {
|
||||
condition: EffectCondition,
|
||||
then_effects: Vec<EffectCommand>,
|
||||
else_effects: Option<Vec<EffectCommand>>,
|
||||
},
|
||||
|
||||
// Repeat
|
||||
ForEach {
|
||||
targets: TargetSelector,
|
||||
effects: Vec<EffectCommand>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum TargetSelector {
|
||||
Self,
|
||||
RandomEnemy,
|
||||
AllEnemies,
|
||||
AllAllies,
|
||||
SpecificEntity(Entity),
|
||||
LowestHealthEnemy,
|
||||
HighestAttackEnemy,
|
||||
// MTG-style
|
||||
TargetCreature,
|
||||
TargetPlayer,
|
||||
TargetArtifact,
|
||||
// Yu-Gi-Oh-style
|
||||
TargetMonster,
|
||||
TargetSpellTrap,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// Examples
|
||||
/// Slay the Spire
|
||||
// "When you play a card, draw 1 card"
|
||||
TriggerDefinition {
|
||||
event_type: TriggerType::OnCardPlayed(CardId::Any),
|
||||
condition: Some(TriggerCondition::PlayerControlled),
|
||||
effects: vec![EffectCommand::DrawCards { count: 1, player: TriggerSource }],
|
||||
is_mandatory: true,
|
||||
once_per_turn: false,
|
||||
}
|
||||
|
||||
// "When you take damage, gain 1 block"
|
||||
TriggerDefinition {
|
||||
event_type: TriggerType::OnDamageTaken { min_amount: 1 },
|
||||
condition: None,
|
||||
effects: vec![EffectCommand::ApplyModifier {
|
||||
modifier: Modifier::new_block(1),
|
||||
target: TargetSelector::Self,
|
||||
}],
|
||||
is_mandatory: true,
|
||||
once_per_turn: false,
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Magic: The Gathering
|
||||
// "Whenever a creature enters the battlefield, draw a card"
|
||||
TriggerDefinition {
|
||||
event_type: TriggerType::OnEntersBattlefield(EntityType::Creature),
|
||||
condition: Some(TriggerCondition::ControllerIsSelf),
|
||||
effects: vec![EffectCommand::DrawCards { count: 1, player: TriggerSource }],
|
||||
is_mandatory: true,
|
||||
once_per_turn: false,
|
||||
}
|
||||
|
||||
// "At the beginning of your upkeep, sacrifice a creature"
|
||||
TriggerDefinition {
|
||||
event_type: TriggerType::OnTurnStart(TriggerSource),
|
||||
condition: Some(TriggerCondition::Phase(Phase::Upkeep)),
|
||||
effects: vec![EffectCommand::SelectAndSacrifice {
|
||||
selector: TargetSelector::TargetCreature,
|
||||
count: 1,
|
||||
}],
|
||||
is_mandatory: true,
|
||||
once_per_turn: true,
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Hearthstone
|
||||
// "Whenever you summon a minion, deal 1 damage to all enemies"
|
||||
TriggerDefinition {
|
||||
event_type: TriggerType::OnSummon(EntityType::Minion),
|
||||
condition: Some(TriggerCondition::ControllerIsSelf),
|
||||
effects: vec![EffectCommand::DealDamage {
|
||||
amount: 1,
|
||||
target: TargetSelector::AllEnemies,
|
||||
}],
|
||||
is_mandatory: true,
|
||||
once_per_turn: false,
|
||||
}
|
||||
|
||||
// "Deathrattle: Draw a card"
|
||||
TriggerDefinition {
|
||||
event_type: TriggerType::OnDeath,
|
||||
condition: None,
|
||||
effects: vec![EffectCommand::DrawCards { count: 1, player: Owner }],
|
||||
is_mandatory: true,
|
||||
once_per_turn: false,
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Yu-Gi-Oh!
|
||||
// "When this card is Normal Summoned: You can add 1 Spell from deck to hand"
|
||||
TriggerDefinition {
|
||||
event_type: TriggerType::OnNormalSummon,
|
||||
condition: Some(TriggerCondition::ThisCard),
|
||||
effects: vec![EffectCommand::SearchDeck {
|
||||
card_type: CardType::Spell,
|
||||
count: 1,
|
||||
to: Zone::Hand,
|
||||
}],
|
||||
is_mandatory: false, // Optional trigger
|
||||
once_per_turn: true,
|
||||
}
|
||||
|
||||
// "When a monster declares an attack: Negate the attack"
|
||||
TriggerDefinition {
|
||||
event_type: TriggerType::OnAttackDeclared,
|
||||
condition: Some(TriggerCondition::FacingThisCard),
|
||||
effects: vec![EffectCommand::NegateAttack],
|
||||
is_mandatory: true,
|
||||
once_per_turn: false,
|
||||
}
|
||||
Reference in New Issue
Block a user