Shop-Leiste im TFT-Stil: Cards mit Splash, Trait-Pills, Shine, Upgrade-Sterne, Lock, Gold/Streak-Pills
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,3 +59,19 @@ def test_state_shape(client):
|
||||
assert key in state
|
||||
assert state["player"]["score"] == 0.0
|
||||
assert all(o["board"] is not None for o in state["opponents"])
|
||||
|
||||
|
||||
def test_upgrade_hint():
|
||||
from tft.api.app import upgrade_hint
|
||||
from tft.sim.game import Game
|
||||
static = make_static()
|
||||
static["meta"]["patch"] = "t"
|
||||
game = Game(artifact_mod.build(static), load_constants(17), seed=1)
|
||||
assert upgrade_hint(game, "U1_0") == 0
|
||||
game.bench = [{"api_name": "U1_0", "stars": 1, "items": []} for _ in range(2)]
|
||||
assert upgrade_hint(game, "U1_0") == 2
|
||||
game.bench = [{"api_name": "U1_0", "stars": 2, "items": []} for _ in range(2)] + \
|
||||
[{"api_name": "U1_0", "stars": 1, "items": []} for _ in range(2)]
|
||||
assert upgrade_hint(game, "U1_0") == 3
|
||||
game.bench = [{"api_name": "U1_0", "stars": 3, "items": []}]
|
||||
assert upgrade_hint(game, "U1_0") == 0
|
||||
|
||||
@@ -79,3 +79,11 @@ def test_invalid_actions_raise(art, cfg):
|
||||
game.buy_xp()
|
||||
with pytest.raises(InvalidAction):
|
||||
game.sell("bench", 0)
|
||||
|
||||
|
||||
def test_shop_lock_survives_step(art, cfg):
|
||||
game = Game(art, cfg, seed=2)
|
||||
game.toggle_lock()
|
||||
before = list(game.shop)
|
||||
game.step()
|
||||
assert game.shop == before
|
||||
|
||||
@@ -75,6 +75,8 @@ def action(game_id: str, req: ActionRequest) -> dict:
|
||||
game.equip(req.item_idx, req.board_idx)
|
||||
elif req.action == "pick_augment":
|
||||
game.pick_augment(req.choice)
|
||||
elif req.action == "lock":
|
||||
game.toggle_lock()
|
||||
elif req.action == "next":
|
||||
game.step()
|
||||
else:
|
||||
@@ -86,6 +88,18 @@ def action(game_id: str, req: ActionRequest) -> dict:
|
||||
return serialize(game)
|
||||
|
||||
|
||||
def upgrade_hint(game: Game, api: str) -> int:
|
||||
"""0 = kein Upgrade, 2/3 = Kauf ergibt einen 2-/3-Sterner."""
|
||||
owned = [u for u in game.board + game.bench
|
||||
if u["api_name"] == api and u["stars"] < 3]
|
||||
copies = sum(3 ** (u["stars"] - 1) for u in owned)
|
||||
if copies + 1 >= 9:
|
||||
return 3
|
||||
if sum(1 for u in owned if u["stars"] == 1) == 2:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def unit_view(game: Game, u: dict) -> dict:
|
||||
info = game.artifact["static"]["units"][u["api_name"]]
|
||||
return {
|
||||
@@ -148,12 +162,17 @@ def serialize(game: Game) -> dict:
|
||||
"api_name": api,
|
||||
"name": static["units"][api].get("name", api),
|
||||
"cost": static["units"][api]["cost"],
|
||||
"traits": [static["traits"][t].get("name", t) for t in static["units"][api]["traits"]
|
||||
if t in static["traits"]],
|
||||
"traits": [
|
||||
{"name": static["traits"][t].get("name", t),
|
||||
"icon": static["traits"][t].get("icon")}
|
||||
for t in static["units"][api]["traits"] if t in static["traits"]
|
||||
],
|
||||
"icon": static["units"][api].get("icon"),
|
||||
"upgrade": upgrade_hint(game, api),
|
||||
}
|
||||
for api in game.shop
|
||||
],
|
||||
"shop_locked": game.shop_locked,
|
||||
"traits": traits_panel,
|
||||
"augment_offer": [
|
||||
{"api_name": a, "name": static["augments"].get(a, {}).get("name", a),
|
||||
|
||||
@@ -36,6 +36,7 @@ class Game:
|
||||
self.augments: list[str] = []
|
||||
self.streak = 0
|
||||
self.shop: list[str | None] = []
|
||||
self.shop_locked = False
|
||||
self.augment_offer: list[str] = []
|
||||
self.placement: int | None = None
|
||||
self.log: list[str] = []
|
||||
@@ -166,6 +167,9 @@ class Game:
|
||||
raise InvalidAction("unit has 3 items")
|
||||
self.board[board_idx]["items"].append(self.items.pop(item_idx))
|
||||
|
||||
def toggle_lock(self) -> None:
|
||||
self.shop_locked = not self.shop_locked
|
||||
|
||||
def pick_augment(self, choice: int) -> None:
|
||||
if not self.augment_offer:
|
||||
raise InvalidAction("no augment offer")
|
||||
@@ -210,7 +214,8 @@ class Game:
|
||||
self.idx += 1
|
||||
if self.round["stage"] != prev_stage:
|
||||
self._refresh_bots()
|
||||
self._reroll_shop()
|
||||
if not self.shop_locked:
|
||||
self._reroll_shop()
|
||||
self._maybe_offer_augment()
|
||||
|
||||
def _resolve(self, rnd: dict) -> bool:
|
||||
|
||||
@@ -20,12 +20,10 @@ const kindLabel = { pvp: 'PvP', pve: 'PvE', carousel: 'Carousel' }
|
||||
<header>
|
||||
<span class="round">Runde {{ s.round.label }} · {{ kindLabel[s.round.kind] }}</span>
|
||||
<span class="stat">❤️ {{ s.player.hp }}</span>
|
||||
<span class="stat">🪙 {{ s.player.gold }}</span>
|
||||
<span class="stat">
|
||||
Lvl {{ s.player.level }}
|
||||
<template v-if="s.player.xp_needed !== null">({{ s.player.xp }}/{{ s.player.xp_needed }})</template>
|
||||
</span>
|
||||
<span class="stat">Streak {{ s.player.streak }}</span>
|
||||
<span class="score">Boardstärke {{ s.player.score }}</span>
|
||||
<button class="fight" :disabled="s.over" @click="store.next()">
|
||||
{{ s.round.kind === 'pvp' ? 'Kampf' : 'Weiter' }} ▶
|
||||
|
||||
@@ -4,57 +4,180 @@ const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shopbar">
|
||||
<div class="controls">
|
||||
<button
|
||||
:disabled="store.state.player.xp_needed === null || store.state.player.gold < store.state.xp_cost"
|
||||
@click="store.buyXp()"
|
||||
<div class="shopwrap">
|
||||
<div class="topbar">
|
||||
<span class="pill gold">🪙 {{ store.state.player.gold }}</span>
|
||||
<span
|
||||
v-if="store.state.player.streak !== 0"
|
||||
class="pill streak"
|
||||
:class="store.state.player.streak > 0 ? 'hot' : 'cold'"
|
||||
>
|
||||
XP kaufen ({{ store.state.xp_cost }}g)
|
||||
</button>
|
||||
<button :disabled="store.state.player.gold < store.state.reroll_cost" @click="store.reroll()">
|
||||
Reroll ({{ store.state.reroll_cost }}g)
|
||||
</button>
|
||||
<span class="flame">🔥</span> {{ Math.abs(store.state.player.streak) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="slots">
|
||||
<div
|
||||
v-for="(s, i) in store.state.shop"
|
||||
:key="i"
|
||||
class="slot"
|
||||
:class="{ empty: !s, unaffordable: s && s.cost > store.state.player.gold }"
|
||||
:style="s ? { borderColor: costColors[s.cost - 1] } : {}"
|
||||
@click="s && store.buy(i)"
|
||||
>
|
||||
<template v-if="s">
|
||||
<img :src="s.icon" :alt="s.name" />
|
||||
<div class="sname">{{ s.name }}</div>
|
||||
<div class="straits">{{ s.traits.join(' · ') }}</div>
|
||||
<div class="scost">{{ s.cost }}g</div>
|
||||
</template>
|
||||
|
||||
<div class="shoprow">
|
||||
<div class="controls">
|
||||
<button
|
||||
class="ctrl"
|
||||
:disabled="store.state.player.gold < store.state.reroll_cost"
|
||||
@click="store.reroll()"
|
||||
>
|
||||
<span class="ctrl-label">Refresh</span>
|
||||
<span class="ctrl-cost">🪙 {{ store.state.reroll_cost }} ⟳</span>
|
||||
</button>
|
||||
<button
|
||||
class="ctrl"
|
||||
:disabled="store.state.player.xp_needed === null || store.state.player.gold < store.state.xp_cost"
|
||||
@click="store.buyXp()"
|
||||
>
|
||||
<span class="ctrl-label">XP kaufen</span>
|
||||
<span class="ctrl-cost">🪙 {{ store.state.xp_cost }} ⌃</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div
|
||||
v-for="(s, i) in store.state.shop"
|
||||
:key="i"
|
||||
class="card"
|
||||
:class="{ empty: !s, unaffordable: s && s.cost > store.state.player.gold, upgrade: s && s.upgrade }"
|
||||
:style="s ? { borderColor: s.upgrade ? '#ffd75e' : costColors[s.cost - 1] } : {}"
|
||||
@click="s && store.buy(i)"
|
||||
>
|
||||
<template v-if="s">
|
||||
<div class="art" :style="{ backgroundImage: `url(${s.icon})` }" />
|
||||
<div class="shine" />
|
||||
<div v-if="s.upgrade" class="starhint">
|
||||
<span v-for="n in s.upgrade" :key="n">★</span>
|
||||
</div>
|
||||
<div class="traitlist">
|
||||
<span v-for="t in s.traits" :key="t.name" class="traitpill">
|
||||
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cardbottom">
|
||||
<span class="cname">{{ s.name }}</span>
|
||||
<span class="ccost">🪙 {{ s.cost }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="lock"
|
||||
:class="{ locked: store.state.shop_locked }"
|
||||
:title="store.state.shop_locked ? 'Shop entsperren' : 'Shop sperren'"
|
||||
@click="store.act({ action: 'lock' })"
|
||||
>
|
||||
{{ store.state.shop_locked ? '🔒' : '🔓' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shopbar { display: flex; gap: 8px; }
|
||||
.controls { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
|
||||
.slots { display: flex; gap: 6px; flex: 1; }
|
||||
.slot {
|
||||
.shopwrap { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.topbar { display: flex; justify-content: center; gap: 10px; }
|
||||
.pill {
|
||||
background: #161b29;
|
||||
border: 1px solid #3a4460;
|
||||
border-radius: 14px;
|
||||
padding: 3px 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pill.gold { color: #ffd75e; }
|
||||
.streak.hot { border-color: #b3543a; }
|
||||
.streak.cold { border-color: #4a7fd9; }
|
||||
.streak.cold .flame { filter: hue-rotate(190deg) saturate(2); }
|
||||
|
||||
.shoprow { display: flex; gap: 8px; align-items: stretch; }
|
||||
|
||||
.controls { display: flex; flex-direction: column; gap: 6px; width: 120px; }
|
||||
.ctrl {
|
||||
flex: 1;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: flex-start; justify-content: center; gap: 2px;
|
||||
background: linear-gradient(180deg, #1d2438, #141927);
|
||||
border: 1px solid #4a4f30;
|
||||
}
|
||||
.ctrl:hover:not(:disabled) { border-color: #8a8340; }
|
||||
.ctrl-label { font-weight: 700; font-size: 13px; }
|
||||
.ctrl-cost { font-size: 11px; color: #c9b96a; }
|
||||
|
||||
.cards { display: flex; gap: 6px; flex: 1; }
|
||||
.card {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border: 2px solid #2a3145;
|
||||
border-radius: 8px;
|
||||
background: #1a2030;
|
||||
padding: 4px;
|
||||
background: #12141d;
|
||||
height: 130px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
min-height: 96px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slot:hover:not(.empty) { filter: brightness(1.2); }
|
||||
.slot.empty { cursor: default; background: #12141d; }
|
||||
.slot.unaffordable { opacity: 0.5; }
|
||||
.slot img { width: 100%; height: 52px; object-fit: cover; border-radius: 5px; }
|
||||
.sname { font-size: 12px; font-weight: 600; }
|
||||
.straits { font-size: 10px; color: #7d86a0; }
|
||||
.scost { font-size: 11px; color: #ffd75e; }
|
||||
.card:hover:not(.empty) { filter: brightness(1.15); }
|
||||
.card.empty { cursor: default; }
|
||||
.card.unaffordable { opacity: 0.5; }
|
||||
.card.upgrade { box-shadow: 0 0 10px #ffd75e88; }
|
||||
|
||||
.art { position: absolute; inset: 0; background-size: cover; background-position: center 20%; }
|
||||
|
||||
.shine { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
|
||||
.shine::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; bottom: 0; width: 45%;
|
||||
background: linear-gradient(105deg, transparent, rgba(255, 255, 255, 0.16), transparent);
|
||||
animation: sweep 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sweep {
|
||||
0% { left: -50%; }
|
||||
55% { left: 110%; }
|
||||
100% { left: 110%; }
|
||||
}
|
||||
|
||||
.starhint {
|
||||
position: absolute;
|
||||
top: 3px; left: 0; right: 0;
|
||||
text-align: center;
|
||||
color: #ffd75e;
|
||||
text-shadow: 0 0 4px #000;
|
||||
font-size: 15px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.traitlist {
|
||||
position: absolute;
|
||||
top: 4px; left: 4px;
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.traitpill {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: rgba(8, 10, 18, 0.72);
|
||||
border-radius: 9px;
|
||||
padding: 1px 7px 1px 3px;
|
||||
font-size: 10px;
|
||||
width: fit-content;
|
||||
}
|
||||
.traitpill img { width: 13px; height: 13px; filter: brightness(0) invert(1); }
|
||||
|
||||
.cardbottom {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14px 7px 4px;
|
||||
background: linear-gradient(transparent, rgba(5, 7, 12, 0.92) 55%);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cname { font-weight: 700; }
|
||||
.ccost { color: #ffd75e; font-weight: 700; }
|
||||
|
||||
.lock {
|
||||
width: 44px;
|
||||
font-size: 17px;
|
||||
background: #141927;
|
||||
}
|
||||
.lock.locked { border-color: #ffd75e; background: #2a2618; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user