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:
team3
2026-07-23 05:47:52 +02:00
parent d191af34ca
commit 50c3f2857e
6 changed files with 214 additions and 45 deletions

View File

@@ -59,3 +59,19 @@ def test_state_shape(client):
assert key in state assert key in state
assert state["player"]["score"] == 0.0 assert state["player"]["score"] == 0.0
assert all(o["board"] is not None for o in state["opponents"]) 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

View File

@@ -79,3 +79,11 @@ def test_invalid_actions_raise(art, cfg):
game.buy_xp() game.buy_xp()
with pytest.raises(InvalidAction): with pytest.raises(InvalidAction):
game.sell("bench", 0) 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

View File

@@ -75,6 +75,8 @@ def action(game_id: str, req: ActionRequest) -> dict:
game.equip(req.item_idx, req.board_idx) game.equip(req.item_idx, req.board_idx)
elif req.action == "pick_augment": elif req.action == "pick_augment":
game.pick_augment(req.choice) game.pick_augment(req.choice)
elif req.action == "lock":
game.toggle_lock()
elif req.action == "next": elif req.action == "next":
game.step() game.step()
else: else:
@@ -86,6 +88,18 @@ def action(game_id: str, req: ActionRequest) -> dict:
return serialize(game) 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: def unit_view(game: Game, u: dict) -> dict:
info = game.artifact["static"]["units"][u["api_name"]] info = game.artifact["static"]["units"][u["api_name"]]
return { return {
@@ -148,12 +162,17 @@ def serialize(game: Game) -> dict:
"api_name": api, "api_name": api,
"name": static["units"][api].get("name", api), "name": static["units"][api].get("name", api),
"cost": static["units"][api]["cost"], "cost": static["units"][api]["cost"],
"traits": [static["traits"][t].get("name", t) for t in static["units"][api]["traits"] "traits": [
if t in static["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"), "icon": static["units"][api].get("icon"),
"upgrade": upgrade_hint(game, api),
} }
for api in game.shop for api in game.shop
], ],
"shop_locked": game.shop_locked,
"traits": traits_panel, "traits": traits_panel,
"augment_offer": [ "augment_offer": [
{"api_name": a, "name": static["augments"].get(a, {}).get("name", a), {"api_name": a, "name": static["augments"].get(a, {}).get("name", a),

View File

@@ -36,6 +36,7 @@ class Game:
self.augments: list[str] = [] self.augments: list[str] = []
self.streak = 0 self.streak = 0
self.shop: list[str | None] = [] self.shop: list[str | None] = []
self.shop_locked = False
self.augment_offer: list[str] = [] self.augment_offer: list[str] = []
self.placement: int | None = None self.placement: int | None = None
self.log: list[str] = [] self.log: list[str] = []
@@ -166,6 +167,9 @@ class Game:
raise InvalidAction("unit has 3 items") raise InvalidAction("unit has 3 items")
self.board[board_idx]["items"].append(self.items.pop(item_idx)) 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: def pick_augment(self, choice: int) -> None:
if not self.augment_offer: if not self.augment_offer:
raise InvalidAction("no augment offer") raise InvalidAction("no augment offer")
@@ -210,6 +214,7 @@ class Game:
self.idx += 1 self.idx += 1
if self.round["stage"] != prev_stage: if self.round["stage"] != prev_stage:
self._refresh_bots() self._refresh_bots()
if not self.shop_locked:
self._reroll_shop() self._reroll_shop()
self._maybe_offer_augment() self._maybe_offer_augment()

View File

@@ -20,12 +20,10 @@ const kindLabel = { pvp: 'PvP', pve: 'PvE', carousel: 'Carousel' }
<header> <header>
<span class="round">Runde {{ s.round.label }} · {{ kindLabel[s.round.kind] }}</span> <span class="round">Runde {{ s.round.label }} · {{ kindLabel[s.round.kind] }}</span>
<span class="stat"> {{ s.player.hp }}</span> <span class="stat"> {{ s.player.hp }}</span>
<span class="stat">🪙 {{ s.player.gold }}</span>
<span class="stat"> <span class="stat">
Lvl {{ s.player.level }} Lvl {{ s.player.level }}
<template v-if="s.player.xp_needed !== null">({{ s.player.xp }}/{{ s.player.xp_needed }})</template> <template v-if="s.player.xp_needed !== null">({{ s.player.xp }}/{{ s.player.xp_needed }})</template>
</span> </span>
<span class="stat">Streak {{ s.player.streak }}</span>
<span class="score">Boardstärke {{ s.player.score }}</span> <span class="score">Boardstärke {{ s.player.score }}</span>
<button class="fight" :disabled="s.over" @click="store.next()"> <button class="fight" :disabled="s.over" @click="store.next()">
{{ s.round.kind === 'pvp' ? 'Kampf' : 'Weiter' }} {{ s.round.kind === 'pvp' ? 'Kampf' : 'Weiter' }}

View File

@@ -4,57 +4,180 @@ const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
</script> </script>
<template> <template>
<div class="shopbar"> <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'"
>
<span class="flame">🔥</span> {{ Math.abs(store.state.player.streak) }}
</span>
</div>
<div class="shoprow">
<div class="controls"> <div class="controls">
<button <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" :disabled="store.state.player.xp_needed === null || store.state.player.gold < store.state.xp_cost"
@click="store.buyXp()" @click="store.buyXp()"
> >
XP kaufen ({{ store.state.xp_cost }}g) <span class="ctrl-label">XP kaufen</span>
</button> <span class="ctrl-cost">🪙 {{ store.state.xp_cost }} </span>
<button :disabled="store.state.player.gold < store.state.reroll_cost" @click="store.reroll()">
Reroll ({{ store.state.reroll_cost }}g)
</button> </button>
</div> </div>
<div class="slots">
<div class="cards">
<div <div
v-for="(s, i) in store.state.shop" v-for="(s, i) in store.state.shop"
:key="i" :key="i"
class="slot" class="card"
:class="{ empty: !s, unaffordable: s && s.cost > store.state.player.gold }" :class="{ empty: !s, unaffordable: s && s.cost > store.state.player.gold, upgrade: s && s.upgrade }"
:style="s ? { borderColor: costColors[s.cost - 1] } : {}" :style="s ? { borderColor: s.upgrade ? '#ffd75e' : costColors[s.cost - 1] } : {}"
@click="s && store.buy(i)" @click="s && store.buy(i)"
> >
<template v-if="s"> <template v-if="s">
<img :src="s.icon" :alt="s.name" /> <div class="art" :style="{ backgroundImage: `url(${s.icon})` }" />
<div class="sname">{{ s.name }}</div> <div class="shine" />
<div class="straits">{{ s.traits.join(' · ') }}</div> <div v-if="s.upgrade" class="starhint">
<div class="scost">{{ s.cost }}g</div> <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> </template>
</div> </div>
</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> </div>
</template> </template>
<style scoped> <style scoped>
.shopbar { display: flex; gap: 8px; } .shopwrap { display: flex; flex-direction: column; gap: 6px; }
.controls { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
.slots { display: flex; gap: 6px; flex: 1; } .topbar { display: flex; justify-content: center; gap: 10px; }
.slot { .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; 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: 2px solid #2a3145;
border-radius: 8px; border-radius: 8px;
background: #1a2030; background: #12141d;
padding: 4px; height: 130px;
cursor: pointer; cursor: pointer;
text-align: center; overflow: hidden;
min-height: 96px;
} }
.slot:hover:not(.empty) { filter: brightness(1.2); } .card:hover:not(.empty) { filter: brightness(1.15); }
.slot.empty { cursor: default; background: #12141d; } .card.empty { cursor: default; }
.slot.unaffordable { opacity: 0.5; } .card.unaffordable { opacity: 0.5; }
.slot img { width: 100%; height: 52px; object-fit: cover; border-radius: 5px; } .card.upgrade { box-shadow: 0 0 10px #ffd75e88; }
.sname { font-size: 12px; font-weight: 600; }
.straits { font-size: 10px; color: #7d86a0; } .art { position: absolute; inset: 0; background-size: cover; background-position: center 20%; }
.scost { font-size: 11px; color: #ffd75e; }
.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> </style>