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 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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' }} ▶
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user