Improve UI of food and set-aside cards.
This commit is contained in:
+134
-37
@@ -9,7 +9,27 @@ type BattleUnit struct {
|
|||||||
Foods []Card `json:"foods,omitempty"`
|
Foods []Card `json:"foods,omitempty"`
|
||||||
Bonus int `json:"bonus"` // total power added by foods, eating, auras
|
Bonus int `json:"bonus"` // total power added by foods, eating, auras
|
||||||
Damage int `json:"damage"` // damage markers accumulated this battle
|
Damage int `json:"damage"` // damage markers accumulated this battle
|
||||||
Shield int `json:"shield"` // hits that will be fully prevented (Gorilla, Melon)
|
// Shields are this pet's own full-hit blocks, newest last. Each carries
|
||||||
|
// its source so the log can name it (Gorilla's innate block, or a Melon
|
||||||
|
// perk) and the client can drop the spent card.
|
||||||
|
Shields []shieldCharge
|
||||||
|
}
|
||||||
|
|
||||||
|
// shieldCharge is one full-hit block a pet carries. source is the granting
|
||||||
|
// item's display name (e.g. "Melon"); an empty source is the pet's own innate
|
||||||
|
// shield (Gorilla). card, when set, is the food to remove from the play area
|
||||||
|
// once the charge is spent.
|
||||||
|
type shieldCharge struct {
|
||||||
|
source string
|
||||||
|
card *Card
|
||||||
|
}
|
||||||
|
|
||||||
|
// shieldBlock describes a hit that a shield just absorbed, for the caller to
|
||||||
|
// narrate and clean up: source names the shield (empty = innate), and release
|
||||||
|
// is the field card to remove — a Melon food or a set-aside Turtle — or nil.
|
||||||
|
type shieldBlock struct {
|
||||||
|
source string
|
||||||
|
release *Card
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
||||||
@@ -83,6 +103,11 @@ type BattleEvent struct {
|
|||||||
// "steal": Seat's pet stole Count apples from Target's pet (Wolverine).
|
// "steal": Seat's pet stole Count apples from Target's pet (Wolverine).
|
||||||
// "eat": Seat's pet ate apples; Bonus is its new total.
|
// "eat": Seat's pet ate apples; Bonus is its new total.
|
||||||
// "heal": Seat's pet healed; DamageAfter is its new damage total.
|
// "heal": Seat's pet healed; DamageAfter is its new damage total.
|
||||||
|
// "setaside": Card (a fainted pet) enters Seat's set-aside zone with a
|
||||||
|
// pending effect (Blowfish, Badger, Snake, Crocodile, Turtle,
|
||||||
|
// Turkey, Mammoth).
|
||||||
|
// "release": a set-aside Card left Seat's zone, its effect consumed
|
||||||
|
// (matched by Card.ID).
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
// Seat/Target must NOT be omitempty: 0 is a valid seat (the first
|
// Seat/Target must NOT be omitempty: 0 is a valid seat (the first
|
||||||
// player) and dropping it makes the client read sides[undefined].
|
// player) and dropping it makes the client read sides[undefined].
|
||||||
@@ -125,6 +150,14 @@ type BattleResult struct {
|
|||||||
type setAsideRocks struct {
|
type setAsideRocks struct {
|
||||||
dice int
|
dice int
|
||||||
everyone bool // Badger: hits every active pet, the owner's own included
|
everyone bool // Badger: hits every active pet, the owner's own included
|
||||||
|
src Card // the fainted pet, for the set-aside display
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastPetVolley is Crocodile's set-aside: rocks that fire when the enemy plays
|
||||||
|
// the last pet in their deck.
|
||||||
|
type lastPetVolley struct {
|
||||||
|
dice int
|
||||||
|
src Card // the fainted pet, for the set-aside display
|
||||||
}
|
}
|
||||||
|
|
||||||
// battleSide is one seat's live state during the simulation.
|
// battleSide is one seat's live state during the simulation.
|
||||||
@@ -140,7 +173,8 @@ type battleSide struct {
|
|||||||
|
|
||||||
oneShotRocks []setAsideRocks // Badger/Blowfish: on next own pet play
|
oneShotRocks []setAsideRocks // Badger/Blowfish: on next own pet play
|
||||||
recurringRocks []int // Snake: on every own pet play
|
recurringRocks []int // Snake: on every own pet play
|
||||||
lastPetRocks []int // Crocodile: when the enemy plays their last pet
|
lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet
|
||||||
|
shieldCards []Card // Turtle set-aside cards, parallel to shields
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasPetInStack reports whether any pet remains face-down in the stack.
|
// hasPetInStack reports whether any pet remains face-down in the stack.
|
||||||
@@ -159,6 +193,7 @@ type queuedPlay struct {
|
|||||||
unit *BattleUnit // the unit whose play queued this; nil for set-asides
|
unit *BattleUnit // the unit whose play queued this; nil for set-asides
|
||||||
effect Effect
|
effect Effect
|
||||||
everyone bool // rock volley hits every active pet (Badger)
|
everyone bool // rock volley hits every active pet (Badger)
|
||||||
|
release *Card // set-aside card to release once this play resolves (Blowfish/Badger/Croc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectCount resolves an effect's final count: base × Per statistic,
|
// effectCount resolves an effect's final count: base × Per statistic,
|
||||||
@@ -266,24 +301,49 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hitUnit applies one attack against a seat's pet: shields block the
|
// hitUnit applies one attack against a seat's pet: shields block the
|
||||||
// whole hit, Garlic shaves per-attack damage. Returns damage dealt and
|
// whole hit, Garlic shaves per-attack damage. Returns the damage dealt and,
|
||||||
// whether a shield blocked it.
|
// when a shield absorbed the hit, a shieldBlock describing it (nil
|
||||||
hitUnit := func(seat, amount int) (dealt int, blocked bool) {
|
// otherwise). A set-aside Turtle shield is spent before the pet's own
|
||||||
|
// shields (Melon/Gorilla) so the borrowed card clears the board first.
|
||||||
|
hitUnit := func(seat, amount int) (dealt int, block *shieldBlock) {
|
||||||
u := sides[seat].unit
|
u := sides[seat].unit
|
||||||
if u == nil || amount <= 0 {
|
if u == nil || amount <= 0 {
|
||||||
return 0, false
|
return 0, nil
|
||||||
}
|
|
||||||
if u.Shield > 0 {
|
|
||||||
u.Shield--
|
|
||||||
return 0, true
|
|
||||||
}
|
}
|
||||||
if sides[seat].shields > 0 {
|
if sides[seat].shields > 0 {
|
||||||
sides[seat].shields--
|
sides[seat].shields--
|
||||||
return 0, true
|
var card *Card
|
||||||
|
source := ""
|
||||||
|
if n := len(sides[seat].shieldCards); n > 0 {
|
||||||
|
c := sides[seat].shieldCards[n-1]
|
||||||
|
sides[seat].shieldCards = sides[seat].shieldCards[:n-1]
|
||||||
|
card = &c
|
||||||
|
source = c.Name
|
||||||
|
}
|
||||||
|
return 0, &shieldBlock{source: source, release: card}
|
||||||
|
}
|
||||||
|
if n := len(u.Shields); n > 0 {
|
||||||
|
sc := u.Shields[n-1]
|
||||||
|
u.Shields = u.Shields[:n-1]
|
||||||
|
return 0, &shieldBlock{source: sc.source, release: sc.card}
|
||||||
}
|
}
|
||||||
dealt = max(0, amount-u.prevention())
|
dealt = max(0, amount-u.prevention())
|
||||||
u.Damage += dealt
|
u.Damage += dealt
|
||||||
return dealt, false
|
return dealt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitShield narrates a blocked hit — naming the source rather than a bare
|
||||||
|
// "shield" — and removes the spent card (Melon food or set-aside Turtle)
|
||||||
|
// from the play area.
|
||||||
|
emitShield := func(seat int, petName string, block *shieldBlock) {
|
||||||
|
txt := fmt.Sprintf("%s blocks the hit.", petName)
|
||||||
|
if block.source != "" {
|
||||||
|
txt = fmt.Sprintf("%s blocks the hit with a %s.", petName, block.source)
|
||||||
|
}
|
||||||
|
emit(BattleEvent{Type: "shield", Seat: seat, Text: txt})
|
||||||
|
if block.release != nil {
|
||||||
|
emit(BattleEvent{Type: "release", Seat: seat, Card: block.release})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// faint fires the unit's faint effects (its own and its perk's) in
|
// faint fires the unit's faint effects (its own and its perk's) in
|
||||||
@@ -296,6 +356,13 @@ func (g *Game) resolveBattle() {
|
|||||||
if isBee(u.Card) {
|
if isBee(u.Card) {
|
||||||
s.beesFainted++
|
s.beesFainted++
|
||||||
}
|
}
|
||||||
|
// setAside marks the fainted pet as kept beside the arena with a
|
||||||
|
// pending effect, so the client can show its card until it resolves.
|
||||||
|
setAside := func() {
|
||||||
|
c := u.Card
|
||||||
|
emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
|
||||||
|
Text: fmt.Sprintf("%s is set aside.", c.Name)})
|
||||||
|
}
|
||||||
for _, e := range u.effects() {
|
for _, e := range u.effects() {
|
||||||
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||||||
continue
|
continue
|
||||||
@@ -319,17 +386,24 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
case ActionDelayedRocks:
|
case ActionDelayedRocks:
|
||||||
s.oneShotRocks = append(s.oneShotRocks,
|
s.oneShotRocks = append(s.oneShotRocks,
|
||||||
setAsideRocks{dice: e.count(), everyone: e.Target == "all"})
|
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
|
||||||
|
setAside()
|
||||||
case ActionRecurringRocks:
|
case ActionRecurringRocks:
|
||||||
s.recurringRocks = append(s.recurringRocks, e.count())
|
s.recurringRocks = append(s.recurringRocks, e.count())
|
||||||
|
setAside()
|
||||||
case ActionEnemyLastPetRocks:
|
case ActionEnemyLastPetRocks:
|
||||||
s.lastPetRocks = append(s.lastPetRocks, e.count())
|
s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card})
|
||||||
|
setAside()
|
||||||
case ActionShieldNext:
|
case ActionShieldNext:
|
||||||
s.shields += e.count()
|
s.shields += e.count()
|
||||||
|
s.shieldCards = append(s.shieldCards, u.Card)
|
||||||
|
setAside()
|
||||||
case ActionBeeAura:
|
case ActionBeeAura:
|
||||||
s.beeBonus += e.count()
|
s.beeBonus += e.count()
|
||||||
|
setAside()
|
||||||
case ActionPetAura:
|
case ActionPetAura:
|
||||||
s.petBonus += e.count()
|
s.petBonus += e.count()
|
||||||
|
setAside()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Enemy Faints triggers on surviving pets elsewhere (Hippo).
|
// Enemy Faints triggers on surviving pets elsewhere (Hippo).
|
||||||
@@ -373,7 +447,10 @@ func (g *Game) resolveBattle() {
|
|||||||
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
||||||
}
|
}
|
||||||
case ActionShieldSelf:
|
case ActionShieldSelf:
|
||||||
u.Shield += e.count()
|
// Gorilla's own hurt-triggered block: innate, no card to drop.
|
||||||
|
for range e.count() {
|
||||||
|
u.Shields = append(u.Shields, shieldCharge{})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,7 +468,7 @@ func (g *Game) resolveBattle() {
|
|||||||
faces[i] = g.rollRockDie()
|
faces[i] = g.rollRockDie()
|
||||||
roll += faces[i]
|
roll += faces[i]
|
||||||
}
|
}
|
||||||
dealt, blocked := hitUnit(target, roll)
|
dealt, block := hitUnit(target, roll)
|
||||||
died := !tu.Alive()
|
died := !tu.Alive()
|
||||||
thrower := pname(from)
|
thrower := pname(from)
|
||||||
if su := sides[from].unit; su != nil {
|
if su := sides[from].unit; su != nil {
|
||||||
@@ -410,9 +487,8 @@ func (g *Game) resolveBattle() {
|
|||||||
Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces,
|
Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces,
|
||||||
DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt,
|
DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt,
|
||||||
})
|
})
|
||||||
if blocked {
|
if block != nil {
|
||||||
emit(BattleEvent{Type: "shield", Seat: target,
|
emitShield(target, tu.Card.Name, block)
|
||||||
Text: fmt.Sprintf("%s blocks the rocks with a shield.", tu.Card.Name)})
|
|
||||||
}
|
}
|
||||||
if died {
|
if died {
|
||||||
faint(target, tu)
|
faint(target, tu)
|
||||||
@@ -483,26 +559,45 @@ func (g *Game) resolveBattle() {
|
|||||||
// Set-aside payouts fire before the new pet's own play
|
// Set-aside payouts fire before the new pet's own play
|
||||||
// effects.
|
// effects.
|
||||||
for _, r := range s.oneShotRocks {
|
for _, r := range s.oneShotRocks {
|
||||||
|
src := r.src
|
||||||
plays = append(plays, queuedPlay{seat: seat, everyone: r.everyone,
|
plays = append(plays, queuedPlay{seat: seat, everyone: r.everyone,
|
||||||
effect: Effect{Action: ActionThrowRock, Count: r.dice}})
|
effect: Effect{Action: ActionThrowRock, Count: r.dice}, release: &src})
|
||||||
}
|
}
|
||||||
s.oneShotRocks = nil
|
s.oneShotRocks = nil
|
||||||
for _, dice := range s.recurringRocks {
|
for _, dice := range s.recurringRocks {
|
||||||
plays = append(plays, queuedPlay{seat: seat,
|
plays = append(plays, queuedPlay{seat: seat,
|
||||||
effect: Effect{Action: ActionThrowRock, Count: dice}})
|
effect: Effect{Action: ActionThrowRock, Count: dice}})
|
||||||
}
|
}
|
||||||
for _, e := range u.effects() {
|
// Walk the pet's own play effects, then its active perk's, so a
|
||||||
|
// play-time shield knows its source (an innate pet block vs a
|
||||||
|
// Melon perk that should be shown and later dropped).
|
||||||
|
addPlay := func(e Effect, perk *Card) {
|
||||||
if e.Trigger != TriggerPlay {
|
if e.Trigger != TriggerPlay {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
// Shields apply the instant the pet enters play, ahead
|
// Shields apply the instant the pet enters play, ahead of
|
||||||
// of any queued rocks (Melon).
|
// any queued rocks (Melon).
|
||||||
if e.Action == ActionShieldSelf {
|
if e.Action == ActionShieldSelf {
|
||||||
u.Shield += e.count()
|
for range e.count() {
|
||||||
continue
|
ch := shieldCharge{}
|
||||||
|
if perk != nil {
|
||||||
|
food := *perk
|
||||||
|
ch = shieldCharge{source: perk.Name, card: &food}
|
||||||
|
}
|
||||||
|
u.Shields = append(u.Shields, ch)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
||||||
}
|
}
|
||||||
|
for _, e := range u.Card.Effects {
|
||||||
|
addPlay(e, nil)
|
||||||
|
}
|
||||||
|
if perk := u.activePerk(); perk != nil {
|
||||||
|
for _, e := range perk.Effects {
|
||||||
|
addPlay(e, perk)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cross-side play triggers: Rhino rocks anyone who just played;
|
// Cross-side play triggers: Rhino rocks anyone who just played;
|
||||||
@@ -524,9 +619,10 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !sides[seat].hasPetInStack() {
|
if !sides[seat].hasPetInStack() {
|
||||||
for _, dice := range os.lastPetRocks {
|
for _, v := range os.lastPetRocks {
|
||||||
|
src := v.src
|
||||||
plays = append(plays, queuedPlay{seat: other,
|
plays = append(plays, queuedPlay{seat: other,
|
||||||
effect: Effect{Action: ActionThrowRock, Count: dice}})
|
effect: Effect{Action: ActionThrowRock, Count: v.dice}, release: &src})
|
||||||
}
|
}
|
||||||
os.lastPetRocks = nil
|
os.lastPetRocks = nil
|
||||||
}
|
}
|
||||||
@@ -582,6 +678,9 @@ func (g *Game) resolveBattle() {
|
|||||||
anyDeath = true
|
anyDeath = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if q.release != nil {
|
||||||
|
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
|
||||||
|
}
|
||||||
case ActionStripFoods:
|
case ActionStripFoods:
|
||||||
t := nextTarget(q.seat)
|
t := nextTarget(q.seat)
|
||||||
if t < 0 {
|
if t < 0 {
|
||||||
@@ -665,8 +764,8 @@ func (g *Game) resolveBattle() {
|
|||||||
// (the surrounding state is already per-seat).
|
// (the surrounding state is already per-seat).
|
||||||
ua, ub := sides[0].unit, sides[1].unit
|
ua, ub := sides[0].unit, sides[1].unit
|
||||||
powA, powB := ua.Power(), ub.Power()
|
powA, powB := ua.Power(), ub.Power()
|
||||||
dealtA, blockedA := hitUnit(0, powB)
|
dealtA, blockA := hitUnit(0, powB)
|
||||||
dealtB, blockedB := hitUnit(1, powA)
|
dealtB, blockB := hitUnit(1, powA)
|
||||||
// Scorpion: a clash attack that hurts, KOs.
|
// Scorpion: a clash attack that hurts, KOs.
|
||||||
if dealtA > 0 && ub.hasKnockout() {
|
if dealtA > 0 && ub.hasKnockout() {
|
||||||
ua.Damage = max(ua.Damage, ua.Power())
|
ua.Damage = max(ua.Damage, ua.Power())
|
||||||
@@ -706,15 +805,13 @@ func (g *Game) resolveBattle() {
|
|||||||
Died: []bool{!ua.Alive(), !ub.Alive()},
|
Died: []bool{!ua.Alive(), !ub.Alive()},
|
||||||
Text: clashTxt,
|
Text: clashTxt,
|
||||||
})
|
})
|
||||||
if blockedA {
|
if blockA != nil {
|
||||||
emit(BattleEvent{Type: "shield", Seat: 0,
|
emitShield(0, ua.Card.Name, blockA)
|
||||||
Text: fmt.Sprintf("%s blocks the hit with a shield.", ua.Card.Name)})
|
|
||||||
}
|
}
|
||||||
if blockedB {
|
if blockB != nil {
|
||||||
emit(BattleEvent{Type: "shield", Seat: 1,
|
emitShield(1, ub.Card.Name, blockB)
|
||||||
Text: fmt.Sprintf("%s blocks the hit with a shield.", ub.Card.Name)})
|
|
||||||
}
|
}
|
||||||
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && !blockedA && !blockedB {
|
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && blockA == nil && blockB == nil {
|
||||||
break // stalemate: nothing can ever change
|
break // stalemate: nothing can ever change
|
||||||
}
|
}
|
||||||
dealt := []int{dealtA, dealtB}
|
dealt := []int{dealtA, dealtB}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
// --- Tier 4 ---
|
// --- Tier 4 ---
|
||||||
|
|
||||||
@@ -165,6 +168,42 @@ func TestBlowfishDelayedRocksEnemyOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fainting Blowfish is set aside as a card (with its own ID), then released
|
||||||
|
// once the next pet plays and its volley resolves.
|
||||||
|
func TestBlowfishSetAsideThenRelease(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 }
|
||||||
|
blowfish := g.realPet(t, "Blowfish")
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{blowfish, g.pet("Next", 2)},
|
||||||
|
[]Card{g.pet("Tank", 6)},
|
||||||
|
)
|
||||||
|
setasides := eventsOfType(res, "setaside")
|
||||||
|
if len(setasides) != 1 || setasides[0].Seat != 0 || setasides[0].Card == nil ||
|
||||||
|
setasides[0].Card.ID != blowfish.ID {
|
||||||
|
t.Fatalf("blowfish should be set aside once for seat 0: %+v", setasides)
|
||||||
|
}
|
||||||
|
releases := eventsOfType(res, "release")
|
||||||
|
if len(releases) != 1 || releases[0].Seat != 0 || releases[0].Card == nil ||
|
||||||
|
releases[0].Card.ID != blowfish.ID {
|
||||||
|
t.Fatalf("blowfish should be released once for seat 0: %+v", releases)
|
||||||
|
}
|
||||||
|
// setaside on faint, then release after the volley: setaside precedes the
|
||||||
|
// rock, which precedes the release.
|
||||||
|
idx := func(typ string) int {
|
||||||
|
for i, ev := range res.Events {
|
||||||
|
if ev.Type == typ {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if !(idx("setaside") < idx("rock") && idx("rock") < idx("release")) {
|
||||||
|
t.Fatalf("expected setaside < rock < release, got %d < %d < %d",
|
||||||
|
idx("setaside"), idx("rock"), idx("release"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Skunk strips the enemy pet's foods; a wounded veteran can faint from the
|
// Skunk strips the enemy pet's foods; a wounded veteran can faint from the
|
||||||
// power loss.
|
// power loss.
|
||||||
func TestSkunkStripCanKill(t *testing.T) {
|
func TestSkunkStripCanKill(t *testing.T) {
|
||||||
@@ -441,6 +480,52 @@ func TestMelonBlocksFirstHit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A used Melon names itself in the log and is released from the play area so
|
||||||
|
// the fan clears.
|
||||||
|
func TestMelonBlockNamesSourceAndClears(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
melon := g.realFood(t, "Melon")
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{melon, g.pet("Holder", 3)},
|
||||||
|
[]Card{g.pet("Big", 5)},
|
||||||
|
)
|
||||||
|
shields := eventsOfType(res, "shield")
|
||||||
|
if len(shields) != 1 || !strings.Contains(shields[0].Text, "Melon") ||
|
||||||
|
strings.Contains(shields[0].Text, "shield") {
|
||||||
|
t.Fatalf("shield line should name the Melon, not say 'shield': %q", shields[0].Text)
|
||||||
|
}
|
||||||
|
releases := eventsOfType(res, "release")
|
||||||
|
if len(releases) != 1 || releases[0].Card == nil || releases[0].Card.ID != melon.ID {
|
||||||
|
t.Fatalf("the spent Melon should be released from the play area: %+v", releases)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With both a set-aside Turtle and a Melon protecting the same pet, the
|
||||||
|
// borrowed Turtle shield is spent first so its card clears the board.
|
||||||
|
func TestTurtleShieldSpentBeforeMelon(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
turtle := g.realPet(t, "Turtle") // power 2
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{turtle, g.realFood(t, "Melon"), g.pet("Holder", 3)},
|
||||||
|
[]Card{g.pet("Big", 5)},
|
||||||
|
)
|
||||||
|
// Turtle (2) dies to Big (5), setting aside a shield; Big keeps 2 damage.
|
||||||
|
// Holder plays with Melon (own shield). The next hit is absorbed by the
|
||||||
|
// Turtle set-aside first, releasing the Turtle; Holder then downs Big and
|
||||||
|
// its Melon shield is never spent.
|
||||||
|
releases := eventsOfType(res, "release")
|
||||||
|
if len(releases) != 1 || releases[0].Card == nil || releases[0].Card.ID != turtle.ID {
|
||||||
|
t.Fatalf("the Turtle shield should be spent (and released) before the Melon: %+v", releases)
|
||||||
|
}
|
||||||
|
shields := eventsOfType(res, "shield")
|
||||||
|
if len(shields) != 1 || !strings.Contains(shields[0].Text, "Turtle") {
|
||||||
|
t.Fatalf("the block should be credited to the Turtle: %+v", shields)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win with the Melon still in reserve, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Leopard throws rocks equal to its power, apples included.
|
// Leopard throws rocks equal to its power, apples included.
|
||||||
func TestLeopardRocksEqualPower(t *testing.T) {
|
func TestLeopardRocksEqualPower(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createPortal } from 'react-dom'
|
|||||||
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
|
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
|
||||||
import { CardView } from './CardView'
|
import { CardView } from './CardView'
|
||||||
import { DiceRoll, ROLL_MS } from './DiceRoll'
|
import { DiceRoll, ROLL_MS } from './DiceRoll'
|
||||||
import { artFor } from '../petArt'
|
|
||||||
|
|
||||||
// How long a settled rock roll (and its damage) stays on screen before the
|
// How long a settled rock roll (and its damage) stays on screen before the
|
||||||
// battle advances to the next step.
|
// battle advances to the next step.
|
||||||
@@ -30,6 +29,7 @@ interface SideVis {
|
|||||||
stack: number
|
stack: number
|
||||||
pending: Card[] // foods revealed (or prepped) waiting for a pet
|
pending: Card[] // foods revealed (or prepped) waiting for a pet
|
||||||
unit: UnitVis | null
|
unit: UnitVis | null
|
||||||
|
setAside: Card[] // fainted pets kept beside the arena with a pending effect
|
||||||
}
|
}
|
||||||
|
|
||||||
// Milliseconds each event type stays on screen during playback.
|
// Milliseconds each event type stays on screen during playback.
|
||||||
@@ -47,6 +47,8 @@ const EVENT_MS: Record<BattleEvent['type'], number> = {
|
|||||||
steal: 1100,
|
steal: 1100,
|
||||||
eat: 1000,
|
eat: 1000,
|
||||||
heal: 900,
|
heal: 900,
|
||||||
|
setaside: 700,
|
||||||
|
release: 500,
|
||||||
}
|
}
|
||||||
|
|
||||||
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
|
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
|
||||||
@@ -55,7 +57,7 @@ const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').le
|
|||||||
// seat's visual state. Units that died in the last applied event are still
|
// seat's visual state. Units that died in the last applied event are still
|
||||||
// present with dying=true so they can animate out.
|
// present with dying=true so they can animate out.
|
||||||
function replay(events: BattleEvent[], stackSizes: number[], upto: number): SideVis[] {
|
function replay(events: BattleEvent[], stackSizes: number[], upto: number): SideVis[] {
|
||||||
const sides: SideVis[] = stackSizes.map((n) => ({ stack: n, pending: [], unit: null }))
|
const sides: SideVis[] = stackSizes.map((n) => ({ stack: n, pending: [], unit: null, setAside: [] }))
|
||||||
for (let k = 0; k < upto && k < events.length; k++) {
|
for (let k = 0; k < upto && k < events.length; k++) {
|
||||||
for (const s of sides) {
|
for (const s of sides) {
|
||||||
if (s.unit?.dying) s.unit = null // clear last step's casualties
|
if (s.unit?.dying) s.unit = null // clear last step's casualties
|
||||||
@@ -145,6 +147,19 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
|||||||
}
|
}
|
||||||
case 'shield':
|
case 'shield':
|
||||||
break // pure animation; no state change
|
break // pure animation; no state change
|
||||||
|
case 'setaside':
|
||||||
|
if (ev.card) sides[ev.seat!].setAside.push(ev.card)
|
||||||
|
break
|
||||||
|
case 'release': {
|
||||||
|
// A spent card leaves the play area: a set-aside pet (Turtle) or a
|
||||||
|
// used-up food perk (Melon), which lives in the pet's food fan.
|
||||||
|
const s = sides[ev.seat!]
|
||||||
|
const id = ev.card?.id
|
||||||
|
s.setAside = s.setAside.filter((c) => c.id !== id)
|
||||||
|
if (s.unit) s.unit.foods = s.unit.foods.filter((c) => c.id !== id)
|
||||||
|
s.pending = s.pending.filter((c) => c.id !== id)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sides
|
return sides
|
||||||
@@ -247,13 +262,13 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
const won = battle.winnerSeat === youSeat
|
const won = battle.winnerSeat === youSeat
|
||||||
const draw = battle.winnerSeat < 0
|
const draw = battle.winnerSeat < 0
|
||||||
|
|
||||||
// Dice for the rock event currently on screen, shown under the thrower's
|
// Dice for the rock event currently on screen. renderSide draws them under
|
||||||
// side. `step` keys the tray so the scramble replays for every rock event.
|
// the throwing side's deck; `step` keys the tray so the scramble replays for
|
||||||
|
// every rock event.
|
||||||
const rockDice =
|
const rockDice =
|
||||||
!done && lastEvent?.type === 'rock' && lastEvent.dice && lastEvent.dice.length > 0
|
!done && lastEvent?.type === 'rock' && lastEvent.dice && lastEvent.dice.length > 0
|
||||||
? lastEvent.dice
|
? lastEvent.dice
|
||||||
: null
|
: null
|
||||||
const rockSide: 'left' | 'right' = lastEvent?.seat === youSeat ? 'left' : 'right'
|
|
||||||
|
|
||||||
function renderSide(seat: number, dir: 'left' | 'right') {
|
function renderSide(seat: number, dir: 'left' | 'right') {
|
||||||
const s = sides[seat]
|
const s = sides[seat]
|
||||||
@@ -298,21 +313,43 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
<CardView card={lastEvent.card} size="sm" dead />
|
<CardView card={lastEvent.card} size="sm" dead />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{rockDice && lastEvent?.seat === seat && (
|
||||||
|
// The dice roll sits directly under the throwing side's deck.
|
||||||
|
<div className="stack-dice">
|
||||||
|
<DiceRoll key={step} dice={rockDice} side={dir} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const foodsEl = (
|
// Set-aside pets (Blowfish, Badger, …) sit in a row above the active pet
|
||||||
<div className="pending-foods">
|
// until their pending effect resolves.
|
||||||
{s.pending.map((f) => (
|
const setAsideEl = s.setAside.length > 0 && (
|
||||||
<span key={f.id} className="food-chip" title={f.name}>
|
<div className="setaside-row">
|
||||||
{artFor(f.name)}
|
{s.setAside.map((c) => (
|
||||||
</span>
|
<CardView key={c.id} card={c} size="sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Foods attached to the pet (or, before one is in play, waiting for the
|
||||||
|
// next) fan out below it: a vertical stack overlapping ~¾, so only each
|
||||||
|
// card's top edge shows — like cards laid out on a table.
|
||||||
|
const foods = s.unit ? s.unit.foods : s.pending
|
||||||
|
const foodFanEl = foods.length > 0 && (
|
||||||
|
<div className="food-fan">
|
||||||
|
{foods.map((f, i) => (
|
||||||
|
<div key={f.id} className="food-fan-card" style={{ zIndex: i + 1 }}>
|
||||||
|
<CardView card={f} size="sm" />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const unitEl = (
|
const unitEl = (
|
||||||
<div className="battle-unit-zone">
|
<div className="battle-unit-zone">
|
||||||
|
{setAsideEl}
|
||||||
|
{foodFanEl}
|
||||||
{s.unit && (
|
{s.unit && (
|
||||||
<div
|
<div
|
||||||
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
|
key={`${s.unit.card.id}-${clashing || rockVictim ? step : 'idle'}`}
|
||||||
@@ -331,15 +368,6 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
damage={s.unit.damage}
|
damage={s.unit.damage}
|
||||||
dead={s.unit.dying}
|
dead={s.unit.dying}
|
||||||
/>
|
/>
|
||||||
{s.unit.foods.length > 0 && (
|
|
||||||
<div className="unit-foods">
|
|
||||||
{s.unit.foods.map((f) => (
|
|
||||||
<span key={f.id} title={f.name}>
|
|
||||||
{artFor(f.name)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{pop && (
|
{pop && (
|
||||||
<div key={`pop-${step}`} className={pop.startsWith('−') ? 'damage-pop' : 'fx-pop'}>
|
<div key={`pop-${step}`} className={pop.startsWith('−') ? 'damage-pop' : 'fx-pop'}>
|
||||||
{pop}
|
{pop}
|
||||||
@@ -353,13 +381,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
return dir === 'left' ? (
|
return dir === 'left' ? (
|
||||||
<div className="battle-side">
|
<div className="battle-side">
|
||||||
{stackEl}
|
{stackEl}
|
||||||
{foodsEl}
|
|
||||||
{unitEl}
|
{unitEl}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="battle-side">
|
<div className="battle-side">
|
||||||
{unitEl}
|
{unitEl}
|
||||||
{foodsEl}
|
|
||||||
{stackEl}
|
{stackEl}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -423,7 +449,6 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
|||||||
<span className="battle-center-bolt">⚡</span>
|
<span className="battle-center-bolt">⚡</span>
|
||||||
</div>
|
</div>
|
||||||
{renderSide(oppSeat, 'right')}
|
{renderSide(oppSeat, 'right')}
|
||||||
{rockDice && <DiceRoll key={step} dice={rockDice} side={rockSide} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{peek &&
|
{peek &&
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const BATTLE_ICONS: Record<BattleEvent['type'], string> = {
|
|||||||
steal: '🍎',
|
steal: '🍎',
|
||||||
eat: '🍎',
|
eat: '🍎',
|
||||||
heal: '💚',
|
heal: '💚',
|
||||||
|
setaside: '🃏',
|
||||||
|
release: '↩️',
|
||||||
}
|
}
|
||||||
|
|
||||||
// battleLogLines turns the battle events revealed up to `step` into readable
|
// battleLogLines turns the battle events revealed up to `step` into readable
|
||||||
|
|||||||
+50
-32
@@ -1197,8 +1197,11 @@ h3 {
|
|||||||
linear-gradient(180deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.18));
|
linear-gradient(180deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.18));
|
||||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 30px 14px;
|
/* Roomy top/bottom padding: pets keep their central row while set-aside
|
||||||
min-height: 244px;
|
cards fan above and attached foods fan below. The bottom needs more room
|
||||||
|
since a pet can carry several foods. */
|
||||||
|
padding: 132px 14px 208px;
|
||||||
|
min-height: 150px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
var(--tray-inset),
|
var(--tray-inset),
|
||||||
@@ -1388,33 +1391,49 @@ h3 {
|
|||||||
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
|
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- foods waiting for a pet --- */
|
|
||||||
|
|
||||||
.pending-foods {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
min-width: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unit-foods {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- the pet in play --- */
|
/* --- the pet in play --- */
|
||||||
|
|
||||||
.battle-unit-zone {
|
.battle-unit-zone {
|
||||||
|
position: relative;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Set-aside pets (Blowfish, Badger, …) laid out in a row above the active
|
||||||
|
pet, kept until their pending effect resolves. */
|
||||||
|
.setaside-row {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attached (or pending) foods fanned below the pet: a vertical stack that
|
||||||
|
overlaps ~¾ so only each card's top edge shows, last card fully on top. */
|
||||||
|
.food-fan {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% - 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-fan-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-fan-card:not(:first-child) {
|
||||||
|
margin-top: -88px;
|
||||||
|
}
|
||||||
|
|
||||||
.battle-unit {
|
.battle-unit {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1442,22 +1461,21 @@ h3 {
|
|||||||
|
|
||||||
/* --- rock dice --- */
|
/* --- rock dice --- */
|
||||||
|
|
||||||
/* A little tray of dice under the thrower's side. Fixed slots: the dice
|
/* A little tray of dice, drawn just below the throwing side's deck (see
|
||||||
scramble in place, then settle showing the rolled rock faces. */
|
.stack-dice). Fixed slots: the dice scramble in place, then settle showing
|
||||||
.dice-roll {
|
the rolled rock faces. */
|
||||||
|
.stack-dice {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
top: calc(100% + 8px);
|
||||||
display: flex;
|
left: 50%;
|
||||||
gap: 7px;
|
transform: translateX(-50%);
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
}
|
||||||
.dice-roll-left {
|
|
||||||
left: 27%;
|
.dice-roll {
|
||||||
}
|
display: flex;
|
||||||
.dice-roll-right {
|
gap: 7px;
|
||||||
left: 73%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.droll {
|
.droll {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface BattleEvent {
|
|||||||
| 'steal'
|
| 'steal'
|
||||||
| 'eat'
|
| 'eat'
|
||||||
| 'heal'
|
| 'heal'
|
||||||
|
| 'setaside'
|
||||||
|
| 'release'
|
||||||
seat?: number
|
seat?: number
|
||||||
target?: number
|
target?: number
|
||||||
card?: Card
|
card?: Card
|
||||||
|
|||||||
Reference in New Issue
Block a user