M10: Vue frontend — traits, shop, bench, opponents, strength meter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-23 00:40:26 +02:00
parent 7f78c965cc
commit 61127fa7e0
15 changed files with 1877 additions and 1 deletions

View File

@@ -27,5 +27,13 @@ Riot-Dev-Key: https://developer.riotgames.com (24h gültig), in `backend/.env` a
## Server ## Server
```sh ```sh
uv run uvicorn tft.api.app:app --reload uv run uvicorn tft.api.app:app --reload --port 8123
```
## Frontend
```sh
cd frontend
npm install
npm run dev # Proxy /api -> localhost:8123
``` ```

View File

@@ -182,6 +182,13 @@ class Game:
raise InvalidAction("game over") raise InvalidAction("game over")
if self.augment_offer: if self.augment_offer:
self.pick_augment(self.rng.randrange(len(self.augment_offer))) self.pick_augment(self.rng.randrange(len(self.augment_offer)))
# Wie in TFT: freie Board-Plätze werden beim Kampfstart von der Bank aufgefüllt.
self.bench.sort(
key=lambda u: -self.artifact["static"]["units"][u["api_name"]]["cost"]
* 3 ** (u["stars"] - 1)
)
while len(self.board) < self.level and self.bench:
self.board.append(self.bench.pop(0))
rnd = self.round rnd = self.round
won = self._resolve(rnd) won = self._resolve(rnd)

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TFT-Simulator</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1361
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "tft-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"vite": "^6.0.0"
}
}

