update
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
220
frontend/src/components/BattleView.vue
Normal file
220
frontend/src/components/BattleView.vue
Normal 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>
|
||||
134
frontend/src/components/CarryPanel.vue
Normal file
134
frontend/src/components/CarryPanel.vue
Normal 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>
|
||||
156
frontend/src/components/GroupPanel.vue
Normal file
156
frontend/src/components/GroupPanel.vue
Normal 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>
|
||||
191
frontend/src/components/HexGrid.vue
Normal file
191
frontend/src/components/HexGrid.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
140
frontend/src/components/PairPanel.vue
Normal file
140
frontend/src/components/PairPanel.vue
Normal 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>
|
||||
159
frontend/src/components/RolePanel.vue
Normal file
159
frontend/src/components/RolePanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user