M10: Vue frontend — traits, shop, bench, opponents, strength meter
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal 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
1361
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
frontend/package.json
Normal file
18
frontend/package.json
Normal 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
164
frontend/src/App.vue
Normal 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
14
frontend/src/api.js
Normal 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)
|
||||
55
frontend/src/components/OpponentList.vue
Normal file
55
frontend/src/components/OpponentList.vue
Normal 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>
|
||||
60
frontend/src/components/Shop.vue
Normal file
60
frontend/src/components/Shop.vue
Normal 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>
|
||||
50
frontend/src/components/TraitPanel.vue
Normal file
50
frontend/src/components/TraitPanel.vue
Normal 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>
|
||||
50
frontend/src/components/UnitChip.vue
Normal file
50
frontend/src/components/UnitChip.vue
Normal 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
5
frontend/src/main.js
Normal 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
41
frontend/src/store.js
Normal 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
20
frontend/src/style.css
Normal 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
11
frontend/vite.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user