M10: Vue frontend — traits, shop, bench, opponents, strength meter
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
10
README.md
10
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
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