ui: plan 01-27 done #1
+39
-13
@@ -48,13 +48,22 @@ across the underlying groups; per-tech detail is available in the
|
|||||||
Reports view (Foreign Ship Classes / My Ship Types).
|
Reports view (Foreign Ship Classes / My Ship Types).
|
||||||
|
|
||||||
Bucket order inside a cluster is **locked at battle start** by the
|
Bucket order inside a cluster is **locked at battle start** by the
|
||||||
initial ship count (`num` summed across tech variants, descending).
|
initial ship count (`num` summed across tech variants, descending),
|
||||||
As ships die during playback only the label number changes — every
|
together with mass, radius and local position. The static layout
|
||||||
bucket keeps its slot in the Vogel spiral, so the user does not see
|
lives in `staticBucketsByRace`; the per-frame derivation
|
||||||
the cluster reshuffle when a class empties. Vogel positions are
|
`renderedByRace` overlays the live `NumberLeft` and drops buckets
|
||||||
then reassigned per rank by their inward distance toward the
|
once they hit zero. The remaining buckets keep their slots in the
|
||||||
planet, so the rank-0 bucket (the largest at battle start) always
|
cloud, so the cluster does not reshuffle when a class empties — the
|
||||||
sits at the most-inward spiral slot.
|
empty bucket simply disappears.
|
||||||
|
|
||||||
|
Vogel positions are reassigned per rank by their inward distance
|
||||||
|
toward the planet, so the rank-0 bucket (the largest by initial
|
||||||
|
ship count) always sits at the most-inward spiral slot.
|
||||||
|
|
||||||
|
When two races remain in battle the radial layout switches to the
|
||||||
|
horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This
|
||||||
|
keeps both race labels clear of the SVG top edge and reads as the
|
||||||
|
two sides facing off naturally.
|
||||||
|
|
||||||
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
||||||
the per-ship `LoadQuantity`). The viewer resolves a
|
the per-ship `LoadQuantity`). The viewer resolves a
|
||||||
@@ -80,20 +89,25 @@ produces the "shot-shot-shot" pulse the user wanted.
|
|||||||
|
|
||||||
## Playback controls
|
## Playback controls
|
||||||
|
|
||||||
`lib/battle-player/playback-controls.svelte` ships the full set:
|
`lib/battle-player/playback-controls.svelte` ships:
|
||||||
|
|
||||||
| Control | Effect |
|
| Control | Effect |
|
||||||
| ------------- | ------------------------------------------ |
|
| ------------------ | ------------------------------------------------------- |
|
||||||
| ⏮ rewind | Stop, jump to frame 0 |
|
| ⏮ rewind | Stop, jump to frame 0 |
|
||||||
| ◀︎ step back | Stop, frame ← frame − 1 |
|
| ◀︎◀︎ step back | Stop, frame ← frame − 1 |
|
||||||
| ▶︎ / ⏸ play | Toggle continuous playback |
|
| ▶︎ / ⏸ play | Toggle continuous playback |
|
||||||
| ▶︎▶︎ step fwd | Stop, frame ← frame + 1 |
|
| ▶︎▶︎ step forward | Stop, frame ← frame + 1 |
|
||||||
| 1x / 2x / 4x | Speed switch: 400 / 200 / 100 ms per frame |
|
| `Nx` cycle speed | Single button, cycles 1x → 2x → 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) |
|
||||||
|
| `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height |
|
||||||
|
|
||||||
When the timeline is at its end and the user hits play, the frame
|
When the timeline is at its end and the user hits play, the frame
|
||||||
counter wraps to 0 and continues. Step buttons disable themselves at
|
counter wraps to 0 and continues. Step buttons disable themselves at
|
||||||
their boundary.
|
their boundary.
|
||||||
|
|
||||||
|
A drag-seek slider sits between the scene and the controls. Dragging
|
||||||
|
pauses playback and lands `frameIndex` on the chosen shot — handy
|
||||||
|
for jumping to the moment a particular race started losing ground.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
Below the scene the viewer renders a static `<ol>` text protocol —
|
Below the scene the viewer renders a static `<ol>` text protocol —
|
||||||
@@ -130,12 +144,24 @@ the scene until its actual ships are gone. The phantom shots still
|
|||||||
draw a line during the frame they belong to; only the running
|
draw a line during the frame they belong to; only the running
|
||||||
counters are protected.
|
counters are protected.
|
||||||
|
|
||||||
|
## Header + layout
|
||||||
|
|
||||||
|
The viewer header carries three rows of chrome in a single line:
|
||||||
|
the back-navigation buttons (`back to map` / `back to report`) on
|
||||||
|
the left, a centred title — `Battle on planet <name> (<#number>)`,
|
||||||
|
i18n key `game.battle.header_title` — and the frame counter on the
|
||||||
|
right. Pulling navigation into the header frees the entire viewer
|
||||||
|
area for the scene; the `.viewer` container has no `max-width` cap,
|
||||||
|
so on wide monitors the scene scales up while the log keeps its
|
||||||
|
internal 30 dvh scroll.
|
||||||
|
|
||||||
## Height fit
|
## Height fit
|
||||||
|
|
||||||
The viewer is pinned to the viewport: `.active-view` uses
|
The viewer is pinned to the viewport: `.active-view` uses
|
||||||
`calc(100dvh − 80px)` so the in-game-shell header + optional
|
`calc(100dvh − 80px)` so the in-game-shell header + optional
|
||||||
HistoryBanner do not push the scene below the fold. Inside the
|
HistoryBanner do not push the scene below the fold. Inside the
|
||||||
viewer, the scene grows (`flex: 1`) and the log shrinks to a
|
viewer, the scene grows (`flex: 1`), the scrubber + controls hold
|
||||||
|
their natural height, and the log (when expanded) shrinks to a
|
||||||
30 dvh ceiling with its own internal scroll, so the page itself
|
30 dvh ceiling with its own internal scroll, so the page itself
|
||||||
never scrolls vertically. The 80 px allowance maps to the current
|
never scrolls vertically. The 80 px allowance maps to the current
|
||||||
Header + HistoryBanner total on desktop; mobile breakpoints reuse
|
Header + HistoryBanner total on desktop; mobile breakpoints reuse
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ not-found state.
|
|||||||
|
|
||||||
This wrapper also bridges the surrounding GameReport's ship-class
|
This wrapper also bridges the surrounding GameReport's ship-class
|
||||||
tables into a `(race, className) → ShipClassRef` lookup the viewer
|
tables into a `(race, className) → ShipClassRef` lookup the viewer
|
||||||
needs to size class circles by ship mass. The viewer remains
|
needs to size class circles by ship mass. The back-navigation
|
||||||
prop-driven; we just resolve the lookup once here so the lower
|
buttons (`back to map` / `back to report`) live INSIDE the viewer
|
||||||
component does not have to know about `RenderedReportSource`.
|
header now — we just hand the routes down as callbacks so the
|
||||||
|
viewer keeps its prop-driven contract.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
@@ -46,12 +47,6 @@ component does not have to know about `RenderedReportSource`.
|
|||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
// shipClassLookup turns `(race, className)` into the five class
|
|
||||||
// parameters required by `calc.EmptyMass`. Local classes belong
|
|
||||||
// to the report recipient (`report.race`); foreign classes carry
|
|
||||||
// their own `race` field. Lookup is cheap to rebuild whenever the
|
|
||||||
// report changes — the active-view-host re-renders on turn flips
|
|
||||||
// anyway.
|
|
||||||
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
|
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
|
||||||
const map = new Map<string, ShipClassRef>();
|
const map = new Map<string, ShipClassRef>();
|
||||||
const report = rendered?.report;
|
const report = rendered?.report;
|
||||||
@@ -115,28 +110,22 @@ component does not have to know about `RenderedReportSource`.
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}>
|
<section
|
||||||
<nav class="back-row">
|
class="active-view"
|
||||||
<button
|
data-testid="active-view-battle"
|
||||||
type="button"
|
data-battle-id={battleId}
|
||||||
class="back-btn"
|
>
|
||||||
onclick={backToMap}
|
|
||||||
data-testid="battle-back-to-map"
|
|
||||||
>{i18n.t("game.battle.back_to_map")}</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="back-btn"
|
|
||||||
onclick={backToReport}
|
|
||||||
data-testid="battle-back-to-report"
|
|
||||||
>{i18n.t("game.battle.back_to_report")}</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{#if state.kind === "loading"}
|
{#if state.kind === "loading"}
|
||||||
<p class="status" data-testid="battle-loading">
|
<p class="status" data-testid="battle-loading">
|
||||||
{i18n.t("game.battle.loading")}
|
{i18n.t("game.battle.loading")}
|
||||||
</p>
|
</p>
|
||||||
{:else if state.kind === "ready"}
|
{:else if state.kind === "ready"}
|
||||||
<BattleViewer report={state.report} shipClassLookup={shipClassLookup} />
|
<BattleViewer
|
||||||
|
report={state.report}
|
||||||
|
{shipClassLookup}
|
||||||
|
onBackToMap={backToMap}
|
||||||
|
onBackToReport={backToReport}
|
||||||
|
/>
|
||||||
{:else if state.kind === "not_found"}
|
{:else if state.kind === "not_found"}
|
||||||
<p class="status" data-testid="battle-not-found">
|
<p class="status" data-testid="battle-not-found">
|
||||||
{i18n.t("game.battle.not_found")}
|
{i18n.t("game.battle.not_found")}
|
||||||
@@ -153,44 +142,23 @@ component does not have to know about `RenderedReportSource`.
|
|||||||
/*
|
/*
|
||||||
* The in-game shell renders this active view inside an
|
* The in-game shell renders this active view inside an
|
||||||
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
|
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
|
||||||
* the surrounding `.game-shell` uses `min-height: 100vh`,
|
* the surrounding `.game-shell` uses `min-height: 100vh`, so
|
||||||
* so without a hard upper bound the viewer pushes the
|
* without a hard upper bound the viewer pushes the whole
|
||||||
* whole shell past the viewport. We pin the active view to
|
* shell past the viewport. We pin the active view to `100dvh`
|
||||||
* `100dvh` minus a small allowance for the header chrome
|
* minus a small allowance for the header chrome (in-game
|
||||||
* (in-game Header + optional HistoryBanner = ~66 px on
|
* Header + optional HistoryBanner ≈ 66 px on desktop) so the
|
||||||
* desktop) so the internal flex chain can split the
|
* internal flex chain can split the remaining height between
|
||||||
* remaining height between the scene and the always-
|
* the scene, scrubber, controls and log without forcing a
|
||||||
* visible log without forcing a page-level scroll.
|
* page-level scroll.
|
||||||
*/
|
*/
|
||||||
height: calc(100dvh - 80px);
|
height: calc(100dvh - 80px);
|
||||||
max-height: calc(100dvh - 80px);
|
max-height: calc(100dvh - 80px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 1rem;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
color: #d6dcf2;
|
color: #d6dcf2;
|
||||||
}
|
}
|
||||||
.back-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
max-width: 880px;
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.back-btn {
|
|
||||||
appearance: none;
|
|
||||||
background: #1f2748;
|
|
||||||
color: #d6dcf2;
|
|
||||||
border: 1px solid #2c3568;
|
|
||||||
padding: 0.35rem 0.7rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.back-btn:hover {
|
|
||||||
background: #2a3463;
|
|
||||||
}
|
|
||||||
.status {
|
.status {
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
max-width: 880px;
|
max-width: 880px;
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ Layout: planet at the centre, race anchors equally spaced on an
|
|||||||
outer ring, each race rendered as a *cloud* of class circles
|
outer ring, each race rendered as a *cloud* of class circles
|
||||||
arranged on a Vogel sunflower spiral. Spiral positions are
|
arranged on a Vogel sunflower spiral. Spiral positions are
|
||||||
reassigned per rank by their inward distance toward the planet so
|
reassigned per rank by their inward distance toward the planet so
|
||||||
the rank-0 bucket (heaviest by NumberLeft) always sits at the
|
the rank-0 bucket (the bucket with the largest initial ship count)
|
||||||
most-inward Vogel slot — the cloud visually leans toward the
|
always sits at the most-inward Vogel slot.
|
||||||
planet without the cluster anchor needing a manual offset.
|
|
||||||
|
|
||||||
Tech-variant groups of the same `(race, className)` collapse to one
|
Tech-variant groups of the same `(race, className)` collapse to one
|
||||||
visual node — the per-tech detail lives in Reports. Each circle's
|
visual node — per-tech detail lives in Reports. Each circle's
|
||||||
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
||||||
visually dominate.
|
visually dominate. Order, position, radius and mass are locked at
|
||||||
|
battle start; only NumberLeft (the label number) and per-bucket
|
||||||
|
visibility change per frame. Empty buckets are hidden so the
|
||||||
|
remaining ones keep their original spots without reshuffling.
|
||||||
|
|
||||||
Observer groups (`inBattle === false`) are filtered out by
|
Observer groups (`inBattle === false`) are filtered out by
|
||||||
`buildFrames`, so they never appear here. Same-race opponents are
|
`buildFrames`. Same-race opponents are forbidden by the engine's
|
||||||
forbidden by the engine's combat filter, so a shot can never
|
combat filter, so a shot never collapses to a single visual node.
|
||||||
collapse to a single visual node.
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
@@ -59,34 +60,40 @@ collapse to a single visual node.
|
|||||||
const BASE_STEP = 1.8 * MAX_RADIUS;
|
const BASE_STEP = 1.8 * MAX_RADIUS;
|
||||||
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||||
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
|
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
|
||||||
|
const LABEL_MIN_Y = 24;
|
||||||
|
|
||||||
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
||||||
|
|
||||||
const groupRace = $derived(buildGroupRaceMap(report.protocol));
|
const groupRace = $derived(buildGroupRaceMap(report.protocol));
|
||||||
const allGroups = $derived(normaliseGroups(report));
|
const allGroups = $derived(normaliseGroups(report));
|
||||||
|
|
||||||
type ClusterEntry = {
|
type StaticBucket = {
|
||||||
bucketKey: string;
|
bucketKey: string;
|
||||||
className: string;
|
className: string;
|
||||||
race: string;
|
race: string;
|
||||||
raceId: number;
|
raceId: number;
|
||||||
groupKeys: number[];
|
groupKeys: number[];
|
||||||
initialNum: number;
|
initialNum: number;
|
||||||
numLeft: number;
|
|
||||||
mass: number;
|
mass: number;
|
||||||
radius: number;
|
radius: number;
|
||||||
|
// Local offsets in the cluster's (u, v) basis. `u` always
|
||||||
|
// points from the race anchor toward the planet, so a
|
||||||
|
// constant local-frame layout produces the same "inward" feel
|
||||||
|
// regardless of which slot on the outer ring the race
|
||||||
|
// currently occupies (races rotate when peers die).
|
||||||
|
offsetU: number;
|
||||||
|
offsetV: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Aggregate every `(raceId, className)` into a single bucket and
|
// staticBucketsByRace locks the bucket roster, ordering, masses,
|
||||||
// compute per-bucket NumberLeft (sum across tech-variants) and
|
// radii and local positions for the lifetime of this viewer. The
|
||||||
// per-ship FullMass via the wasm bridge. mass=0 when the class
|
// derivation only re-runs when `report` or the wasm `core` flip
|
||||||
// either doesn't resolve in the lookup or the calc rejects its
|
// (initial mount and core boot completion). Per-frame NumberLeft
|
||||||
// params; downstream `radiusForMass` falls back to MAX_RADIUS for
|
// changes do not touch this map — they live in `renderedByRace`.
|
||||||
// those nodes.
|
const staticBucketsByRace = $derived.by(() => {
|
||||||
const clustersByRace = $derived.by(() => {
|
|
||||||
const core = coreHandle?.core ?? null;
|
const core = coreHandle?.core ?? null;
|
||||||
const out = new Map<number, ClusterEntry[]>();
|
const out = new Map<number, StaticBucket[]>();
|
||||||
const bucketIndex = new Map<string, ClusterEntry>();
|
const bucketIndex = new Map<string, StaticBucket>();
|
||||||
for (const g of allGroups) {
|
for (const g of allGroups) {
|
||||||
const bucketKey = `${g.raceId}::${g.group.className}`;
|
const bucketKey = `${g.raceId}::${g.group.className}`;
|
||||||
let bucket = bucketIndex.get(bucketKey);
|
let bucket = bucketIndex.get(bucketKey);
|
||||||
@@ -103,9 +110,10 @@ collapse to a single visual node.
|
|||||||
raceId: g.raceId,
|
raceId: g.raceId,
|
||||||
groupKeys: [],
|
groupKeys: [],
|
||||||
initialNum: 0,
|
initialNum: 0,
|
||||||
numLeft: 0,
|
|
||||||
mass,
|
mass,
|
||||||
radius: MAX_RADIUS,
|
radius: MAX_RADIUS,
|
||||||
|
offsetU: 0,
|
||||||
|
offsetV: 0,
|
||||||
};
|
};
|
||||||
bucketIndex.set(bucketKey, bucket);
|
bucketIndex.set(bucketKey, bucket);
|
||||||
const list = out.get(g.raceId) ?? [];
|
const list = out.get(g.raceId) ?? [];
|
||||||
@@ -114,11 +122,10 @@ collapse to a single visual node.
|
|||||||
}
|
}
|
||||||
bucket.groupKeys.push(g.key);
|
bucket.groupKeys.push(g.key);
|
||||||
bucket.initialNum += g.group.num;
|
bucket.initialNum += g.group.num;
|
||||||
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-battle mass normalisation: the heaviest visual node
|
// Per-battle mass normalisation: the heaviest bucket renders
|
||||||
// renders at MAX_RADIUS; lighter ones scale by sqrt(m/max).
|
// at MAX_RADIUS; lighter ones scale by sqrt(m/max).
|
||||||
let maxMass = 0;
|
let maxMass = 0;
|
||||||
for (const bucket of bucketIndex.values()) {
|
for (const bucket of bucketIndex.values()) {
|
||||||
if (bucket.mass > maxMass) maxMass = bucket.mass;
|
if (bucket.mass > maxMass) maxMass = bucket.mass;
|
||||||
@@ -127,23 +134,67 @@ collapse to a single visual node.
|
|||||||
bucket.radius = radiusForMass(bucket.mass, maxMass);
|
bucket.radius = radiusForMass(bucket.mass, maxMass);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order is locked by initial ship count, NOT live numLeft:
|
// Sort each race's buckets by initial count (descending) +
|
||||||
// the user wants every bucket to keep its visual slot through
|
// className as a stable tie-break, then assign Vogel positions
|
||||||
// the battle. Rank 0 = the bucket that *started* with the
|
// reordered by inward dot product (offsetU desc) so the
|
||||||
// most ships, which the legacy game heuristic treats as
|
// largest-by-num bucket lands at the most-inward Vogel slot.
|
||||||
// "cover" placed in front of more valuable hulls.
|
|
||||||
for (const list of out.values()) {
|
for (const list of out.values()) {
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
|
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
|
||||||
return a.className.localeCompare(b.className);
|
return a.className.localeCompare(b.className);
|
||||||
});
|
});
|
||||||
|
const N = list.length;
|
||||||
|
const denom = Math.max(1, Math.sqrt(Math.max(N, 1)));
|
||||||
|
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
|
||||||
|
const positions = Array.from({ length: N }, (_, r) => {
|
||||||
|
const radius = step * Math.sqrt(r);
|
||||||
|
const angle = r * GOLDEN_ANGLE;
|
||||||
|
return {
|
||||||
|
offsetU: radius * Math.cos(angle),
|
||||||
|
offsetV: radius * Math.sin(angle),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
positions.sort((a, b) => {
|
||||||
|
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
|
||||||
|
return a.offsetV - b.offsetV;
|
||||||
|
});
|
||||||
|
for (let r = 0; r < N; r++) {
|
||||||
|
list[r].offsetU = positions[r].offsetU;
|
||||||
|
list[r].offsetV = positions[r].offsetV;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
const bucketByGroupKey = $derived.by(() => {
|
type RenderedBucket = StaticBucket & { numLeft: number };
|
||||||
const out = new Map<number, ClusterEntry>();
|
|
||||||
for (const list of clustersByRace.values()) {
|
// renderedByRace overlays the per-frame `remaining` map onto the
|
||||||
|
// static cluster: only buckets with `numLeft > 0` survive into
|
||||||
|
// the render list, so an emptied class disappears from the cloud
|
||||||
|
// while its neighbours keep their slots.
|
||||||
|
const renderedByRace = $derived.by(() => {
|
||||||
|
const out = new Map<number, RenderedBucket[]>();
|
||||||
|
for (const [raceId, list] of staticBucketsByRace) {
|
||||||
|
const filtered: RenderedBucket[] = [];
|
||||||
|
for (const bucket of list) {
|
||||||
|
let numLeft = 0;
|
||||||
|
for (const key of bucket.groupKeys) {
|
||||||
|
numLeft += frame.remaining.get(key) ?? 0;
|
||||||
|
}
|
||||||
|
if (numLeft > 0) filtered.push({ ...bucket, numLeft });
|
||||||
|
}
|
||||||
|
if (filtered.length > 0) out.set(raceId, filtered);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// visibleBucketByGroupKey lets shot endpoints resolve to a node
|
||||||
|
// only when the bucket is currently rendered. A phantom shot
|
||||||
|
// against an already-empty bucket therefore returns `null` and
|
||||||
|
// no line is drawn.
|
||||||
|
const visibleBucketByGroupKey = $derived.by(() => {
|
||||||
|
const out = new Map<number, RenderedBucket>();
|
||||||
|
for (const list of renderedByRace.values()) {
|
||||||
for (const bucket of list) {
|
for (const bucket of list) {
|
||||||
for (const key of bucket.groupKeys) {
|
for (const key of bucket.groupKeys) {
|
||||||
out.set(key, bucket);
|
out.set(key, bucket);
|
||||||
@@ -167,7 +218,6 @@ collapse to a single visual node.
|
|||||||
uy: number;
|
uy: number;
|
||||||
vx: number;
|
vx: number;
|
||||||
vy: number;
|
vy: number;
|
||||||
step: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clusterBasisById = $derived.by(() => {
|
const clusterBasisById = $derived.by(() => {
|
||||||
@@ -180,9 +230,6 @@ collapse to a single visual node.
|
|||||||
const uy = dy / len;
|
const uy = dy / len;
|
||||||
const vx = uy;
|
const vx = uy;
|
||||||
const vy = -ux;
|
const vy = -ux;
|
||||||
const count = (clustersByRace.get(anchor.raceId) ?? []).length;
|
|
||||||
const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
|
|
||||||
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
|
|
||||||
out.set(anchor.raceId, {
|
out.set(anchor.raceId, {
|
||||||
anchorX: anchor.x,
|
anchorX: anchor.x,
|
||||||
anchorY: anchor.y,
|
anchorY: anchor.y,
|
||||||
@@ -190,61 +237,24 @@ collapse to a single visual node.
|
|||||||
uy,
|
uy,
|
||||||
vx,
|
vx,
|
||||||
vy,
|
vy,
|
||||||
step,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
// vogelLocalsByRace generates Vogel sunflower positions for each
|
function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
|
||||||
// cluster and reassigns them by inward dot product (`offsetU`
|
|
||||||
// descending) so the rank-0 bucket always lands at the most-
|
|
||||||
// inward spiral point. With the original `r * GOLDEN_ANGLE` angle
|
|
||||||
// scheme, ranks with r ≥ 2 can land further toward the planet
|
|
||||||
// than r = 0; the reassignment makes the "biggest group near the
|
|
||||||
// planet" invariant exact.
|
|
||||||
const vogelLocalsByRace = $derived.by(() => {
|
|
||||||
const out = new Map<number, { offsetU: number; offsetV: number }[]>();
|
|
||||||
for (const [raceId, cluster] of clustersByRace) {
|
|
||||||
const basis = clusterBasisById.get(raceId);
|
|
||||||
if (!basis) continue;
|
|
||||||
const positions = Array.from({ length: cluster.length }, (_, r) => {
|
|
||||||
const radius = basis.step * Math.sqrt(r);
|
|
||||||
const angle = r * GOLDEN_ANGLE;
|
|
||||||
return {
|
return {
|
||||||
offsetU: radius * Math.cos(angle),
|
x: basis.anchorX + bucket.offsetU * basis.ux + bucket.offsetV * basis.vx,
|
||||||
offsetV: radius * Math.sin(angle),
|
y: basis.anchorY + bucket.offsetU * basis.uy + bucket.offsetV * basis.vy,
|
||||||
};
|
|
||||||
});
|
|
||||||
positions.sort((a, b) => {
|
|
||||||
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
|
|
||||||
return a.offsetV - b.offsetV;
|
|
||||||
});
|
|
||||||
out.set(raceId, positions);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
|
|
||||||
function worldPosition(
|
|
||||||
basis: ClusterBasis,
|
|
||||||
local: { offsetU: number; offsetV: number },
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
x: basis.anchorX + local.offsetU * basis.ux + local.offsetV * basis.vx,
|
|
||||||
y: basis.anchorY + local.offsetU * basis.uy + local.offsetV * basis.vy,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findClassCircleCenter(groupKey: number) {
|
function findClassCircleCenter(groupKey: number) {
|
||||||
const bucket = bucketByGroupKey.get(groupKey);
|
const bucket = visibleBucketByGroupKey.get(groupKey);
|
||||||
if (!bucket) return null;
|
if (!bucket) return null;
|
||||||
const basis = clusterBasisById.get(bucket.raceId);
|
const basis = clusterBasisById.get(bucket.raceId);
|
||||||
if (!basis) return null;
|
if (!basis) return null;
|
||||||
const cluster = clustersByRace.get(bucket.raceId) ?? [];
|
return worldPosition(basis, bucket);
|
||||||
const rank = cluster.indexOf(bucket);
|
|
||||||
const locals = vogelLocalsByRace.get(bucket.raceId);
|
|
||||||
if (rank === -1 || locals === undefined) return null;
|
|
||||||
return worldPosition(basis, locals[rank]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shotLine = $derived.by(() => {
|
const shotLine = $derived.by(() => {
|
||||||
@@ -258,7 +268,7 @@ collapse to a single visual node.
|
|||||||
|
|
||||||
const flashDefenderBucketKey = $derived.by(() => {
|
const flashDefenderBucketKey = $derived.by(() => {
|
||||||
if (!shotLine || !shotVisible) return null;
|
if (!shotLine || !shotVisible) return null;
|
||||||
const bucket = bucketByGroupKey.get(shotLine.defenderKey);
|
const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
|
||||||
return bucket?.bucketKey ?? null;
|
return bucket?.bucketKey ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,25 +283,23 @@ collapse to a single visual node.
|
|||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
// raceLabelYById computes a label position that sits above the
|
// raceLabelYById finds a y just above the visible cluster's top
|
||||||
// cluster's bounding top so the race name never hides inside the
|
// edge and clamps it to the SVG viewport so the north race
|
||||||
// cloud. The label is also clamped to a minimum distance from the
|
// (anchor near the top) never has its label clipped off-canvas.
|
||||||
// race anchor so a single-bucket cluster still has its label
|
|
||||||
// rendered just above the only circle.
|
|
||||||
const raceLabelYById = $derived.by(() => {
|
const raceLabelYById = $derived.by(() => {
|
||||||
const out = new Map<number, number>();
|
const out = new Map<number, number>();
|
||||||
for (const [raceId, cluster] of clustersByRace) {
|
for (const [raceId, list] of renderedByRace) {
|
||||||
const basis = clusterBasisById.get(raceId);
|
const basis = clusterBasisById.get(raceId);
|
||||||
const locals = vogelLocalsByRace.get(raceId);
|
if (!basis || list.length === 0) continue;
|
||||||
if (!basis || !locals) continue;
|
|
||||||
let topY = basis.anchorY;
|
let topY = basis.anchorY;
|
||||||
for (let i = 0; i < cluster.length; i++) {
|
for (const bucket of list) {
|
||||||
const world = worldPosition(basis, locals[i]);
|
const world = worldPosition(basis, bucket);
|
||||||
const y = world.y - cluster[i].radius;
|
const top = world.y - bucket.radius;
|
||||||
if (y < topY) topY = y;
|
if (top < topY) topY = top;
|
||||||
}
|
}
|
||||||
const fallback = basis.anchorY - MAX_RADIUS - 12;
|
const fallback = basis.anchorY - MAX_RADIUS - 12;
|
||||||
out.set(raceId, Math.min(topY - 12, fallback));
|
const target = Math.min(topY - 12, fallback);
|
||||||
|
out.set(raceId, Math.max(target, LABEL_MIN_Y));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
@@ -320,9 +328,9 @@ collapse to a single visual node.
|
|||||||
>{report.planetName} (#{report.planet})</text>
|
>{report.planetName} (#{report.planet})</text>
|
||||||
|
|
||||||
{#each raceLayout as anchor (anchor.raceId)}
|
{#each raceLayout as anchor (anchor.raceId)}
|
||||||
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
{@const cluster = renderedByRace.get(anchor.raceId) ?? []}
|
||||||
{@const basis = clusterBasisById.get(anchor.raceId)}
|
{@const basis = clusterBasisById.get(anchor.raceId)}
|
||||||
{@const locals = vogelLocalsByRace.get(anchor.raceId) ?? []}
|
{#if basis && cluster.length > 0}
|
||||||
<g
|
<g
|
||||||
class="race-cluster"
|
class="race-cluster"
|
||||||
data-testid="battle-race-cluster"
|
data-testid="battle-race-cluster"
|
||||||
@@ -334,10 +342,8 @@ collapse to a single visual node.
|
|||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="race-label"
|
class="race-label"
|
||||||
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
||||||
{#if basis}
|
{#each cluster as entry (entry.bucketKey)}
|
||||||
{#each cluster as entry, rank (entry.bucketKey)}
|
{@const pos = worldPosition(basis, entry)}
|
||||||
{@const local = locals[rank]}
|
|
||||||
{@const pos = local ? worldPosition(basis, local) : { x: anchor.x, y: anchor.y }}
|
|
||||||
{@const flash =
|
{@const flash =
|
||||||
entry.bucketKey === flashDefenderBucketKey
|
entry.bucketKey === flashDefenderBucketKey
|
||||||
? shotLine?.destroyed
|
? shotLine?.destroyed
|
||||||
@@ -360,8 +366,8 @@ collapse to a single visual node.
|
|||||||
>{entry.className}:{entry.numLeft}</text>
|
>{entry.className}:{entry.numLeft}</text>
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
|
||||||
</g>
|
</g>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if shotLine && shotVisible}
|
{#if shotLine && shotVisible}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
<!--
|
<!--
|
||||||
BattleViewer — orchestrates the radial battle scene, the playback
|
BattleViewer — orchestrates the radial battle scene, the playback
|
||||||
controls, and the accessibility text log for one BattleReport. Owns
|
controls, and the accessibility text log for one BattleReport. Owns
|
||||||
the playback state (`frameIndex`, `playing`, `speed`). The component
|
the playback state (`frameIndex`, `playing`, `speed`, `logOpen`).
|
||||||
is logically isolated: feed it any `BattleReport` matching
|
Layout reorganisation (latest iteration):
|
||||||
`pkg/model/report/battle.go` and it plays back.
|
|
||||||
|
- The header carries the planet title, the back-navigation links and
|
||||||
|
the frame counter so the scene captures the full viewer width and
|
||||||
|
height beneath them.
|
||||||
|
- A drag-seek slider sits between the scene and the controls; the
|
||||||
|
user can scrub the playback timeline at any speed.
|
||||||
|
- The text log collapses behind a toggle in the controls bar so a
|
||||||
|
user who wants the biggest scene possible can hide it entirely.
|
||||||
|
|
||||||
|
The component is logically isolated: feed it any `BattleReport`
|
||||||
|
matching `pkg/model/report/battle.go` and it plays back.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
@@ -11,38 +21,38 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
import type { ShipClassLookup } from "./mass";
|
import type { ShipClassLookup } from "./mass";
|
||||||
|
|
||||||
import BattleScene from "./battle-scene.svelte";
|
import BattleScene from "./battle-scene.svelte";
|
||||||
import PlaybackControls from "./playback-controls.svelte";
|
import PlaybackControls, {
|
||||||
|
type PlaybackSpeed,
|
||||||
|
} from "./playback-controls.svelte";
|
||||||
import { buildFrames } from "./timeline";
|
import { buildFrames } from "./timeline";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
report,
|
report,
|
||||||
shipClassLookup,
|
shipClassLookup,
|
||||||
|
onBackToMap,
|
||||||
|
onBackToReport,
|
||||||
}: {
|
}: {
|
||||||
report: BattleReport;
|
report: BattleReport;
|
||||||
shipClassLookup?: ShipClassLookup;
|
shipClassLookup?: ShipClassLookup;
|
||||||
|
onBackToMap?: () => void;
|
||||||
|
onBackToReport?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const frames = $derived(buildFrames(report));
|
const frames = $derived(buildFrames(report));
|
||||||
let frameIndex = $state(0);
|
let frameIndex = $state(0);
|
||||||
let playing = $state(false);
|
let playing = $state(false);
|
||||||
let speed = $state<1 | 2 | 4>(1);
|
let speed = $state<PlaybackSpeed>(1);
|
||||||
// shotVisible blinks off for the last 10% of each frame so two
|
let logOpen = $state(true);
|
||||||
// consecutive shots from the same attacker on the same defender
|
|
||||||
// look like two distinct flashes, not one continuous line. On
|
|
||||||
// pause the line stays drawn so the user can study it.
|
|
||||||
let shotVisible = $state(true);
|
let shotVisible = $state(true);
|
||||||
let logEl = $state<HTMLOListElement | null>(null);
|
let logEl = $state<HTMLOListElement | null>(null);
|
||||||
|
|
||||||
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
|
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
|
||||||
|
|
||||||
// Schedule one tick per frame instead of a long-running
|
// One tick per frame: blink the shot line off during the last
|
||||||
// setInterval so the blink and the frame-advance share the same
|
// 10 % of the frame's interval, then advance. Effect re-arms
|
||||||
// timeline. The effect re-runs whenever frameIndex / playing /
|
// whenever frameIndex / playing / speed changes; previous
|
||||||
// speed changes; on each run we (re)arm the blink + advance
|
// timers clean up through the return.
|
||||||
// timers and let the previous run's timers be cleared by the
|
|
||||||
// cleanup return.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Track changes via direct reads.
|
|
||||||
void frameIndex;
|
void frameIndex;
|
||||||
void speed;
|
void speed;
|
||||||
shotVisible = true;
|
shotVisible = true;
|
||||||
@@ -64,13 +74,11 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-scroll the log so the current row stays visible as the
|
// Auto-scroll the visible log row into view so the highlight
|
||||||
// timeline advances. `block: "nearest"` keeps the scroll movement
|
// keeps up with the timeline on long battles.
|
||||||
// gentle — the row lands at the closest edge of the visible
|
|
||||||
// portion instead of jumping to the centre.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void frame.shotIndex;
|
void frame.shotIndex;
|
||||||
if (logEl === null) return;
|
if (!logOpen || logEl === null) return;
|
||||||
const current = logEl.querySelector(
|
const current = logEl.querySelector(
|
||||||
'li[data-current="true"]',
|
'li[data-current="true"]',
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
@@ -87,6 +95,14 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onScrub(event: Event) {
|
||||||
|
const target = event.currentTarget as HTMLInputElement;
|
||||||
|
const value = Number(target.value);
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
playing = false;
|
||||||
|
frameIndex = Math.max(0, Math.min(frames.length - 1, Math.trunc(value)));
|
||||||
|
}
|
||||||
|
|
||||||
function describeAction(index: number): string {
|
function describeAction(index: number): string {
|
||||||
const action = report.protocol[index];
|
const action = report.protocol[index];
|
||||||
const attackerGroup = report.ships[String(action.sa)];
|
const attackerGroup = report.ships[String(action.sa)];
|
||||||
@@ -109,8 +125,29 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
|
|
||||||
<div class="viewer" data-testid="battle-viewer">
|
<div class="viewer" data-testid="battle-viewer">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
|
<div class="back-row">
|
||||||
|
{#if onBackToMap}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="back-btn"
|
||||||
|
onclick={onBackToMap}
|
||||||
|
data-testid="battle-back-to-map"
|
||||||
|
>{i18n.t("game.battle.back_to_map")}</button>
|
||||||
|
{/if}
|
||||||
|
{#if onBackToReport}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="back-btn"
|
||||||
|
onclick={onBackToReport}
|
||||||
|
data-testid="battle-back-to-report"
|
||||||
|
>{i18n.t("game.battle.back_to_report")}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<h2 data-testid="battle-viewer-title">
|
<h2 data-testid="battle-viewer-title">
|
||||||
{i18n.t("game.battle.title")}
|
{i18n.t("game.battle.header_title", {
|
||||||
|
planet_name: report.planetName,
|
||||||
|
planet_number: String(report.planet),
|
||||||
|
})}
|
||||||
</h2>
|
</h2>
|
||||||
<span class="progress" data-testid="battle-frame-index">
|
<span class="progress" data-testid="battle-frame-index">
|
||||||
{frame.shotIndex} / {report.protocol.length}
|
{frame.shotIndex} / {report.protocol.length}
|
||||||
@@ -121,13 +158,27 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
|
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="scrubber"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={Math.max(0, frames.length - 1)}
|
||||||
|
step="1"
|
||||||
|
value={frameIndex}
|
||||||
|
oninput={onScrub}
|
||||||
|
aria-label={i18n.t("game.battle.controls.scrub")}
|
||||||
|
data-testid="battle-scrubber"
|
||||||
|
/>
|
||||||
|
|
||||||
<PlaybackControls
|
<PlaybackControls
|
||||||
bind:playing
|
bind:playing
|
||||||
bind:frameIndex
|
bind:frameIndex
|
||||||
bind:speed
|
bind:speed
|
||||||
|
bind:logOpen
|
||||||
frameCount={frames.length}
|
frameCount={frames.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if logOpen}
|
||||||
<section
|
<section
|
||||||
class="log"
|
class="log"
|
||||||
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
|
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
|
||||||
@@ -148,38 +199,62 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.viewer {
|
.viewer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
max-width: 880px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 0.75rem 1rem;
|
||||||
color: #d6dcf2;
|
color: #d6dcf2;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
align-items: baseline;
|
gap: 0.75rem;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.header h2 {
|
.header h2 {
|
||||||
|
flex: 1 1 auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.05rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.04em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.back-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
appearance: none;
|
||||||
|
background: #1f2748;
|
||||||
|
color: #d6dcf2;
|
||||||
|
border: 1px solid #2c3568;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #2a3463;
|
||||||
}
|
}
|
||||||
.progress {
|
.progress {
|
||||||
color: #93a0d0;
|
color: #93a0d0;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 5rem;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.scene {
|
.scene {
|
||||||
background: #0a0d1a;
|
background: #0a0d1a;
|
||||||
@@ -189,6 +264,12 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
.scrubber {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
accent-color: #6d7bb5;
|
||||||
|
}
|
||||||
.log {
|
.log {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
min-height: 4rem;
|
min-height: 4rem;
|
||||||
@@ -198,15 +279,15 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.log h3 {
|
.log h3 {
|
||||||
margin: 0 0 0.4rem;
|
margin: 0 0 0.3rem;
|
||||||
color: #93a0d0;
|
color: #93a0d0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.log ol {
|
.log ol {
|
||||||
list-style: decimal inside;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
<!--
|
<!--
|
||||||
PlaybackControls — rewind / step-back / play-pause / step-forward
|
PlaybackControls — rewind / step-back / play-pause / step-forward
|
||||||
plus a 1x/2x/4x speed switch. Owns no playback state; bind `playing`,
|
plus a single cycling speed button (1x → 2x → 4x → 6x → 1x) and a
|
||||||
`frameIndex`, and `speed` from the orchestrator. Disables step/rewind
|
"log" toggle that the orchestrator uses to collapse the always-on
|
||||||
when there's nowhere to go and disables forward when the timeline is
|
text protocol when the user wants more space for the scene. Owns no
|
||||||
already at its end.
|
state of its own; binds `playing`, `frameIndex`, `speed`, and
|
||||||
|
`logOpen` from the orchestrator. Disables step/rewind when there's
|
||||||
|
nowhere to go and step-forward when the timeline is at its end.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
|
||||||
|
export type PlaybackSpeed = 1 | 2 | 4 | 6;
|
||||||
|
|
||||||
|
const SPEED_CYCLE: PlaybackSpeed[] = [1, 2, 4, 6];
|
||||||
|
|
||||||
let {
|
let {
|
||||||
playing = $bindable(),
|
playing = $bindable(),
|
||||||
frameIndex = $bindable(),
|
frameIndex = $bindable(),
|
||||||
speed = $bindable(),
|
speed = $bindable(),
|
||||||
|
logOpen = $bindable(),
|
||||||
frameCount,
|
frameCount,
|
||||||
}: {
|
}: {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
frameIndex: number;
|
frameIndex: number;
|
||||||
speed: 1 | 2 | 4;
|
speed: PlaybackSpeed;
|
||||||
|
logOpen: boolean;
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -38,9 +46,16 @@ already at its end.
|
|||||||
playing = false;
|
playing = false;
|
||||||
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
|
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
|
||||||
}
|
}
|
||||||
function setSpeed(value: 1 | 2 | 4) {
|
function cycleSpeed() {
|
||||||
speed = value;
|
const idx = SPEED_CYCLE.indexOf(speed);
|
||||||
|
const next = SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length];
|
||||||
|
speed = next;
|
||||||
}
|
}
|
||||||
|
function toggleLog() {
|
||||||
|
logOpen = !logOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedLabel = $derived(`${speed}x`);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="controls" data-testid="battle-controls">
|
<div class="controls" data-testid="battle-controls">
|
||||||
@@ -77,25 +92,25 @@ already at its end.
|
|||||||
|
|
||||||
<div class="spacer" aria-hidden="true"></div>
|
<div class="spacer" aria-hidden="true"></div>
|
||||||
|
|
||||||
<span class="speed-label">{i18n.t("game.battle.controls.speed_label")}</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={speed === 1}
|
class="speed-btn"
|
||||||
onclick={() => setSpeed(1)}
|
onclick={cycleSpeed}
|
||||||
data-testid="battle-control-speed-1x"
|
title={i18n.t("game.battle.controls.speed_label")}
|
||||||
>{i18n.t("game.battle.controls.speed_1x")}</button>
|
aria-label={i18n.t("game.battle.controls.speed_label")}
|
||||||
|
data-testid="battle-control-speed"
|
||||||
|
data-speed={speed}
|
||||||
|
>{speedLabel}</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={speed === 2}
|
class="log-toggle"
|
||||||
onclick={() => setSpeed(2)}
|
class:active={logOpen}
|
||||||
data-testid="battle-control-speed-2x"
|
onclick={toggleLog}
|
||||||
>{i18n.t("game.battle.controls.speed_2x")}</button>
|
aria-pressed={logOpen}
|
||||||
<button
|
aria-label={i18n.t("game.battle.controls.log_toggle")}
|
||||||
type="button"
|
data-testid="battle-control-log-toggle"
|
||||||
class:active={speed === 4}
|
>{i18n.t("game.battle.controls.log_toggle")} {logOpen ? "▲" : "▼"}</button>
|
||||||
onclick={() => setSpeed(4)}
|
|
||||||
data-testid="battle-control-speed-4x"
|
|
||||||
>{i18n.t("game.battle.controls.speed_4x")}</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -130,16 +145,11 @@ already at its end.
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
button.active {
|
.speed-btn {
|
||||||
background: #3a4585;
|
min-width: 3rem;
|
||||||
border-color: #5d6cb8;
|
font-variant-numeric: tabular-nums;
|
||||||
color: #ffffff;
|
|
||||||
}
|
}
|
||||||
.speed-label {
|
.log-toggle.active {
|
||||||
color: #93a0d0;
|
background: #2a3463;
|
||||||
font-size: 0.8rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-right: 0.2rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
// Radial layout for the BattleViewer.
|
// Radial layout for the BattleViewer.
|
||||||
//
|
//
|
||||||
// Places race anchors on a circle of radius `radius` around `center`
|
// Places race anchors on a circle of radius `radius` around `center`
|
||||||
// at equal angular spacing. The first anchor sits at the top (12
|
// at equal angular spacing. For three or more races the first anchor
|
||||||
// o'clock); subsequent anchors march clockwise. When a race is
|
// sits at the top (12 o'clock) and subsequent anchors march
|
||||||
// eliminated mid-battle, the caller filters it out of `activeRaceIds`
|
// clockwise. For exactly two races the pair is rotated 90° so they
|
||||||
// and the survivors are re-spaced on the next frame. The same helper
|
// face each other horizontally (3 o'clock vs 9 o'clock) — that keeps
|
||||||
// drives both the initial layout and that re-distribution.
|
// every race label clear of the SVG top edge when only two clusters
|
||||||
|
// remain, and reads as "the two sides facing off" naturally.
|
||||||
|
//
|
||||||
|
// When a race is eliminated mid-battle the caller filters it out of
|
||||||
|
// `activeRaceIds` and the survivors are re-spaced on the next frame
|
||||||
|
// through the same helper.
|
||||||
|
|
||||||
export interface RaceAnchor {
|
export interface RaceAnchor {
|
||||||
raceId: number;
|
raceId: number;
|
||||||
@@ -35,10 +40,14 @@ export function layoutRaces(
|
|||||||
if (count === 0) return [];
|
if (count === 0) return [];
|
||||||
const { center, radius } = options;
|
const { center, radius } = options;
|
||||||
const out: RaceAnchor[] = [];
|
const out: RaceAnchor[] = [];
|
||||||
|
// For two participants we want a horizontal duel layout: race 0
|
||||||
|
// at 9 o'clock, race 1 at 3 o'clock. For any other count the
|
||||||
|
// first anchor lands at the top (12 o'clock) and the rest march
|
||||||
|
// clockwise at equal spacing.
|
||||||
|
const startAngle = count === 2 ? Math.PI : -Math.PI / 2;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
// 12 o'clock = -PI/2 in math convention; clockwise → +i*step.
|
|
||||||
const step = (2 * Math.PI) / count;
|
const step = (2 * Math.PI) / count;
|
||||||
const angle = -Math.PI / 2 + i * step;
|
const angle = startAngle + i * step;
|
||||||
out.push({
|
out.push({
|
||||||
raceId: activeRaceIds[i],
|
raceId: activeRaceIds[i],
|
||||||
x: center.x + radius * Math.cos(angle),
|
x: center.x + radius * Math.cos(angle),
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ const en = {
|
|||||||
"game.report.section.battles.empty": "no battles last turn",
|
"game.report.section.battles.empty": "no battles last turn",
|
||||||
"game.report.section.battles.id_label": "battle",
|
"game.report.section.battles.id_label": "battle",
|
||||||
"game.battle.title": "battle",
|
"game.battle.title": "battle",
|
||||||
|
"game.battle.header_title": "Battle on planet {planet_name} (#{planet_number})",
|
||||||
"game.battle.loading": "loading battle…",
|
"game.battle.loading": "loading battle…",
|
||||||
"game.battle.not_found": "battle not found",
|
"game.battle.not_found": "battle not found",
|
||||||
"game.battle.back_to_report": "back to report",
|
"game.battle.back_to_report": "back to report",
|
||||||
@@ -497,6 +498,9 @@ const en = {
|
|||||||
"game.battle.controls.speed_1x": "1x",
|
"game.battle.controls.speed_1x": "1x",
|
||||||
"game.battle.controls.speed_2x": "2x",
|
"game.battle.controls.speed_2x": "2x",
|
||||||
"game.battle.controls.speed_4x": "4x",
|
"game.battle.controls.speed_4x": "4x",
|
||||||
|
"game.battle.controls.speed_6x": "6x",
|
||||||
|
"game.battle.controls.scrub": "scrub battle timeline",
|
||||||
|
"game.battle.controls.log_toggle": "Log",
|
||||||
"game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}",
|
"game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}",
|
||||||
"game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held",
|
"game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held",
|
||||||
"game.battle.accessibility.protocol_heading": "battle log",
|
"game.battle.accessibility.protocol_heading": "battle log",
|
||||||
|
|||||||
@@ -485,6 +485,10 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.report.section.battles.empty": "сражений в этом ходу не было",
|
"game.report.section.battles.empty": "сражений в этом ходу не было",
|
||||||
"game.report.section.battles.id_label": "сражение",
|
"game.report.section.battles.id_label": "сражение",
|
||||||
"game.battle.title": "сражение",
|
"game.battle.title": "сражение",
|
||||||
|
"game.battle.header_title": "Битва на планете {planet_name} (#{planet_number})",
|
||||||
|
"game.battle.controls.speed_6x": "6x",
|
||||||
|
"game.battle.controls.scrub": "перемотать таймлайн битвы",
|
||||||
|
"game.battle.controls.log_toggle": "Лог",
|
||||||
"game.battle.loading": "загрузка сражения…",
|
"game.battle.loading": "загрузка сражения…",
|
||||||
"game.battle.not_found": "сражение не найдено",
|
"game.battle.not_found": "сражение не найдено",
|
||||||
"game.battle.back_to_report": "к отчёту",
|
"game.battle.back_to_report": "к отчёту",
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ describe("layoutRaces", () => {
|
|||||||
expect(result[0].y).toBeCloseTo(center.y - radius, 5);
|
expect(result[0].y).toBeCloseTo(center.y - radius, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places two races at opposite poles (180° apart)", () => {
|
it("places two races on the horizontal axis (9 vs 3 o'clock)", () => {
|
||||||
|
// Special-case duel layout: two anchors face each other on
|
||||||
|
// the horizontal axis so neither cluster's race label clips
|
||||||
|
// against the SVG top edge.
|
||||||
const result = layoutRaces([0, 1], { center, radius });
|
const result = layoutRaces([0, 1], { center, radius });
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(result[0].x).toBeCloseTo(center.x, 5);
|
expect(result[0].x).toBeCloseTo(center.x - radius, 5);
|
||||||
expect(result[0].y).toBeCloseTo(center.y - radius, 5);
|
expect(result[0].y).toBeCloseTo(center.y, 5);
|
||||||
expect(result[1].x).toBeCloseTo(center.x, 5);
|
expect(result[1].x).toBeCloseTo(center.x + radius, 5);
|
||||||
expect(result[1].y).toBeCloseTo(center.y + radius, 5);
|
expect(result[1].y).toBeCloseTo(center.y, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places three races at 120° intervals", () => {
|
it("places three races at 120° intervals", () => {
|
||||||
|
|||||||
@@ -76,20 +76,20 @@ describe("active-view stubs", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("battle view stamps the battleId and renders the back-to-map link", () => {
|
test("battle view stamps the battleId and shows the loading placeholder", () => {
|
||||||
// Phase 27 replaces the Phase 10 stub with the Battle Viewer
|
// Phase 27 replaces the Phase 10 stub with the Battle Viewer
|
||||||
// wrapper. The wrapper mounts the loading copy until the
|
// wrapper. The latest layout iteration moved the back-
|
||||||
// fetcher resolves (component test runs in jsdom without a
|
// navigation buttons inside `BattleViewer` so they only mount
|
||||||
// network); the back buttons and the data-battle-id stamp are
|
// once the BattleReport finishes loading. The wrapper itself
|
||||||
// rendered unconditionally so the orchestrator scaffold is the
|
// always renders the `active-view-battle` host with the
|
||||||
// stable hook the active-view shell relies on.
|
// `data-battle-id` stamp and a localized loading copy until
|
||||||
|
// the fetcher resolves.
|
||||||
const ui = render(BattleView, {
|
const ui = render(BattleView, {
|
||||||
props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" },
|
props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" },
|
||||||
});
|
});
|
||||||
const node = ui.getByTestId("active-view-battle");
|
const node = ui.getByTestId("active-view-battle");
|
||||||
expect(node).toHaveAttribute("data-battle-id", "b-42");
|
expect(node).toHaveAttribute("data-battle-id", "b-42");
|
||||||
expect(ui.getByTestId("battle-back-to-map")).toBeInTheDocument();
|
expect(ui.getByTestId("battle-loading")).toBeInTheDocument();
|
||||||
expect(ui.getByTestId("battle-back-to-report")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("battle view surfaces the not-found state for an empty battleId", () => {
|
test("battle view surfaces the not-found state for an empty battleId", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user