This commit is contained in:
team3
2026-09-07 07:44:09 +02:00
parent 848c8ea1aa
commit e12c2595fc
69 changed files with 7080 additions and 1165 deletions

View File

@@ -1,53 +1,109 @@
<script setup>
import { ref, watch } from 'vue'
import GroupPanel from './components/GroupPanel.vue'
import RolePanel from './components/RolePanel.vue'
import CarryPanel from './components/CarryPanel.vue'
import PairPanel from './components/PairPanel.vue'
// Reiter neben dem Spiel — reine Statistik, kein Spielzustand.
// Der aktive Reiter steht in der URL, damit ein Reload dort bleibt.
const TABS = ['spiel', 'gruppen', 'rollen', 'carries', 'paare']
const fromHash = () => TABS.find((t) => t === location.hash.slice(1)) || 'spiel'
const tab = ref(fromHash())
watch(tab, (t) => {
history.replaceState(null, '', t === 'spiel' ? location.pathname : `#${t}`)
})
// Vor und Zurück im Browser sollen den Reiter mitnehmen.
window.addEventListener('hashchange', () => { tab.value = fromHash() })
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'
import HexGrid from './components/HexGrid.vue'
import BattleView from './components/BattleView.vue'
const s = computed(() => store.state)
const kindLabel = { pvp: 'PvP', pve: 'PvE', carousel: 'Carousel' }
const kindIcon = { pvp: '⚔️', pve: '🐺', carousel: '🎠' }
function dropOnBench() {
const d = store.dragging
store.dragging = null
if (d?.where === 'board') store.move('board', d.idx)
}
// Ab Platz 5 verliert man HP — das ist die Linie, die zählt.
const placeClass = (p) => (p <= 2 ? 'good' : p <= 4 ? 'mid' : 'bad')
const standings = computed(() => {
const p = s.value.player
const rows = [
{
name: 'Du', is_player: true, hp: Math.max(p.hp, 0), alive: p.hp > 0,
score: p.score, level: p.level, gold: p.gold, archetype: '',
off: p.off, def: p.def, level: p.level, gold: p.gold, archetype: '',
placement: s.value.placement, board: [],
},
...s.value.opponents,
]
const total = (r) => (r.off ?? 0) + (r.def ?? 0)
return rows.sort((a, b) => {
if (a.alive !== b.alive) return a.alive ? -1 : 1
if (!a.alive) return (a.placement ?? 9) - (b.placement ?? 9)
return (b.score ?? 0) - (a.score ?? 0)
return total(b) - total(a)
})
})
</script>
<template>
<div v-if="!s" class="splash">
<nav class="toptabs">
<button :class="{ on: tab === 'spiel' }" @click="tab = 'spiel'">Spiel</button>
<button :class="{ on: tab === 'gruppen' }" @click="tab = 'gruppen'">Gruppen</button>
<button :class="{ on: tab === 'rollen' }" @click="tab = 'rollen'">Rollen</button>
<button :class="{ on: tab === 'carries' }" @click="tab = 'carries'">Carries</button>
<button :class="{ on: tab === 'paare' }" @click="tab = 'paare'">Paare</button>
</nav>
<GroupPanel v-if="tab === 'gruppen'" />
<RolePanel v-else-if="tab === 'rollen'" />
<CarryPanel v-else-if="tab === 'carries'" />
<PairPanel v-else-if="tab === 'paare'" />
<div v-else-if="!s" class="splash">
<h1>TFT-Simulator</h1>
<button class="big" @click="store.start()">Neues Spiel</button>
<div v-if="store.error" class="error">{{ store.error }}</div>
</div>
<div v-else class="layout">
<header>
<span class="round">Runde {{ s.round.label }} · {{ kindLabel[s.round.kind] }}</span>
<span class="tracker">
<span class="stagebar">
<span class="stagehex">⬢</span>
<span class="stagelabel">{{ s.round.label }}</span>
<span class="tracker">
<span
v-for="r in s.stage_rounds"
:key="r.label"
class="tick"
:class="[r.kind, { done: r.done, current: r.current, augment: r.augment }]"
:title="`${r.label} · ${kindLabel[r.kind]}`"
>{{ kindIcon[r.kind] }}</span>
</span>
<span class="sep" />
<span class="strengths" title="Boardstärke — 1.0 = Ø-Champion auf 1★">
<span class="off">⚔ {{ s.player.off }}</span>
<span class="def">🛡 {{ s.player.def }}</span>
</span>
<span
v-for="r in s.stage_rounds"
:key="r.label"
class="tick"
:class="{ done: r.done, current: r.current, augment: r.augment }"
:title="r.label"
>{{ kindIcon[r.kind] }}</span>
v-if="s.player.expected_place"
class="place"
:class="placeClass(s.player.expected_place)"
title="Platz, den dein Board jetzt machen würde — Vergleich mit den sieben Gegnern"
>🏁 {{ s.player.expected_place }}</span>
</span>
<span class="stat">❤️ {{ s.player.hp }}</span>
<span class="score">Boardstärke {{ s.player.score }}</span>
<span class="hpbadge">❤️ {{ s.player.hp }}</span>
<button class="fight" :disabled="s.over" @click="store.next()">
{{ s.round.kind === 'pvp' ? 'Kampf' : 'Weiter' }} ▶
</button>
@@ -78,46 +134,44 @@ const standings = computed(() => {
</div>
<main>
<div class="itemrail">
<div class="railtitle" :title="store.selectedItem !== null ? 'Ziel-Unit anklicken' : 'Items'">🎒</div>
<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>
<TraitPanel :traits="s.traits" />
<section class="center">
<div class="sidebar">
<TraitPanel :traits="s.traits" :items="s.items" :bench-size="s.bench_size" />
<div class="zone log">
<div v-for="(line, i) in [...s.log].reverse()" :key="i">{{ line }}</div>
</div>
<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>
<section class="center">
<div class="zone field">
<h3>
Kampffeld ({{ s.board.length }}/{{ s.player.level }})
<button class="replaybtn" @click="store.loadReplay()">Kampf zeigen</button>
<button v-if="store.replay" class="replaybtn"
@click="store.replay = null">schließen</button>
</h3>
<BattleView v-if="store.replay" :log="store.replay" />
<HexGrid
v-else
:units="s.board"
:rows="s.board_grid.rows"
:cols="s.board_grid.cols"
/>
</div>
<div class="zone">
<h3>Bank ({{ s.bench.length }}/9)</h3>
<div
class="zone"
@dragover.prevent
@drop.prevent="dropOnBench()"
>
<h3>Bank ({{ s.bench.length }}/{{ s.bench_size }}) · Rechtsklick verkauft</h3>
<div class="unitrow">
<UnitChip
v-for="(u, i) in s.bench"
:key="'n' + i"
:unit="u"
sellable
:size="92"
draggable="true"
@dragstart="store.dragging = { where: 'bench', idx: i }"
@dragend="store.dragging = null"
@click="store.move('bench', i)"
@sell="store.sell('bench', i)"
/>
@@ -145,31 +199,64 @@ const standings = computed(() => {
.layout {
height: 100vh;
display: flex; flex-direction: column;
padding: 10px; gap: 8px;
max-width: 1400px; margin: 0 auto;
padding: 12px 18px; gap: 10px;
}
header {
display: flex; align-items: center; gap: 16px;
background: #161b29; border-radius: 8px; padding: 8px 14px;
/* Kopfleiste mittig wie im Spiel; HP und Weiter hängen an den Rändern. */
header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; }
header > .stagebar { grid-column: 2; justify-self: center; }
header > .hpbadge { grid-column: 1; justify-self: start; }
header > .fight { grid-column: 3; justify-self: end; }
/* Kopfleiste wie im Spiel: eine schmale dunkle Pille, Runde links,
Rundensymbole in der Mitte, rechts statt Timer die Boardstärke. */
.stagebar {
display: flex; align-items: center; gap: 9px;
background: #0e1119; border: 1px solid #2a3145;
border-radius: 6px; padding: 3px 12px;
}
.round { font-weight: 700; }
.tracker { display: flex; gap: 3px; }
.stagehex { color: #8fe3d4; font-size: 13px; }
.stagelabel { font-weight: 800; font-size: 14px; letter-spacing: 0.5px; }
.tracker { display: flex; gap: 2px; }
.tick {
font-size: 13px;
opacity: 0.35;
filter: grayscale(0.7);
padding: 1px 3px;
border-radius: 4px;
font-size: 13px; line-height: 1;
opacity: 0.3; filter: grayscale(0.8);
padding: 2px 3px; border-radius: 3px;
}
.tick.done { opacity: 0.15; }
.tick.current {
opacity: 1; filter: none;
background: #2a3145;
outline: 1px solid #56638a;
/* Gespielte Runden bekommen ein rotes Kreuz, wie im Spiel. */
.tick { position: relative; }
.tick.done { opacity: 0.55; filter: grayscale(1) brightness(0.65); }
.tick.done::after {
content: '✕';
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #d9534f; font-size: 13px; font-weight: 800;
}
.tick.current { opacity: 1; filter: none; background: #23304a; outline: 1px solid #5f7bb0; }
.tick.augment { outline: 1px solid #ffd75e88; }
.sep { width: 1px; align-self: stretch; background: #2a3145; margin: 0 2px; }
.strengths { display: flex; gap: 11px; font-weight: 800; font-size: 13px; }
.off { color: #e5a531; }
.def { color: #7de3d4; }
.toptabs {
display: flex; gap: 4px; padding: 6px 10px 0;
border-bottom: 1px solid #1e2534;
}
.toptabs button {
background: transparent; border: none; border-bottom: 2px solid transparent;
padding: 6px 16px; font-weight: 700; color: #8a92a8; border-radius: 0;
}
.toptabs button.on { color: #dfe5f0; border-bottom-color: #4a90d9; }
.place { font-weight: 800; margin-left: 10px; }
.place.good { color: #7ee38a; }
.place.mid { color: #dfe5f0; }
.place.bad { color: #e58686; }
.hpbadge {
background: #0e1119; border: 1px solid #2a3145;
border-radius: 6px; padding: 3px 12px;
font-weight: 700; color: #ff8d8d;
}
.tick.augment { outline: 1px solid #ffd75e66; }
.stat { color: #c6cddd; }
.score { margin-left: auto; font-weight: 700; color: #7de3d4; }
.fight { background: #2b4a33; border-color: #4caf6e; font-weight: 700; }
.error {
@@ -199,27 +286,34 @@ header {
main {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 46px 210px 1fr 250px; gap: 10px;
display: grid;
grid-template-columns: clamp(230px, 16vw, 320px) 1fr clamp(230px, 17vw, 330px);
gap: 12px;
overflow: hidden;
}
main > * { overflow-y: auto; }
.itemrail {
display: flex; flex-direction: column; gap: 5px; align-items: center;
background: #12141d; border-radius: 8px; padding: 6px 4px;
}
.railtitle { font-size: 15px; opacity: 0.7; }
.itemrail img {
width: 34px; height: 34px; border-radius: 6px;
border: 2px solid #3a4460; cursor: pointer;
}
.itemrail img.selected { border-color: #7de3d4; }
.center { display: flex; flex-direction: column; gap: 10px; }
/* overflow: das Log scrollt selbst, die Spalte darf keinen zweiten Balken zeigen. */
.center { display: flex; flex-direction: column; gap: 10px; overflow: hidden; }
.zone h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 5px; }
.zone h3, .center .unitrow { justify-content: center; text-align: center; }
.unitrow { display: flex; flex-wrap: wrap; gap: 6px; min-height: 46px; }
/* Log in die Seitenspalte: die Mitte gehört komplett dem Kampffeld. */
.sidebar { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.log {
flex: 1; min-height: 0; overflow-y: auto;
color: #7d86a0; font-size: 12px;
}
.zone.field {
/* Feld gibt Höhe ab, damit die Bank-Kacheln groß sein können. */
flex: 1; min-height: 0; max-height: 50vh;
display: flex; flex-direction: column;
}
.zone.field > :last-child { flex: 1; min-height: 0; }
.unitrow { justify-content: center; }
.replaybtn {
background: #141927; border: 1px solid #2a3145; border-radius: 5px;
padding: 1px 8px; margin-left: 8px; font-size: 11px; font-weight: 700;
color: #dfe4ee;
}
</style>

View File

@@ -10,5 +10,12 @@ async function request(method, url, body) {
}
export const newGame = () => request('POST', '/api/game')
export const fetchGroups = (size, limit) =>
request('GET', `/api/groups?size=${size}&limit=${limit}`)
export const fetchRoles = () => request('GET', '/api/roles')
export const fetchCarries = () => request('GET', '/api/carries')
export const fetchPairs = () => request('GET', '/api/pairs')
export const fetchReplay = (gameId) =>
request('POST', `/api/game/${gameId}/replay`)
export const doAction = (gameId, action) =>
request('POST', `/api/game/${gameId}/action`, action)

View File

@@ -0,0 +1,220 @@
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({ log: { type: Object, default: null } })
// Sechsecke mit Spitze oben. R ist der Radius bis zur Ecke.
// Waagerechter Abstand: √3·R. Senkrechter Abstand: 1.5·R. Ungerade
// Reihen sind eine halbe Zellbreite nach rechts versetzt.
const R = 26
const COL_STEP = Math.sqrt(3) * R
const ROW_STEP = 1.5 * R
const PAD = 14
const canvas = ref(null)
const playing = ref(false)
const tick = ref(0)
const speed = ref(1)
let raf = null
let last = 0
// Zustand je Kämpfer, aus dem Protokoll aufgebaut.
const state = new Map()
// Gezeichnet wird gespiegelt: der Spieler steht unten, wie im Spiel.
// Die Reihe 0 der Engine ist die hinterste eigene, sie landet ganz unten.
function center(col, row) {
const flipped = (props.log?.rows ?? 8) - 1 - row
const shift = flipped % 2 === 1 ? COL_STEP / 2 : 0
return { x: PAD + shift + col * COL_STEP + COL_STEP / 2,
y: PAD + flipped * ROW_STEP + R }
}
// Setzt nur den Kampfzustand zurück, nicht die Zeit. Die Zeit hier mit
// zurückzusetzen war der Fehler: `step` ruft `reset` in jedem Bild auf,
// die Uhr stand dadurch dauerhaft auf null.
function reset() {
state.clear()
if (!props.log) return
for (const u of props.log.units) {
state.set(u.slot, { ...u, col: 0, row: 0, hp: u.max_hp, alive: true, flash: 0 })
}
// Startfelder stehen in den spawn-Ereignissen.
for (const e of props.log.events) {
if (e.e !== 'spawn') break
const s = state.get(e.u)
if (s) Object.assign(s, { col: e.col, row: e.row, hp: e.hp })
}
}
function applyUpTo(target) {
for (const e of props.log.events) {
if (e.t > target) break
const s = state.get(e.u)
if (!s) continue
if (e.e === 'move') { s.col = e.col; s.row = e.row }
else if (e.e === 'die') s.alive = false
else if (e.e === 'hit' || e.e === 'cast') {
const foe = state.get(e.to)
if (foe) { foe.hp = e.hp; foe.flash = e.t }
s.attacking = e.t
s.attackTarget = e.to
}
}
}
function hexPath(ctx, x, y) {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const a = (Math.PI / 180) * (60 * i - 30)
const px = x + R * Math.cos(a)
const py = y + R * Math.sin(a)
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py)
}
ctx.closePath()
}
function draw() {
const el = canvas.value
if (!el || !props.log) return
const ctx = el.getContext('2d')
ctx.clearRect(0, 0, el.width, el.height)
for (let r = 0; r < props.log.rows; r++) {
for (let c = 0; c < props.log.cols; c++) {
const { x, y } = center(c, r)
hexPath(ctx, x, y)
ctx.fillStyle = r < props.log.rows / 2 ? '#18241c' : '#241820'
ctx.fill()
ctx.strokeStyle = '#2a3145'
ctx.stroke()
}
}
for (const s of state.values()) {
if (!s.alive) continue
const { x, y } = center(s.col, s.row)
// Angriffslinie zum Ziel, solange der Schlag frisch ist.
if (s.attacking !== undefined && tick.value - s.attacking < 3) {
const foe = state.get(s.attackTarget)
if (foe && foe.alive) {
const t = center(foe.col, foe.row)
ctx.strokeStyle = s.side === 'a' ? '#7ee38a88' : '#e5868688'
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(t.x, t.y)
ctx.stroke()
}
}
const hurt = tick.value - (s.flash ?? -99) < 3
ctx.beginPath()
ctx.arc(x, y, R * 0.55, 0, Math.PI * 2)
ctx.fillStyle = hurt ? '#ffffff' : s.side === 'a' ? '#4caf6e' : '#b153d8'
ctx.fill()
// Lebensbalken
const share = Math.max(0, Math.min(1, s.hp / s.max_hp))
ctx.fillStyle = '#0d1017'
ctx.fillRect(x - R * 0.6, y - R * 0.85, R * 1.2, 4)
ctx.fillStyle = s.side === 'a' ? '#7ee38a' : '#e58686'
ctx.fillRect(x - R * 0.6, y - R * 0.85, R * 1.2 * share, 4)
ctx.fillStyle = '#dfe4ee'
ctx.font = '9px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(s.name.slice(0, 9), x, y + R * 0.95)
}
}
function lastTick() {
const events = props.log?.events
return events?.length ? events[events.length - 1].t : 0
}
function step(now) {
if (!playing.value) return
const dt = last ? (now - last) / 1000 : 0
last = now
tick.value += dt * props.log.tps * speed.value
if (tick.value >= lastTick()) {
tick.value = lastTick()
playing.value = false
}
reset()
applyUpTo(tick.value)
draw()
if (playing.value) raf = requestAnimationFrame(step)
}
function play() {
if (!props.log) return
if (tick.value >= lastTick()) tick.value = 0
playing.value = true
last = 0
raf = requestAnimationFrame(step)
}
function scrub(value) {
playing.value = false
tick.value = Number(value)
reset()
applyUpTo(tick.value)
draw()
}
// Erst nach dem Einhängen zeichnen — vorher gibt es kein Canvas.
async function show() {
playing.value = false
tick.value = 0
reset()
await nextTick()
draw()
if (lastTick() > 0) play()
}
watch(() => props.log, show)
onMounted(show)
onUnmounted(() => raf && cancelAnimationFrame(raf))
</script>
<template>
<div v-if="log" class="battle">
<div class="bar">
<button @click="playing ? (playing = false) : play()">
{{ playing ? '⏸' : '▶' }}
</button>
<input
type="range"
min="0"
:max="lastTick()"
:value="tick"
@input="scrub($event.target.value)"
/>
<span class="clock">{{ (tick / log.tps).toFixed(1) }}s</span>
<button v-for="s in [1, 2, 4]" :key="s" :class="{ on: speed === s }"
@click="speed = s">{{ s }}×</button>
<span v-if="log.winner" class="winner" :class="log.winner">
{{ log.winner === 'a' ? 'Sieg' : 'Niederlage' }}
</span>
</div>
<canvas
ref="canvas"
:width="2 * PAD + (log.cols + 0.5) * COL_STEP"
:height="2 * PAD + (log.rows - 1) * ROW_STEP + 2 * R"
/>
</div>
</template>
<style scoped>
.battle { padding: 8px 0; }
.bar { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.bar input[type='range'] { flex: 1; max-width: 420px; }
button {
background: #141927; border: 1px solid #2a3145; border-radius: 5px;
padding: 2px 10px; color: #dfe4ee; font-weight: 700;
}
button.on { border-color: #4a90d9; color: #9ccfff; background: #1b2a44; }
.clock { color: #8a92a8; font-size: 12px; width: 42px; }
.winner { font-weight: 800; }
.winner.a { color: #7ee38a; }
.winner.b { color: #e58686; }
canvas { background: #0d1017; border-radius: 6px; }
</style>

View File

@@ -0,0 +1,134 @@
<script setup>
import { onMounted, ref } from 'vue'
import { fetchCarries } from '../api.js'
const data = ref(null)
const error = ref(null)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
const sign = (n) => (n >= 0 ? '+' : '') + n.toFixed(2)
onMounted(async () => {
try {
data.value = await fetchCarries()
} catch (e) {
error.value = e.message
}
})
</script>
<template>
<div class="carries">
<h2>Beste Carries</h2>
<div v-if="error" class="error">{{ error }}</div>
<p v-else-if="!data" class="loading">lädt…</p>
<div v-else class="grid">
<div v-for="c in data.carries" :key="c.name" class="slot">
<div
class="card"
:title="`${c.name} · ${c.cost} Gold`
+ (c.tier ? ` · Tier ${c.tier}` : '')
+ ` · ${c.boards} Boards`"
>
<div class="art" :style="{ backgroundImage: `url(${c.splash || c.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[c.cost - 1] }" />
<div class="traitlist">
<span v-for="t in c.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<span v-if="c.tier" class="tier" :class="c.tier">{{ c.tier }}</span>
<span class="edge" :class="{ neg: c.edge < 0 }">{{ sign(c.edge) }}</span>
<div class="items">
<span
v-for="it in c.items"
:key="it.name"
class="item"
:title="`${it.name} — ${sign(it.edge)} Plätze, ${it.boards} Boards`"
>
<img v-if="it.icon" :src="it.icon" :alt="it.name" />
</span>
</div>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg, ${costColors[c.cost - 1]}66,`
+ ` ${costColors[c.cost - 1]}bb)` }"
>
<span class="cname">
{{ c.name }}
<span class="stars">{{ '★'.repeat(c.stars) }}</span>
</span>
<span class="ccoin">🪙 {{ c.cost }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.carries { padding: 14px 18px 40px; }
h2 { margin: 0 0 10px; font-size: 18px; }
.loading { color: #8a92a8; font-size: 12px; }
.error { color: #e58686; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.card {
position: relative; width: 100%; aspect-ratio: 1.7;
border-radius: 5px; background: #0d1017; overflow: hidden;
}
.art { position: absolute; inset: 0; background-size: cover; background-position: center 25%; }
.wash { position: absolute; inset: 0; }
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(6, 8, 14, 0.62); border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 10px; width: fit-content; text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 12px; height: 12px; filter: brightness(0) invert(1); }
.tier {
position: absolute; top: 4px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 4px; padding: 1px 8px;
font-size: 20px; font-weight: 900; letter-spacing: 0.5px; line-height: 1.15;
}
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
.edge {
position: absolute; top: 32px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 3px; padding: 0 4px;
font-size: 11px; font-weight: 800; color: #7ee38a;
}
.edge.neg { color: #e58686; }
.items { position: absolute; left: 5px; bottom: 24px; display: flex; gap: 3px; }
.item img {
display: block; width: 30px; height: 30px; border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.cardbottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 3px 7px; font-size: 12px;
}
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.stars { color: #ffd75e; font-size: 10px; letter-spacing: -1px; }
.ccoin { color: #ffd75e; font-weight: 700; }
</style>

View File

@@ -0,0 +1,156 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { fetchGroups } from '../api.js'
const size = ref(3)
const data = ref(null)
const error = ref(null)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
// Kartenfläche wird von der Kostenstufe eingefärbt, wie im Shop.
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
async function load() {
error.value = null
data.value = null
try {
data.value = await fetchGroups(size.value, 5)
} catch (e) {
error.value = e.message
}
}
onMounted(load)
watch(size, load)
</script>
<template>
<div class="groups">
<header>
<h2>Starke Gruppen</h2>
<span class="tabs">
<button
v-for="n in [2, 3, 4, 5]"
:key="n"
:class="{ on: size === n }"
@click="size = n"
>{{ n }}er</button>
</span>
</header>
<div v-if="error" class="error">{{ error }}</div>
<p v-else-if="!data" class="hint">lädt…</p>
<template v-else>
<div v-for="(g, i) in data.groups" :key="i" class="row">
<div class="head">
<span class="rank">{{ i + 1 }}</span>
<span class="edge">+{{ g.edge.toFixed(2) }}</span>
<span class="boards">{{ g.boards.toLocaleString('de-DE') }} Boards</span>
</div>
<div class="cards">
<div
v-for="u in g.units"
:key="u.name"
class="card"
:title="`${u.name} · ${u.cost} Gold · ${u.items} Items`
+ (u.tier ? ` · Tier ${u.tier}` : '')"
>
<div class="art" :style="{ backgroundImage: `url(${u.splash || u.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[u.cost - 1] }" />
<div class="traitlist">
<span v-for="t in u.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<div class="vals">
<span v-if="u.tier" class="tier" :class="u.tier">{{ u.tier }}</span>
</div>
<div class="items" :title="`${u.items} Items`">
<span v-for="n in 3" :key="n" :class="{ on: n <= u.items }" />
</div>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg, ${costColors[u.cost - 1]}66,`
+ ` ${costColors[u.cost - 1]}bb)` }"
>
<span class="cname">{{ u.name }}</span>
<span class="ccoin">🪙 {{ u.cost }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.groups { padding: 14px 18px 40px; max-width: 1180px; margin: 0 auto; }
header { display: flex; align-items: baseline; gap: 16px; }
h2 { margin: 0; font-size: 18px; }
.tabs button {
background: #141927; border: 1px solid #2a3145; border-radius: 5px;
padding: 3px 12px; margin-left: 4px; font-weight: 700;
}
.tabs button.on { border-color: #4a90d9; color: #9ccfff; background: #1b2a44; }
.hint { color: #8a92a8; font-size: 12px; margin: 8px 0 14px; max-width: 760px; }
.error { color: #e58686; }
.row { margin-bottom: 14px; }
.head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; }
.rank { color: #8a92a8; font-size: 12px; width: 20px; }
.edge { color: #7ee38a; font-weight: 800; font-size: 15px; }
.boards { color: #8a92a8; font-size: 11px; }
/* Karten wie im Shop: 1.7:1, Splash füllt die Fläche. */
.cards { display: flex; gap: 6px; flex-wrap: wrap; }
.card {
position: relative; width: 168px; aspect-ratio: 1.7;
border-radius: 5px; background: #0d1017; overflow: hidden;
}
.art { position: absolute; inset: 0; background-size: cover; background-position: center 25%; }
.wash { position: absolute; inset: 0; }
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(6, 8, 14, 0.62); border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 10px; width: fit-content; text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 12px; height: 12px; filter: brightness(0) invert(1); }
.vals {
position: absolute; top: 4px; right: 4px;
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
font-size: 11px; font-weight: 800;
}
.tier {
background: rgba(4, 6, 11, 0.78); border-radius: 3px; padding: 0 4px;
font-weight: 900; letter-spacing: 0.5px;
}
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
/* Drei Punkte = drei Item-Plätze, gefüllte zeigen die Belegung. */
.items { position: absolute; bottom: 24px; left: 6px; display: flex; gap: 3px; }
.items span {
width: 9px; height: 9px; border-radius: 2px;
background: rgba(4, 6, 11, 0.6); border: 1px solid #4a5468;
}
.items span.on { background: #ffd75e; border-color: #ffd75e; }
.cardbottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 3px 7px; font-size: 12px;
}
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.ccoin { color: #ffd75e; font-weight: 700; }
</style>

View File

@@ -0,0 +1,191 @@
<script setup>
import { computed } from 'vue'
import { store } from '../store.js'
const props = defineProps({
units: { type: Array, required: true },
rows: { type: Number, default: 4 },
cols: { type: Number, default: 7 },
})
const fmt = (n) => Number(n).toFixed(1)
const tip = (u) => `Tier ${u.tier} — Stärke ${fmt(u.strength)}`
+ ' (1.0 = Schnitt der eigenen Kostenstufe, aus echten Spielen gelernt)'
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
// hex-Id -> { unit, idx }, damit jedes Feld seine Unit direkt kennt.
const byHex = computed(() => {
const map = {}
props.units.forEach((u, idx) => {
if (u.hex !== null && u.hex !== undefined) map[u.hex] = { unit: u, idx }
})
return map
})
const grid = computed(() =>
Array.from({ length: props.rows }, (_, r) =>
Array.from({ length: props.cols }, (_, c) => r * props.cols + c)))
function onDrop(hex) {
const d = store.dragging
store.dragging = null
if (d) store.place(d.where, d.idx, hex)
}
function onClick(hex) {
const cell = byHex.value[hex]
if (!cell) return
if (store.selectedItem !== null) store.equip(cell.idx)
else store.move('board', cell.idx)
}
</script>
<template>
<div class="gridwrap">
<div class="grid">
<div v-for="(row, r) in grid" :key="r" class="hexrow" :class="{ shifted: r % 2 === 1 }">
<div
v-for="hex in row"
:key="hex"
class="hex"
:class="{ filled: byHex[hex], droppable: store.dragging }"
:style="byHex[hex] ? { '--cost': costColors[byHex[hex].unit.cost - 1] } : null"
:title="byHex[hex] ? byHex[hex].unit.name : ''"
:draggable="!!byHex[hex]"
@dragstart="byHex[hex] && (store.dragging = { where: 'board', idx: byHex[hex].idx })"
@dragend="store.dragging = null"
@dragover.prevent
@drop.prevent="onDrop(hex)"
@click="onClick(hex)"
>
<template v-if="byHex[hex]">
<img class="art" :src="byHex[hex].unit.icon" :alt="byHex[hex].unit.name" />
<span class="stars">{{ '★'.repeat(byHex[hex].unit.stars) }}</span>
<span v-if="byHex[hex].unit.items.length" class="items">
<img
v-for="(it, i) in byHex[hex].unit.items"
:key="i"
:src="it.icon"
:alt="it.name"
:title="it.name"
/>
</span>
<span
v-if="byHex[hex].unit.tier"
class="meta tier"
:class="byHex[hex].unit.tier"
:title="tip(byHex[hex].unit)"
>{{ byHex[hex].unit.tier }}</span>
<span class="vals">
<span class="off">{{ fmt(byHex[hex].unit.off) }}</span>
<span class="def">{{ fmt(byHex[hex].unit.def) }}</span>
</span>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Geometrie am Original vermessen (MetaTFT Team Builder):
Hex 91.9 x 106.1 -> regelmäßiges Sechseck mit Spitze oben (h = w * 2/√3).
Spalten-Pitch 1.1 * w, Reihen-Pitch 0.826 * h — die Reihen greifen ineinander. */
.gridwrap {
/* Größen-Container: das Grid darf Breite UND Höhe des Platzes ausmessen. */
container-type: size;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
.grid {
/* Gesamtmaße in Hex-Breiten: 8.2 breit (Versatz + 6 Pitches + 1 Hex),
4.05 hoch (3 Reihen-Pitches + 1 Hex). Die engere Achse gewinnt. */
--hex-w: min(calc(100cqw / 8.2), calc(100cqh / 4.05));
--hex-h: calc(var(--hex-w) * 1.1545);
--hex-shape: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
display: flex; flex-direction: column;
width: fit-content;
}
.hexrow { display: flex; gap: calc(var(--hex-w) * 0.1); }
.hexrow + .hexrow { margin-top: calc(var(--hex-h) * -0.174); }
.hexrow.shifted { margin-left: calc(var(--hex-w) * 0.55); }
.hex {
position: relative;
width: var(--hex-w);
height: var(--hex-h);
background: #232a3d;
/* clip-path begrenzt auch die Trefferfläche — die Boxen dürfen überlappen. */
clip-path: var(--hex-shape);
transition: background 0.12s;
}
.hex.droppable { background: #2f3a55; }
.hex.filled { background: var(--cost); cursor: pointer; }
.hex.filled:hover { filter: brightness(1.25); }
.art {
position: absolute;
inset: 2px;
width: calc(100% - 4px);
height: calc(100% - 4px);
object-fit: cover;
clip-path: var(--hex-shape);
pointer-events: none;
}
.stars {
position: absolute;
top: 12%; left: 0; right: 0;
text-align: center;
font-size: calc(var(--hex-w) * 0.17);
letter-spacing: 1px;
color: #ffd75e;
text-shadow: 0 0 3px #000;
pointer-events: none;
}
.items {
position: absolute;
bottom: 36%; left: 0; right: 0;
display: flex; justify-content: center; gap: 2px;
pointer-events: none;
}
.items img {
width: calc(var(--hex-w) * 0.2);
height: calc(var(--hex-w) * 0.2);
border-radius: 3px;
}
.vals {
position: absolute;
/* Auf 18% Höhe ist das Hex noch ~80% breit; weiter unten läuft es
spitz zu und clip-path schneidet die Zahlen ab. */
bottom: 18%; left: 13%; right: 13%;
display: flex; justify-content: space-between;
font-size: calc(var(--hex-w) * 0.16);
font-weight: 800;
pointer-events: none;
}
/* Dunkle Pille dahinter — auf hellen Portraits waren die Zahlen sonst weg. */
.off, .def {
background: rgba(4, 6, 11, 0.8);
border-radius: 3px;
padding: 0 3px;
}
.off { color: #e5a531; }
.def { color: #7de3d4; }
/* Meta sitzt zwischen Sternen und Portraitmitte, wo das Hex breit ist. */
.meta {
position: absolute;
top: 30%; left: 0; right: 0;
text-align: center;
font-size: calc(var(--hex-w) * 0.15);
font-weight: 800;
text-shadow: 0 0 4px #000, 0 1px 2px #000;
pointer-events: none;
}
/* Tier-Farben wie im Spiel gewohnt: S golden, D grau-rot. */
.tier { font-weight: 900; letter-spacing: 0.5px; }
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
</style>

View File

@@ -2,62 +2,101 @@
import { ref } from 'vue'
defineProps({ players: { type: Array, required: true } })
const expanded = ref(null)
// Ring um das Portrait färbt sich mit dem Leben, wie im Spiel.
function ring(hp) {
if (hp > 60) return '#4caf6e'
if (hp > 30) return '#e5a531'
return '#d9534f'
}
</script>
<template>
<aside class="opponents">
<h3>Spieler</h3>
<aside class="players">
<div
v-for="p in players"
:key="p.name"
class="opp"
class="row"
:class="{ dead: !p.alive, me: p.is_player }"
@click="!p.is_player && (expanded = expanded === p.name ? null : p.name)"
>
<div class="row">
<span class="oname">{{ p.name }}</span>
<span v-if="p.archetype" class="arch">{{ p.archetype }}</span>
<span v-if="p.alive && p.level" class="lvl">L{{ p.level }}</span>
<span v-if="p.alive && p.gold !== undefined" class="goldval">🪙{{ p.gold }}</span>
<span v-if="p.alive" class="strength" title="Boardstärke">⚡ {{ p.score }}</span>
<span v-if="p.alive" class="hp">{{ p.hp }}</span>
<span v-else class="placement">#{{ p.placement }}</span>
</div>
<div v-if="p.alive" class="hpbar"><div :style="{ width: p.hp + '%' }" /></div>
<span class="emblem" :style="{ borderColor: ring(p.hp) }">{{ p.name[0] }}</span>
<span class="mid">
<span class="name">{{ p.name }}</span>
<span v-if="p.alive" class="stats">
<span v-if="p.level" class="lvl">L{{ p.level }}</span>
<span class="off">⚔ {{ p.off }}</span>
<span class="def">🛡 {{ p.def }}</span>
</span>
<span v-else class="stats">ausgeschieden</span>
</span>
<span class="hp" :style="{ color: ring(p.hp) }">{{ p.alive ? p.hp : '#' + p.placement }}</span>
<span class="portrait" :style="{ borderColor: ring(p.hp) }">
<img v-if="p.carry" :src="p.carry" :alt="p.name" />
</span>
<span class="hpbar"><span :style="{ width: p.hp + '%', background: ring(p.hp) }" /></span>
<div v-if="expanded === p.name && p.alive && p.board.length" class="scout">
<div>Level {{ p.level }}</div>
<div class="units">
<img
v-for="(u, i) in p.board"
:key="i"
:src="u.icon"
:alt="u.name"
:title="`${u.name} ${'★'.repeat(u.stars)}`"
/>
</div>
<img
v-for="(u, i) in p.board"
:key="i"
:src="u.icon"
:alt="u.name"
:title="`${u.name} ${'★'.repeat(u.stars)}`"
/>
</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.me { border: 1px solid #7de3d4; cursor: default; }
.opp.dead { opacity: 0.4; }
.row { display: flex; align-items: center; gap: 6px; }
.oname { flex: 1; font-weight: 600; }
.me .oname { color: #7de3d4; }
.arch { color: #7d86a0; font-size: 11px; }
.lvl { color: #8a92a8; font-size: 11px; font-weight: 700; }
.goldval { color: #ffd75e; font-size: 11px; }
.strength { color: #e5a531; font-size: 12px; font-weight: 600; }
.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; }
.players { display: flex; flex-direction: column; gap: 5px; }
.row {
position: relative;
display: grid;
grid-template-columns: 28px 1fr auto 42px;
align-items: center;
gap: 7px;
padding: 5px 6px 8px;
background: linear-gradient(90deg, #10131c, #1b2130);
border-radius: 6px;
cursor: pointer;
overflow: hidden;
}
.row.me { background: linear-gradient(90deg, #12232a, #1b3540); outline: 1px solid #7de3d4; cursor: default; }
.row.dead { opacity: 0.42; }
.emblem {
width: 26px; height: 26px;
border-radius: 50%;
border: 2px solid;
background: #0a0c12;
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 12px; color: #c6cddd;
}
.hp { font-size: 17px; font-weight: 800; text-align: right; }
.mid { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
.name { font-weight: 700; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.me .name { color: #7de3d4; }
.stats { display: flex; gap: 7px; font-size: 11px; color: #8a92a8; }
.lvl { font-weight: 700; }
.off { color: #e5a531; }
.def { color: #7de3d4; }
.portrait {
width: 40px; height: 40px;
border-radius: 50%;
border: 2px solid;
background: #0a0c12;
overflow: hidden;
}
.portrait img { width: 100%; height: 100%; object-fit: cover; }
.hpbar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: #232a3d; }
.hpbar span { display: block; height: 100%; }
.scout { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }
.scout img { width: 26px; height: 26px; border-radius: 4px; object-fit: cover; }
</style>

View File

@@ -0,0 +1,140 @@
<script setup>
import { onMounted, ref } from 'vue'
import { fetchPairs } from '../api.js'
const data = ref(null)
const error = ref(null)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
onMounted(async () => {
try {
data.value = await fetchPairs()
} catch (e) {
error.value = e.message
}
})
</script>
<template>
<div class="pairs">
<h2>Gruppen von Champions</h2>
<div v-if="error" class="error">{{ error }}</div>
<p v-else-if="!data" class="loading">lädt…</p>
<div v-else class="grid">
<div v-for="(g, i) in data.groups" :key="i" class="pair">
<div class="head">
<span
class="weight"
title="Plätze besser als vergleichbare Boards — gleiches Level,
gleiche Item-Menge, gleich viele 3★-Units"
>+{{ g.edge.toFixed(2) }}</span>
<span
class="src"
:title="g.links.map((l) => `${l.comps}× zusammen in den Pro-Comps`)
.join(' · ')"
>Bindung {{ g.bond.toFixed(2) }}</span>
<span
v-for="t in g.traits"
:key="t.name"
class="shared"
title="dieser Trait verbindet die Gruppe"
>
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
<span class="src">{{ g.boards.toLocaleString('de-DE') }} Boards</span>
</div>
<div class="cards">
<div
v-for="u in g.units"
:key="u.name"
class="card"
:title="`${u.name} · ${u.cost} Gold` + (u.tier ? ` · Tier ${u.tier}` : '')"
>
<div class="art" :style="{ backgroundImage: `url(${u.splash || u.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[u.cost - 1] }" />
<div class="traitlist">
<span v-for="t in u.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<span v-if="u.tier" class="tier" :class="u.tier">{{ u.tier }}</span>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg, ${costColors[u.cost - 1]}66,`
+ ` ${costColors[u.cost - 1]}bb)` }"
>
<span class="cname">{{ u.name }}</span>
<span class="ccoin">🪙 {{ u.cost }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.pairs { padding: 14px 18px 40px; }
h2 { margin: 0 0 10px; font-size: 18px; }
.loading { color: #8a92a8; font-size: 12px; }
.error { color: #e58686; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 12px;
}
.head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.weight { color: #7ee38a; font-weight: 800; font-size: 15px; }
.src { color: #8a92a8; font-size: 11px; }
/* Der Trait, der die Gruppe zusammenhält. */
.shared {
display: inline-flex; align-items: center; gap: 4px;
background: #1b2a44; border: 1px solid #2f4a72; border-radius: 4px;
padding: 0 6px 0 3px; font-size: 11px; color: #9ccfff;
}
.shared img { width: 13px; height: 13px; filter: brightness(0) invert(1); }
.cards { display: flex; gap: 5px; }
.card {
position: relative; flex: 1; aspect-ratio: 1.7;
border-radius: 5px; background: #0d1017; overflow: hidden;
}
.art { position: absolute; inset: 0; background-size: cover; background-position: center 25%; }
.wash { position: absolute; inset: 0; }
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(6, 8, 14, 0.62); border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 10px; width: fit-content; text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 12px; height: 12px; filter: brightness(0) invert(1); }
.tier {
position: absolute; top: 4px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 4px; padding: 1px 8px;
font-size: 20px; font-weight: 900; letter-spacing: 0.5px; line-height: 1.15;
}
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
.cardbottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 3px 7px; font-size: 12px;
}
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.ccoin { color: #ffd75e; font-weight: 700; }
</style>

View File

@@ -0,0 +1,159 @@
<script setup>
import { onMounted, ref } from 'vue'
import { fetchRoles } from '../api.js'
const data = ref(null)
const error = ref(null)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
const sign = (n) => (n >= 0 ? '+' : '') + n.toFixed(2)
onMounted(async () => {
try {
data.value = await fetchRoles()
} catch (e) {
error.value = e.message
}
})
</script>
<template>
<div class="roles">
<h2>Beste Unit je Rolle</h2>
<div v-if="error" class="error">{{ error }}</div>
<p v-else-if="!data" class="hint">lädt…</p>
<template v-else>
<div class="grid">
<div v-for="role in data.roles" :key="role.label" class="role">
<h3>{{ role.label }}</h3>
<div class="slot" v-for="e in role.costs.filter((c) => c.unit)" :key="e.cost">
<template v-if="e.unit">
<div
class="card"
:title="`${e.unit.name} · ${e.unit.cost} Gold`
+ (e.unit.tier ? ` · Tier ${e.unit.tier}` : '')
+ ` · ${e.unit.boards} Boards`"
>
<div class="art"
:style="{ backgroundImage: `url(${e.unit.splash || e.unit.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[e.unit.cost - 1] }" />
<div class="traitlist">
<span v-for="t in e.unit.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<span v-if="e.unit.tier" class="tier" :class="e.unit.tier">
{{ e.unit.tier }}
</span>
<span class="edge" :class="{ neg: e.unit.edge < 0 }">
{{ sign(e.unit.edge) }}
</span>
<div class="items">
<span
v-for="it in e.unit.items"
:key="it.name"
class="item"
:title="`${it.name} — ${sign(it.edge)} Plätze, ${it.boards} Boards`"
>
<img v-if="it.icon" :src="it.icon" :alt="it.name" />
</span>
</div>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg,`
+ ` ${costColors[e.unit.cost - 1]}66,`
+ ` ${costColors[e.unit.cost - 1]}bb)` }"
>
<span class="cname">
{{ e.unit.name }}
<span class="stars">{{ '★'.repeat(e.unit.stars) }}</span>
</span>
<span class="ccoin">🪙 {{ e.unit.cost }}</span>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.roles { padding: 14px 18px 40px; }
h2 { margin: 0 0 10px; font-size: 18px; }
h3 { margin: 0 0 6px; font-size: 14px; color: #c6cddd; }
.hint { color: #8a92a8; font-size: 12px; margin: 6px 0 16px; }
.error { color: #e58686; }
/* Eine Spalte je Rolle, die Kostenstufen stapeln sich darunter. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
align-items: start;
}
.role { min-width: 0; }
.slot { margin-bottom: 6px; }
/* Die drei Items gehoeren zum Champion und sitzen auf seinem Bild,
unten links nebeneinander — das beste zuerst. */
.items {
position: absolute; left: 5px; bottom: 24px;
display: flex; gap: 3px;
}
.item img {
display: block; width: 30px; height: 30px; border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.card {
position: relative; width: 100%; aspect-ratio: 1.7;
border-radius: 5px; background: #0d1017; overflow: hidden;
}
.art { position: absolute; inset: 0; background-size: cover; background-position: center 25%; }
.wash { position: absolute; inset: 0; }
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(6, 8, 14, 0.62); border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 10px; width: fit-content; text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 12px; height: 12px; filter: brightness(0) invert(1); }
.tier {
position: absolute; top: 4px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 4px; padding: 1px 8px;
font-size: 20px; font-weight: 900; letter-spacing: 0.5px; line-height: 1.15;
}
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
/* Der Wert sitzt jetzt auf der Karte — in der Spalte ist darunter kein Platz. */
.edge {
position: absolute; top: 32px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 3px; padding: 0 4px;
font-size: 11px; font-weight: 800; color: #7ee38a;
}
.edge.neg { color: #e58686; }
.cardbottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 3px 7px; font-size: 12px;
}
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.stars { color: #ffd75e; font-size: 10px; letter-spacing: -1px; }
.ccoin { color: #ffd75e; font-weight: 700; }
</style>

View File

@@ -1,9 +1,25 @@
<script setup>
import { computed } from 'vue'
import { store } from '../store.js'
const s = computed(() => store.state)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
// Kartenfläche wird von der Kostenstufe eingefärbt, wie im Spiel.
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
const fmt = (n) => Number(n).toFixed(1)
// Tier aus echten Endboards, Rang innerhalb der Kostenstufe.
const tip = (c) => `Tier ${c.tier} — Stärke ${fmt(c.strength)}`
+ ' (1.0 = Schnitt der eigenen Kostenstufe, aus echten Spielen gelernt)'
function owned(api) {
return [...store.state.board, ...store.state.bench].some((u) => u.api_name === api)
return [...s.value.board, ...s.value.bench].some((u) => u.api_name === api)
}
</script>
@@ -11,83 +27,95 @@ function owned(api) {
<div class="shopwrap">
<div class="topbar">
<div class="tleft">
<span class="lvl">Lvl. {{ store.state.player.level }}</span>
<span v-if="store.state.player.xp_needed !== null" class="xp">
{{ store.state.player.xp }}/{{ store.state.player.xp_needed }}
<span class="lvl">Lvl. {{ s.player.level }}</span>
<span v-if="s.player.xp_needed !== null" class="xp">
{{ s.player.xp }}/{{ s.player.xp_needed }}
</span>
<span class="odds">
<span
v-for="(p, i) in store.state.shop_odds"
:key="i"
:style="{ color: costColors[i] }"
>● {{ p }}%</span>
<span v-for="(p, i) in s.shop_odds" :key="i" class="odd">
<span class="dot" :style="{ background: costColors[i] }" />{{ p }}%
</span>
</span>
</div>
<div class="tcenter">
<span class="pill gold">🪙 {{ store.state.player.gold }}</span>
<span class="pill gold">🪙 {{ s.player.gold }}</span>
<span
v-if="store.state.player.streak !== 0"
v-if="s.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>
:class="s.player.streak > 0 ? 'hot' : 'cold'"
:title="s.player.streak > 0 ? 'Siegesserie' : 'Niederlagenserie'"
><span class="flame">🔥</span> {{ Math.abs(s.player.streak) }}</span>
</div>
<div class="tright">
<button
class="lock"
:class="{ locked: store.state.shop_locked }"
:title="store.state.shop_locked ? 'Shop entsperren' : 'Shop sperren'"
:class="{ locked: s.shop_locked }"
:title="s.shop_locked ? 'Shop entsperren' : 'Shop sperren'"
@click="store.act({ action: 'lock' })"
>
{{ store.state.shop_locked ? '🔒' : '🔓' }}
</button>
>{{ s.shop_locked ? '🔒' : '🔓' }}</button>
</div>
</div>
<div class="shoprow">
<div class="controls">
<button
class="ctrl"
:disabled="store.state.player.xp_needed === null || store.state.player.gold < store.state.xp_cost"
class="ctrl xp"
:disabled="s.player.xp_needed === null || s.player.gold < s.xp_cost"
@click="store.buyXp()"
>
<span class="ctrl-label">XP kaufen</span>
<span class="ctrl-cost">🪙 {{ store.state.xp_cost }} ⌃</span>
<span class="cbody"><span class="clabel">Buy XP</span>
<span class="ccost">🪙 {{ s.xp_cost }}</span></span>
<span class="cicon">▲</span>
</button>
<button
class="ctrl"
:disabled="store.state.player.gold < store.state.reroll_cost"
class="ctrl reroll"
:disabled="s.player.gold < s.reroll_cost"
@click="store.reroll()"
>
<span class="ctrl-label">Refresh</span>
<span class="ctrl-cost">🪙 {{ store.state.reroll_cost }} ⟳</span>
<span class="cbody"><span class="clabel">Reroll</span>
<span class="ccost">🪙 {{ s.reroll_cost }}</span></span>
<span class="cicon">↻</span>
</button>
</div>
<div class="cards">
<div
v-for="(s, i) in store.state.shop"
v-for="(c, i) in s.shop"
:key="i"
class="card"
:class="{ empty: !s, unaffordable: s && s.cost > store.state.player.gold, upgrade: s && s.upgrade }"
:style="s ? { borderColor: s.upgrade ? '#ffd75e' : costColors[s.cost - 1] } : {}"
@click="s && store.buy(i)"
:class="{ empty: !c, unaffordable: c && c.cost > s.player.gold }"
@click="c && store.buy(i)"
>
<template v-if="s">
<div class="art" :style="{ backgroundImage: `url(${s.icon})` }" />
<div v-if="owned(s.api_name)" class="shine" />
<div v-if="s.upgrade" class="starhint">
<span v-for="n in s.upgrade" :key="n">★</span>
<template v-if="c">
<div class="art" :style="{ backgroundImage: `url(${c.splash || c.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[c.cost - 1] }" />
<div v-if="owned(c.api_name)" class="shine" />
<div v-if="c.upgrade" class="starhint">
<span v-for="n in c.upgrade" :key="n">★</span>
</div>
<div class="vals">
<span class="off">⚔ {{ fmt(c.off) }}</span>
<span class="def">🛡 {{ fmt(c.def) }}</span>
<!-- Nur der Buchstabe: die Zahl gilt je Kostenstufe und
wäre zwischen zwei Karten unterschiedlicher Kosten irreführend. -->
<span v-if="c.tier" class="meta tier" :class="c.tier" :title="tip(c)">
{{ c.tier }}
</span>
</div>
<div class="traitlist">
<span v-for="t in s.traits" :key="t.name" class="traitpill">
<span v-for="t in c.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
class="cardbottom"
:style="{ background: `linear-gradient(180deg, ${costColors[c.cost - 1]}66,`
+ ` ${costColors[c.cost - 1]}bb)` }"
>
<span class="cname">{{ c.name }}</span>
<span class="ccoin">🪙 {{ c.cost }}</span>
</div>
</template>
</div>
@@ -97,113 +125,144 @@ function owned(api) {
</template>
<style scoped>
.shopwrap { display: flex; flex-direction: column; gap: 6px; }
/* Shopleiste als kompaktes, zentriertes Panel — nicht über die volle Breite.
Maße aus der Vorlage: Karte 132 x 78 (Verhältnis 1.7), Knopfspalte so breit
wie eine Karte. */
.shopwrap {
display: flex; flex-direction: column; gap: 5px;
width: fit-content; max-width: 100%; margin: 0 auto;
background: linear-gradient(180deg, #12161f, #0b0e15);
border: 1px solid #2a3145;
border-radius: 8px;
padding: 6px 8px 8px;
}
.topbar { display: flex; align-items: center; gap: 10px; }
.tleft { flex: 1; display: flex; align-items: baseline; gap: 8px; }
.tcenter { display: flex; gap: 10px; }
.tcenter { display: flex; gap: 8px; }
.tright { flex: 1; display: flex; justify-content: flex-end; }
.lvl { font-weight: 800; font-size: 15px; }
.lvl { font-weight: 800; font-size: 16px; }
.xp { color: #8a92a8; font-size: 12px; }
.odds { display: flex; gap: 8px; font-size: 12px; font-weight: 600; margin-left: 8px; }
.odds { display: flex; gap: 10px; font-size: 12px; font-weight: 600; margin-left: 10px; color: #c6cddd; }
.odd { display: inline-flex; align-items: center; gap: 4px; }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.pill {
background: #161b29;
border: 1px solid #3a4460;
border-radius: 14px;
padding: 3px 14px;
font-weight: 700;
background: #10131c; border: 1px solid #3a4460; border-radius: 14px;
padding: 2px 16px; 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); }
.streak.hot { border-color: #b3543a; color: #ffb08a; }
/* Verlustserie: dieselbe Flamme, nur blau eingefärbt. */
.streak.cold { border-color: #4a7fd9; color: #a8c6ff; }
.streak.cold .flame { filter: hue-rotate(190deg) saturate(1.8); }
.shoprow { display: flex; gap: 8px; align-items: stretch; }
.shoprow {
--card-w: clamp(140px, 12vw, 210px);
display: flex; gap: 6px; align-items: stretch;
}
.controls { display: flex; flex-direction: column; gap: 6px; width: 120px; }
.controls {
display: flex; flex-direction: column; gap: 5px;
width: var(--card-w); flex: none;
}
.ctrl {
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;
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px;
border: 1px solid #3d4457;
border-radius: 5px;
}
.ctrl:hover:not(:disabled) { border-color: #8a8340; }
.ctrl-label { font-weight: 700; font-size: 13px; }
.ctrl-cost { font-size: 11px; color: #c9b96a; }
.ctrl .cbody { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.clabel { font-weight: 700; font-size: 14px; }
.ccost { font-size: 11px; color: #c9b96a; }
/* Der farbige Block rechts im Knopf entspricht dem Pfeil-Feld im Spiel. */
.cicon {
font-size: 18px; line-height: 1;
padding: 5px 8px; border-radius: 4px;
}
.ctrl.xp { background: linear-gradient(180deg, #1b2a44, #131a28); }
.ctrl.xp:hover:not(:disabled) { border-color: #4a90d9; }
.ctrl.xp .cicon { background: #26456f; color: #9ccfff; }
.ctrl.reroll { background: linear-gradient(180deg, #2d2716, #1a1710); }
.ctrl.reroll:hover:not(:disabled) { border-color: #b79a3f; }
.ctrl.reroll .cicon { background: #5c4a12; color: #f0d675; }
.cards { display: flex; gap: 6px; flex: 1; }
.cards { display: flex; gap: 5px; }
/* Maße aus der Vorlage: 132 x 78, der Splash füllt die Karte. */
.card {
flex: 1;
flex: none;
position: relative;
border: 2px solid #2a3145;
border-radius: 8px;
background: #12141d;
height: 130px;
border-radius: 5px;
background: #0d1017;
width: var(--card-w);
aspect-ratio: 1.7;
cursor: pointer;
overflow: hidden;
}
.card:hover:not(.empty) { filter: brightness(1.15); }
.card.empty { cursor: default; }
.card.unaffordable { opacity: 0.5; }
.card.upgrade { box-shadow: 0 0 10px #ffd75e88; }
.card:hover:not(.empty) { filter: brightness(1.18); }
.card.empty { cursor: default; background: #0a0c12; border: 1px solid #1e2534; box-shadow: inset 0 0 18px #000; }
.card.unaffordable { opacity: 0.55; }
.art { position: absolute; inset: 0; background-size: cover; background-position: center 20%; }
.art {
position: absolute; inset: 0;
background-size: cover; background-position: center 25%;
}
.wash { position: absolute; inset: 0; }
.vals {
position: absolute; top: 4px; right: 4px;
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
font-size: 12px; font-weight: 800;
}
.off, .def, .meta {
background: rgba(4, 6, 11, 0.78);
border-radius: 3px;
padding: 0 4px;
}
.off { color: #e5a531; }
.def { color: #7de3d4; }
/* Tier-Farben wie im Spiel gewohnt: S golden, D grau-rot. */
.tier { font-weight: 900; letter-spacing: 0.5px; }
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
.shine { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.shine::after {
content: '';
position: absolute;
top: 0; bottom: 0; width: 45%;
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%; }
}
@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;
position: absolute; top: 2px; 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;
}
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.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;
background: rgba(6, 8, 14, 0.62);
border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 11px; width: fit-content;
text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 13px; height: 13px; filter: brightness(0) invert(1); }
.cardbottom {
position: absolute;
left: 0; right: 0; bottom: 0;
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%);
padding: 4px 7px;
font-size: 12px;
}
.cname { font-weight: 700; }
.ccost { color: #ffd75e; font-weight: 700; }
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.ccoin { color: #ffd75e; font-weight: 700; }
.lock {
font-size: 15px;
padding: 3px 10px;
background: #141927;
}
.lock { font-size: 15px; padding: 2px 10px; background: #141927; }
.lock.locked { border-color: #ffd75e; background: #2a2618; }
</style>

View File

@@ -1,50 +1,127 @@
<script setup>
defineProps({ traits: { type: Array, required: true } })
const tierColors = ['#4a4f5d', '#a8703d', '#b9bec9', '#e8c35a', '#7de3d4']
import { computed } from 'vue'
import { store } from '../store.js'
const props = defineProps({
traits: { type: Array, required: true },
items: { type: Array, required: true },
benchSize: { type: Number, default: 9 },
})
// Riot-Stile: 1 Bronze, 3 Silber, 4 Chromatisch, 5 Gold, 6 Prismatisch.
const STYLE = {
1: { bg: '#cd7f32', edge: '#e8a463' },
3: { bg: '#c3cedb', edge: '#e6eef7' },
4: { bg: '#3fc9b4', edge: '#8ff0e2' },
5: { bg: '#f0c94d', edge: '#ffe28a' },
6: { bg: '#dcd0ff', edge: '#f2ecff' },
}
const active = computed(() => props.traits.filter((t) => t.tier > 0))
const inactive = computed(() => props.traits.filter((t) => t.tier === 0))
const style = (t) => STYLE[t.style] || STYLE[1]
// Item-Leiste: immer volle Höhe der Bank, leere Plätze bleiben als Kästchen.
const slots = computed(() => {
const s = props.items.slice(0, props.benchSize)
while (s.length < props.benchSize) s.push(null)
return s
})
</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>
<aside class="panel">
<div class="itemrail">
<div
v-for="(it, i) in slots"
:key="i"
class="slot"
:class="{ selected: it && store.selectedItem === i, empty: !it }"
:title="it ? it.name : ''"
@click="it && (store.selectedItem = store.selectedItem === i ? null : i)"
>
<img v-if="it" :src="it.icon" :alt="it.name" />
</div>
</div>
<div class="traits">
<div v-for="t in active" :key="t.api_name" class="row on">
<span
class="hex"
:style="{ background: style(t).bg, borderColor: style(t).edge }"
>
<img v-if="t.icon" :src="t.icon" :alt="t.name" />
</span>
<span class="count" :style="{ color: style(t).edge }">{{ t.count }}</span>
<span class="body">
<span class="tname">{{ t.name }}</span>
<span class="pips">
<template v-for="(bp, i) in t.breakpoints" :key="i">
<span v-if="i" class="sep">›</span>
<span
class="pip"
:class="{ hit: i + 1 === t.tier }"
:style="i + 1 === t.tier ? { color: style(t).edge } : null"
>{{ bp.units }}</span>
</template>
</span>
</span>
</div>
<div v-for="t in inactive" :key="t.api_name" class="row off">
<span class="hex dim">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />
</span>
<span class="body">
<span class="tname">{{ t.name }}</span>
<span class="pips">{{ t.count }} / {{ t.breakpoints[0]?.units ?? '–' }}</span>
</span>
</div>
<div v-if="!traits.length" class="hint">Keine Units auf dem Board</div>
</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;
.panel { display: flex; gap: 5px; min-height: 0; }
.itemrail { display: flex; flex-direction: column; gap: 3px; flex: none; }
.slot {
width: 30px; height: 30px;
border-radius: 4px;
background: #0d1017;
border: 1px solid #232a3d;
}
.trait.active { opacity: 1; }
.badge {
width: 24px; height: 24px;
border-radius: 50%;
.slot:not(.empty) { cursor: pointer; border-color: #3a4460; }
.slot.selected { border-color: #7de3d4; box-shadow: 0 0 6px #7de3d488; }
.slot img { width: 100%; height: 100%; border-radius: 3px; }
.traits { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; overflow-y: auto; }
.row { display: flex; align-items: center; gap: 6px; padding: 2px 6px 2px 2px; border-radius: 4px; }
.row.on { background: linear-gradient(90deg, #2b3243, #1a1f2c 70%); }
.row.off { background: #171b26; }
.hex {
flex: none;
width: 26px; height: 29px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
border: 2px solid transparent;
}
.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; }
/* Aktive Hexes tragen das Icon dunkel auf der Stufenfarbe, wie im Spiel. */
.hex img { width: 17px; height: 17px; filter: brightness(0); }
.hex.dim { background: #2a2f3d; }
.hex.dim img { filter: brightness(0) invert(1); opacity: 0.42; }
.count { flex: none; font-weight: 800; font-size: 14px; width: 12px; text-align: center; }
.body { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
.tname { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row.off .tname { color: #79808f; font-weight: 500; }
.pips { font-size: 10px; color: #6d7484; display: flex; gap: 3px; }
.pip.hit { font-weight: 800; }
.sep { opacity: 0.5; }
.hint { color: #6d7484; font-size: 12px; padding: 4px 2px; }
</style>

View File

@@ -1,50 +1,89 @@
<script setup>
defineProps({
unit: { type: Object, required: true },
sellable: { type: Boolean, default: false },
size: { type: Number, default: 72 },
})
defineEmits(['click', 'sell'])
const fmt = (n) => Number(n).toFixed(1)
const tip = (u) => `Tier ${u.tier} — Stärke ${fmt(u.strength)}`
+ ' (1.0 = Schnitt der eigenen Kostenstufe, aus echten Spielen gelernt)'
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
class="tile"
:style="{ width: size + 'px', height: size + 'px',
borderColor: costColors[unit.cost - 1] }"
:title="`${unit.name} — Kampfkraft ${unit.power} · Rechtsklick verkauft`"
@click="$emit('click')"
@contextmenu.prevent="$emit('sell')"
>
<img v-if="unit.icon" :src="unit.icon" :alt="unit.name" class="art" />
<span class="stars">{{ '★'.repeat(unit.stars) }}</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>
<span v-if="unit.tier" class="meta tier" :class="unit.tier" :title="tip(unit)">
{{ unit.tier }}
</span>
<span class="vals">
<span class="off">{{ fmt(unit.off) }}</span>
<span class="def">{{ fmt(unit.def) }}</span>
</span>
</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;
.tile {
position: relative;
border: 2px solid;
border-radius: 6px;
background: #0d1017;
overflow: hidden;
cursor: pointer;
}
.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 {
.tile:hover { filter: brightness(1.2); }
.art { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Dunkle Streifen oben und unten, damit Sterne und Werte auf jedem
Portrait lesbar bleiben — helle Icons haben sie sonst verschluckt. */
.stars {
position: absolute; top: 0; left: 0; right: 0;
padding: 1px 0 3px;
background: linear-gradient(rgba(4, 6, 11, 0.8), transparent);
text-align: center; font-size: 14px; letter-spacing: 1px;
color: #ffd75e; text-shadow: 0 0 4px #000, 0 1px 2px #000;
}
.items {
position: absolute; bottom: 20px; left: 2px;
display: flex; gap: 1px;
}
.items img { width: 15px; height: 15px; border-radius: 2px; }
.vals {
position: absolute; bottom: 0; left: 0; right: 0;
display: flex; justify-content: space-between;
padding: 3px 4px;
font-size: 12px; font-weight: 800;
}
.off, .def {
background: rgba(4, 6, 11, 0.8);
border-radius: 3px;
padding: 0 4px;
font-size: 10px;
background: #3d2330;
border-color: #6a3a50;
margin-left: 2px;
}
.off { color: #e5a531; }
.def { color: #7de3d4; }
/* Meta oben rechts — unten ist die Zeile mit Off/Def schon voll. */
.meta {
position: absolute; top: 2px; right: 2px;
background: rgba(4, 6, 11, 0.8);
border-radius: 3px; padding: 0 3px;
font-size: 11px; font-weight: 800;
}
/* Tier-Farben wie im Spiel gewohnt: S golden, D grau-rot. */
.tier { font-weight: 900; letter-spacing: 0.5px; }
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
</style>

View File

@@ -1,23 +1,37 @@
import { reactive } from 'vue'
import { newGame, doAction } from './api.js'
import { newGame, doAction, fetchReplay } from './api.js'
export const store = reactive({
gameId: null,
state: null,
error: null,
selectedItem: null,
dragging: null,
// Kampf-Protokoll der letzten Runde, nur wenn es angefragt wurde.
replay: null,
async start() {
this.error = null
const data = await newGame()
this.gameId = data.game_id
this.state = data
this.selectedItem = null
try {
const data = await newGame()
this.gameId = data.game_id
this.state = data
} catch (e) {
this.error = e.message
}
},
async act(action) {
this.error = null
// Jede Aktion ändert das Board — ein alter Kampf gehört dann weg.
// Sonst verdeckt das Protokoll das Hex-Feld und man kann nichts
// mehr aufstellen.
if (action.action !== 'lock') this.replay = null
try {
this.state = await doAction(this.gameId, action)
// Item-Indizes verschieben sich bei jeder Aktion — Auswahl verfällt.
this.selectedItem = null
} catch (e) {
this.error = e.message
setTimeout(() => { if (this.error === e.message) this.error = null }, 2500)
@@ -29,7 +43,19 @@ export const store = reactive({
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' }) },
place(where, idx, hex) { return this.act({ action: 'place', where, idx, hex }) },
async next() {
return this.act({ action: 'next' })
},
async loadReplay() {
try {
this.replay = await fetchReplay(this.gameId)
} catch {
// Kein Gegner oder leeres Board: dann gibt es nichts zu zeigen.
this.replay = null
}
},
pickAugment(choice) { return this.act({ action: 'pick_augment', choice }) },
equip(boardIdx) {