164
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,164 @@
<script setup>
import { computed } from 'vue'
import { store } from './store.js'
import TraitPanel from './components/TraitPanel.vue'
import OpponentList from './components/OpponentList.vue'
import Shop from './components/Shop.vue'
import UnitChip from './components/UnitChip.vue'
const s = computed(() => store.state)
const kindLabel = { pvp: 'PvP', pve: 'PvE', carousel: 'Carousel' }
</script>
<template>
<div v-if="!s" class="splash">
<h1>TFT-Simulator</h1>
<button class="big" @click="store.start()">Neues Spiel</button>
</div>
<div v-else class="layout">
<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' }}
</button>
</header>
<div v-if="store.error" class="error">{{ store.error }}</div>
<div v-if="s.over" class="gameover">
<h2>Platz {{ s.placement }}</h2>
<button class="big" @click="store.start()">Nochmal</button>
</div>
<div v-if="s.augment_offer.length" class="augments">
<h3>Augment wählen</h3>
<div class="offer">
<button v-for="(a, i) in s.augment_offer" :key="a.api_name" @click="store.pickAugment(i)">
<img v-if="a.icon" :src="a.icon" :alt="a.name" />
{{ a.name }}
</button>
</div>
</div>
<main>
<TraitPanel :traits="s.traits" />
<section class="center">
<div class="zone">
<h3>Board ({{ s.board.length }}/{{ s.player.level }})</h3>
<div class="unitrow">
<UnitChip
v-for="(u, i) in s.board"
:key="'b' + i"
:unit="u"
sellable
@click="store.selectedItem !== null ? store.equip(i) : store.move('board', i)"
@sell="store.sell('board', i)"
/>
</div>
</div>
<div class="zone">
<h3>Bank ({{ s.bench.length }}/9)</h3>
<div class="unitrow">
<UnitChip
v-for="(u, i) in s.bench"
:key="'n' + i"
:unit="u"
sellable
@click="store.move('bench', i)"
@sell="store.sell('bench', i)"
/>
</div>
</div>
<div class="zone" v-if="s.items.length">
<h3>Items {{ store.selectedItem !== null ? '— Ziel-Unit anklicken' : '' }}</h3>
<div class="itemrow">
<img
v-for="(it, i) in s.items"
:key="i"
:src="it.icon"
:alt="it.name"
:title="it.name"
:class="{ selected: store.selectedItem === i }"
@click="store.selectedItem = store.selectedItem === i ? null : i"
/>
</div>
</div>
<div class="zone log">
<div v-for="(line, i) in [...s.log].reverse()" :key="i">{{ line }}</div>
</div>
</section>
<OpponentList :opponents="s.opponents" />
</main>
<footer>
<Shop />
</footer>
</div>
</template>
<style scoped>
.splash {
height: 100vh;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px;
}
.big { font-size: 18px; padding: 12px 28px; }
.layout {
height: 100vh;
display: flex; flex-direction: column;
padding: 10px; gap: 8px;
max-width: 1400px; margin: 0 auto;
}
header {
display: flex; align-items: center; gap: 16px;
background: #161b29; border-radius: 8px; padding: 8px 14px;
}
.round { font-weight: 700; }
.stat { color: #c6cddd; }
.score { margin-left: auto; font-weight: 700; color: #7de3d4; }
.fight { background: #2b4a33; border-color: #4caf6e; font-weight: 700; }
.error {
background: #3d2330; border: 1px solid #a5476a;
border-radius: 6px; padding: 6px 12px;
}
.gameover {
background: #161b29; border-radius: 8px; padding: 16px;
display: flex; align-items: center; gap: 16px;
}
.augments { background: #1d2438; border-radius: 8px; padding: 10px 14px; }
.augments h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 6px; }
.offer { display: flex; gap: 8px; }
.offer button { display: flex; align-items: center; gap: 8px; flex: 1; }
.offer img { width: 28px; height: 28px; }
main {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 220px 1fr 240px; gap: 10px;
overflow: hidden;
}
main > * { overflow-y: auto; }
.center { display: flex; flex-direction: column; gap: 10px; }
.zone h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 5px; }
.unitrow { display: flex; flex-wrap: wrap; gap: 6px; min-height: 46px; }
.itemrow { display: flex; flex-wrap: wrap; gap: 5px; }
.itemrow img {
width: 34px; height: 34px; border-radius: 6px;
border: 2px solid #3a4460; cursor: pointer;
}
.itemrow img.selected { border-color: #7de3d4; }
.log { color: #7d86a0; font-size: 12px; margin-top: auto; }
</style>

14
frontend/src/api.js Normal file
View File

@@ -0,0 +1,14 @@
async function request(method, url, body) {
const resp = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
const data = await resp.json()
if (!resp.ok) throw new Error(data.detail || resp.statusText)
return data
}
export const newGame = () => request('POST', '/api/game')
export const doAction = (gameId, action) =>
request('POST', `/api/game/${gameId}/action`, action)

View File

@@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue'
defineProps({ opponents: { type: Array, required: true } })
const expanded = ref(null)
</script>
<template>
<aside class="opponents">
<h3>Gegner</h3>
<div
v-for="o in opponents"
:key="o.name"
class="opp"
:class="{ dead: !o.alive }"
@click="expanded = expanded === o.name ? null : o.name"
>
<div class="row">
<span class="oname">{{ o.name }}</span>
<span class="arch">{{ o.archetype }}</span>
<span v-if="o.alive" class="hp">{{ o.hp }}</span>
<span v-else class="placement">#{{ o.placement }}</span>
</div>
<div v-if="o.alive" class="hpbar"><div :style="{ width: o.hp + '%' }" /></div>
<div v-if="expanded === o.name && o.alive" class="scout">
<div>Level {{ o.level }} · Stärke {{ o.score }}</div>
<div class="units">
<img
v-for="(u, i) in o.board"
:key="i"
:src="u.icon"
:alt="u.name"
:title="`${u.name} ${'★'.repeat(u.stars)}`"
/>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.opponents { display: flex; flex-direction: column; gap: 6px; }
h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 2px; }
.opp { background: #161b29; border-radius: 6px; padding: 6px 8px; cursor: pointer; }
.opp.dead { opacity: 0.4; }
.row { display: flex; align-items: center; gap: 6px; }
.oname { flex: 1; font-weight: 600; }
.arch { color: #7d86a0; font-size: 11px; }
.hp { font-weight: 700; color: #7de37d; }
.placement { color: #8a92a8; }
.hpbar { height: 4px; background: #232a3d; border-radius: 2px; margin-top: 4px; }
.hpbar div { height: 100%; background: #4caf6e; border-radius: 2px; }
.scout { margin-top: 6px; font-size: 12px; color: #aab2c8; }
.units { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }
.units img { width: 26px; height: 26px; border-radius: 4px; object-fit: cover; }
</style>

View File

@@ -0,0 +1,60 @@
<script setup>
import { store } from '../store.js'
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()"
>
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>
</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>
</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 {
flex: 1;
border: 2px solid #2a3145;
border-radius: 8px;
background: #1a2030;
padding: 4px;
cursor: pointer;
text-align: center;
min-height: 96px;
}
.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; }
</style>

View File

@@ -0,0 +1,50 @@
<script setup>
defineProps({ traits: { type: Array, required: true } })
const tierColors = ['#4a4f5d', '#a8703d', '#b9bec9', '#e8c35a', '#7de3d4']
</script>
<template>
<aside class="traits">
<h3>Traits</h3>
<div v-if="!traits.length" class="empty">Keine Units auf dem Board</div>
<div
v-for="t in traits"
:key="t.api_name"
class="trait"
:class="{ active: t.tier > 0 }"
>
<span class="badge" :style="{ background: tierColors[Math.min(t.tier, 4)] }">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />
</span>
<span class="count">{{ t.count }}</span>
<span class="tname">{{ t.name }}</span>
<span class="bp">{{ t.breakpoints.join(' ') }}</span>
</div>
</aside>
</template>
<style scoped>
.traits { display: flex; flex-direction: column; gap: 4px; }
h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 4px; }
.empty { color: #5a6178; font-size: 12px; }
.trait {
display: flex;
align-items: center;
gap: 6px;
background: #161b29;
border-radius: 6px;
padding: 4px 6px;
opacity: 0.55;
}
.trait.active { opacity: 1; }
.badge {
width: 24px; height: 24px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.badge img { width: 16px; height: 16px; filter: brightness(0) invert(1); }
.count { font-weight: 700; min-width: 14px; text-align: center; }
.tname { flex: 1; font-size: 13px; }
.bp { color: #7d86a0; font-size: 11px; }
</style>

View File

@@ -0,0 +1,50 @@
<script setup>
defineProps({
unit: { type: Object, required: true },
sellable: { type: Boolean, default: false },
})
defineEmits(['click', 'sell'])
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
</script>
<template>
<div class="chip" :style="{ borderColor: costColors[unit.cost - 1] }" @click="$emit('click')">
<img v-if="unit.icon" :src="unit.icon" :alt="unit.name" class="portrait" />
<div class="info">
<span class="stars">{{ '★'.repeat(unit.stars) }}</span>
<span class="name">{{ unit.name }}</span>
<span v-if="unit.items.length" class="items">
<img v-for="(it, i) in unit.items" :key="i" :src="it.icon" :alt="it.name" :title="it.name" />
</span>
</div>
<button v-if="sellable" class="sell" title="Verkaufen" @click.stop="$emit('sell')"></button>
</div>
</template>
<style scoped>
.chip {
display: flex;
align-items: center;
gap: 6px;
background: #1a2030;
border: 2px solid;
border-radius: 8px;
padding: 3px 6px 3px 3px;
cursor: pointer;
position: relative;
}
.chip:hover { filter: brightness(1.2); }
.portrait { width: 36px; height: 36px; border-radius: 6px; object-fit: cover; }
.info { display: flex; flex-direction: column; line-height: 1.15; }
.stars { color: #ffd75e; font-size: 10px; letter-spacing: 1px; }
.name { font-size: 12px; }
.items { display: flex; gap: 2px; margin-top: 1px; }
.items img { width: 14px; height: 14px; border-radius: 3px; }
.sell {
padding: 0 4px;
font-size: 10px;
background: #3d2330;
border-color: #6a3a50;
margin-left: 2px;
}
</style>

5
frontend/src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

41
frontend/src/store.js Normal file
View File

@@ -0,0 +1,41 @@
import { reactive } from 'vue'
import { newGame, doAction } from './api.js'
export const store = reactive({
gameId: null,
state: null,
error: null,
selectedItem: null,
async start() {
this.error = null
const data = await newGame()
this.gameId = data.game_id
this.state = data
},
async act(action) {
this.error = null
try {
this.state = await doAction(this.gameId, action)
} catch (e) {
this.error = e.message
setTimeout(() => { if (this.error === e.message) this.error = null }, 2500)
}
},
buy(slot) { return this.act({ action: 'buy', slot }) },
sell(where, idx) { return this.act({ action: 'sell', where, idx }) },
reroll() { return this.act({ action: 'reroll' }) },
buyXp() { return this.act({ action: 'buy_xp' }) },
move(where, idx) { return this.act({ action: 'move', where, idx }) },
next() { return this.act({ action: 'next' }) },
pickAugment(choice) { return this.act({ action: 'pick_augment', choice }) },
equip(boardIdx) {
if (this.selectedItem === null) return
const itemIdx = this.selectedItem
this.selectedItem = null
return this.act({ action: 'equip', item_idx: itemIdx, board_idx: boardIdx })
},
})

20
frontend/src/style.css Normal file
View File

@@ -0,0 +1,20 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #10141f;
color: #e6e8ee;
font-size: 14px;
}
button {
font: inherit;
color: inherit;
background: #232a3d;
border: 1px solid #3a4460;
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
}
button:hover:not(:disabled) { background: #2e3750; border-color: #56638a; }
button:disabled { opacity: 0.45; cursor: default; }

11
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:8123',
},
},
})