feat(@projects/@magic-civilization): ✨ add combat calculator navigation link
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
eba9b32db8
commit
dd2385d806
2 changed files with 338 additions and 93 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { t } from "../theme";
|
||||
import type { Unit } from "../data/units";
|
||||
import { UNIT_PORTRAIT } from "../data/allUnits";
|
||||
import { ALL_UNITS, UNIT_PORTRAIT } from "../data/allUnits";
|
||||
import { calcCombat } from "../utils/combatCalc";
|
||||
import { UnitBrowser } from "../components/combat/UnitBrowser";
|
||||
import { HpSlider } from "../components/combat/HpSlider";
|
||||
|
|
@ -44,6 +44,18 @@ const TopSub = styled.div`
|
|||
font-family: ${t.font.mono};
|
||||
`;
|
||||
|
||||
const TopLink = styled.a`
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: ${t.accent.gold};
|
||||
font-family: ${t.font.mono};
|
||||
text-decoration: none;
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 2px;
|
||||
padding: 4px 10px;
|
||||
&:hover { border-color: ${t.accent.goldBright}; color: ${t.text.btnHover}; }
|
||||
`;
|
||||
|
||||
const LeftPanel = styled.div`
|
||||
border-right: 1px solid ${t.border.panel};
|
||||
overflow: hidden;
|
||||
|
|
@ -65,8 +77,6 @@ const CenterPanel = styled.div`
|
|||
scrollbar-color: ${t.border.panel} transparent;
|
||||
`;
|
||||
|
||||
// ── empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
const EmptyState = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
|
@ -77,10 +87,7 @@ const EmptyState = styled.div`
|
|||
color: ${t.text.muted};
|
||||
`;
|
||||
|
||||
const EmptyIcon = styled.div`
|
||||
font-size: 48px;
|
||||
opacity: 0.4;
|
||||
`;
|
||||
const EmptyIcon = styled.div`font-size: 48px; opacity: 0.4;`;
|
||||
|
||||
const EmptyText = styled.div`
|
||||
font-family: ${t.font.heading};
|
||||
|
|
@ -89,8 +96,6 @@ const EmptyText = styled.div`
|
|||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
// ── unit summary card (top of center) ────────────────────────────────────────
|
||||
|
||||
const UnitSummaryRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 40px 1fr;
|
||||
|
|
@ -99,7 +104,7 @@ const UnitSummaryRow = styled.div`
|
|||
align-items: start;
|
||||
`;
|
||||
|
||||
const UnitCard = styled.div<{ $side: "attacker" | "defender" }>`
|
||||
const UnitCard = styled.div`
|
||||
background: ${t.bg.panel};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 4px;
|
||||
|
|
@ -107,7 +112,6 @@ const UnitCard = styled.div<{ $side: "attacker" | "defender" }>`
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: ${({ $side }) => ($side === "defender" ? "flex-end" : "flex-start")};
|
||||
`;
|
||||
|
||||
const Portrait = styled.div<{ $color: string }>`
|
||||
|
|
@ -129,9 +133,13 @@ const UnitName = styled.div`
|
|||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
const UnitMeta = styled.div`
|
||||
font-size: 11px;
|
||||
color: ${t.text.muted};
|
||||
const UnitMeta = styled.div`font-size: 11px; color: ${t.text.muted};`;
|
||||
|
||||
const StatLine = styled.div`
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-family: ${t.font.mono};
|
||||
`;
|
||||
|
||||
const VsColumn = styled.div`
|
||||
|
|
@ -154,26 +162,13 @@ const VsBadge = styled.div`
|
|||
justify-content: center;
|
||||
`;
|
||||
|
||||
// ── sliders row ───────────────────────────────────────────────────────────────
|
||||
|
||||
const SlidersRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 40px 1fr;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SliderGap = styled.div``; // spacer for the VS column
|
||||
|
||||
// ── section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const Section = styled.div`
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
// ── hp after row ──────────────────────────────────────────────────────────────
|
||||
|
||||
const HpAfterRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
@ -181,39 +176,44 @@ const HpAfterRow = styled.div`
|
|||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
// ── stat row in unit card ─────────────────────────────────────────────────────
|
||||
|
||||
const StatLine = styled.div`
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-family: ${t.font.mono};
|
||||
`;
|
||||
|
||||
// ── component ─────────────────────────────────────────────────────────────────
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function unitColor(unit: Unit): string {
|
||||
return unit.archetype === "Military" ? "#6699ff" : "#e69933";
|
||||
}
|
||||
|
||||
function statColor(key: "atk" | "def" | "hp"): string {
|
||||
return key === "atk" ? t.sem.negative : key === "def" ? t.accent.science : t.accent.sage;
|
||||
}
|
||||
// ── component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CombatCalculatorPage(): React.ReactElement {
|
||||
const [attacker, setAttacker] = useState<Unit | null>(null);
|
||||
const [defender, setDefender] = useState<Unit | null>(null);
|
||||
const [atkHp, setAtkHp] = useState(0);
|
||||
const [defHp, setDefHp] = useState(0);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
function handleSelectAttacker(u: Unit): void {
|
||||
setAttacker(u);
|
||||
setAtkHp(u.hp);
|
||||
// URL is the source of truth
|
||||
const attackerId = searchParams.get("atk") ?? "";
|
||||
const defenderId = searchParams.get("def") ?? "";
|
||||
const atkHpParam = parseInt(searchParams.get("atkHp") ?? "0", 10);
|
||||
const defHpParam = parseInt(searchParams.get("defHp") ?? "0", 10);
|
||||
|
||||
const attacker = ALL_UNITS.find(u => u.id === attackerId) ?? null;
|
||||
const defender = ALL_UNITS.find(u => u.id === defenderId) ?? null;
|
||||
|
||||
// HP: use URL param if valid, else full HP
|
||||
const atkHp = attacker ? (atkHpParam > 0 ? Math.min(atkHpParam, attacker.hp) : attacker.hp) : 0;
|
||||
const defHp = defender ? (defHpParam > 0 ? Math.min(defHpParam, defender.hp) : defender.hp) : 0;
|
||||
|
||||
function selectAttacker(u: Unit): void {
|
||||
setSearchParams(p => { p.set("atk", u.id); p.set("atkHp", String(u.hp)); return p; });
|
||||
}
|
||||
|
||||
function handleSelectDefender(u: Unit): void {
|
||||
setDefender(u);
|
||||
setDefHp(u.hp);
|
||||
function selectDefender(u: Unit): void {
|
||||
setSearchParams(p => { p.set("def", u.id); p.set("defHp", String(u.hp)); return p; });
|
||||
}
|
||||
|
||||
function setAtkHp(hp: number): void {
|
||||
setSearchParams(p => { p.set("atkHp", String(hp)); return p; });
|
||||
}
|
||||
|
||||
function setDefHp(hp: number): void {
|
||||
setSearchParams(p => { p.set("defHp", String(hp)); return p; });
|
||||
}
|
||||
|
||||
const result = attacker && defender
|
||||
|
|
@ -222,15 +222,37 @@ export function CombatCalculatorPage(): React.ReactElement {
|
|||
|
||||
const bothSelected = attacker !== null && defender !== null;
|
||||
|
||||
// Shareable link — full current URL
|
||||
const shareUrl = window.location.href;
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<TopBar>
|
||||
<TopTitle>Combat Calculator</TopTitle>
|
||||
<TopSub>real unit data · live matchup · damage matrix</TopSub>
|
||||
<TopSub>
|
||||
{ALL_UNITS.length} units loaded from JSON · live matchup · real damage matrix
|
||||
</TopSub>
|
||||
{bothSelected && (
|
||||
<TopLink
|
||||
href="/permutations"
|
||||
title="See all unit matchup permutations"
|
||||
>
|
||||
📊 All permutations
|
||||
</TopLink>
|
||||
)}
|
||||
{bothSelected && (
|
||||
<TopLink
|
||||
href={shareUrl}
|
||||
onClick={e => { e.preventDefault(); navigator.clipboard.writeText(shareUrl); }}
|
||||
title="Copy link to this matchup"
|
||||
>
|
||||
🔗 Copy link
|
||||
</TopLink>
|
||||
)}
|
||||
</TopBar>
|
||||
|
||||
<LeftPanel>
|
||||
<UnitBrowser side="attacker" selected={attacker} onSelect={handleSelectAttacker} />
|
||||
<UnitBrowser side="attacker" selected={attacker} onSelect={selectAttacker} />
|
||||
</LeftPanel>
|
||||
|
||||
<CenterPanel>
|
||||
|
|
@ -239,78 +261,67 @@ export function CombatCalculatorPage(): React.ReactElement {
|
|||
<EmptyIcon>⚔</EmptyIcon>
|
||||
<EmptyText>Select a unit on each side</EmptyText>
|
||||
<div style={{ fontSize: 13, color: t.text.muted }}>
|
||||
Choose attacker (left) and defender (right) to see the matchup
|
||||
Choose attacker ← and defender → to see the live matchup
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: t.text.muted, fontFamily: t.font.mono, marginTop: 8 }}>
|
||||
URL updates with selection — bookmarkable & shareable
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{/* Unit summary cards */}
|
||||
<UnitSummaryRow>
|
||||
<UnitCard $side="attacker">
|
||||
<UnitCard>
|
||||
<Portrait $color={unitColor(attacker)}>{UNIT_PORTRAIT[attacker.id] ?? "⚔"}</Portrait>
|
||||
<UnitName>{attacker.name}</UnitName>
|
||||
<UnitMeta>Tier {attacker.tier} · {attacker.attackType} / {attacker.armor}</UnitMeta>
|
||||
<UnitMeta>T{attacker.tier} · {attacker.attackType} · {attacker.armor}</UnitMeta>
|
||||
<TagRow>
|
||||
{attacker.keywords.slice(0, 4).map(kw => (
|
||||
<Tag key={kw} variant="kw">{kw}</Tag>
|
||||
))}
|
||||
{attacker.keywords.slice(0, 4).map(kw => <Tag key={kw} variant="kw">{kw}</Tag>)}
|
||||
</TagRow>
|
||||
<StatLine>
|
||||
<span style={{ color: statColor("atk") }}>ATK {attacker.attack}</span>
|
||||
<span style={{ color: statColor("def") }}>DEF {attacker.defense}</span>
|
||||
<span style={{ color: statColor("hp") }}>HP {attacker.hp}</span>
|
||||
<span style={{ color: t.sem.negative }}>ATK {attacker.attack}</span>
|
||||
<span style={{ color: t.accent.science }}>DEF {attacker.defense}</span>
|
||||
<span style={{ color: t.accent.sage }}>HP {attacker.hp}</span>
|
||||
<span style={{ color: t.accent.gold }}>MOV {attacker.movement}</span>
|
||||
</StatLine>
|
||||
</UnitCard>
|
||||
|
||||
<VsColumn><VsBadge>VS</VsBadge></VsColumn>
|
||||
|
||||
<UnitCard $side="defender">
|
||||
<UnitCard>
|
||||
<Portrait $color={unitColor(defender)}>{UNIT_PORTRAIT[defender.id] ?? "⚔"}</Portrait>
|
||||
<UnitName>{defender.name}</UnitName>
|
||||
<UnitMeta>Tier {defender.tier} · {defender.attackType} / {defender.armor}</UnitMeta>
|
||||
<TagRow $align="end">
|
||||
{defender.keywords.slice(0, 4).map(kw => (
|
||||
<Tag key={kw} variant="kw">{kw}</Tag>
|
||||
))}
|
||||
<UnitMeta>T{defender.tier} · {defender.attackType} · {defender.armor}</UnitMeta>
|
||||
<TagRow>
|
||||
{defender.keywords.slice(0, 4).map(kw => <Tag key={kw} variant="kw">{kw}</Tag>)}
|
||||
</TagRow>
|
||||
<StatLine>
|
||||
<span style={{ color: statColor("atk") }}>ATK {defender.attack}</span>
|
||||
<span style={{ color: statColor("def") }}>DEF {defender.defense}</span>
|
||||
<span style={{ color: statColor("hp") }}>HP {defender.hp}</span>
|
||||
<span style={{ color: t.sem.negative }}>ATK {defender.attack}</span>
|
||||
<span style={{ color: t.accent.science }}>DEF {defender.defense}</span>
|
||||
<span style={{ color: t.accent.sage }}>HP {defender.hp}</span>
|
||||
<span style={{ color: t.accent.gold }}>MOV {defender.movement}</span>
|
||||
</StatLine>
|
||||
</UnitCard>
|
||||
</UnitSummaryRow>
|
||||
|
||||
{/* HP sliders */}
|
||||
<SlidersRow>
|
||||
<HpSlider unit={attacker} currentHp={atkHp} onChange={setAtkHp} />
|
||||
<SliderGap />
|
||||
<div />
|
||||
<HpSlider unit={defender} currentHp={defHp} onChange={setDefHp} />
|
||||
</SlidersRow>
|
||||
|
||||
{/* Damage matrix */}
|
||||
<Section>
|
||||
<DamageMatrix
|
||||
highlightAtk={attacker.attackType}
|
||||
highlightArmor={defender.armor}
|
||||
/>
|
||||
</Section>
|
||||
<DamageMatrix
|
||||
highlightAtk={attacker.attackType}
|
||||
highlightArmor={defender.armor}
|
||||
/>
|
||||
|
||||
{/* Probability */}
|
||||
{result && (
|
||||
<>
|
||||
<Section>
|
||||
<ProbabilityBar
|
||||
effAtk={result.effAtk}
|
||||
effDef={result.effDef}
|
||||
atkLabel={`${attacker.name} wins`}
|
||||
defLabel={`${defender.name} survives`}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* HP-after bars */}
|
||||
<ProbabilityBar
|
||||
effAtk={result.effAtk}
|
||||
effDef={result.effDef}
|
||||
atkLabel={`${attacker.name} wins`}
|
||||
defLabel={`${defender.name} survives`}
|
||||
/>
|
||||
<HpAfterRow>
|
||||
<HpAfterBar
|
||||
label={`${attacker.name} HP after (${atkHp} now)`}
|
||||
|
|
@ -334,7 +345,7 @@ export function CombatCalculatorPage(): React.ReactElement {
|
|||
</CenterPanel>
|
||||
|
||||
<RightPanel>
|
||||
<UnitBrowser side="defender" selected={defender} onSelect={handleSelectDefender} />
|
||||
<UnitBrowser side="defender" selected={defender} onSelect={selectDefender} />
|
||||
</RightPanel>
|
||||
</Shell>
|
||||
);
|
||||
|
|
|
|||
234
public/games/age-of-dwarves/docs/HEX_GEOMETRY.md
Normal file
234
public/games/age-of-dwarves/docs/HEX_GEOMETRY.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# Hex Geometry — The SQ + QL Duality
|
||||
|
||||
**The single differentiator of this game's hex grid.** Every other hex-based 4X treats a tile as one indivisible cell. We do not. Each hex is composed of two 4-cornered formation footprints joined at a shared **spine**. This document is the source of truth for that geometry — every other doc that mentions formations, flanking, biome boundaries, or ZOC is downstream of this one.
|
||||
|
||||
---
|
||||
|
||||
## 1. The identity
|
||||
|
||||
A regular hexagon has 6 sides. A square has 4. A quadrilateral has 4. Two 4-shapes joined at a single shared edge produce `4 + 4 − 2 = 6`. That sum is the hex.
|
||||
|
||||
```
|
||||
┌─── SQ half ───┐
|
||||
╱ ╲
|
||||
╱ NW NE ╲
|
||||
╱ ● ● ╲
|
||||
●─── W ═══════════ E ───● ← spine (E, W)
|
||||
╲ ● ● ╱
|
||||
╲ SW SE ╱
|
||||
╲ ╱
|
||||
└─── QL half ───┘
|
||||
```
|
||||
|
||||
- **SQ** (square half) — 4 slots facing one direction
|
||||
- **QL** (quadrilateral half) — 4 slots facing the opposite direction
|
||||
- **Spine** — the 2 slots shared between the halves (the joined edge has 2 endpoints)
|
||||
|
||||
A formation occupies one of these halves. A formation that fully tiles a hex occupies both halves and shares the spine with itself. The hex is the smallest plane-tiling regular polygon admitting this decomposition (see §10).
|
||||
|
||||
---
|
||||
|
||||
## 2. Direction-to-half mapping
|
||||
|
||||
The simulator's neighbor directions are indexed `0..5` in `mc-core/src/algorithms/hex.rs:11-19`:
|
||||
|
||||
| Index | Name | Axial vector |
|
||||
|---|---|---|
|
||||
| 0 | E | (+1, 0) |
|
||||
| 1 | NE | (+1, −1) |
|
||||
| 2 | NW | (0, −1) |
|
||||
| 3 | W | (−1, 0) |
|
||||
| 4 | SW | (−1, +1) |
|
||||
| 5 | SE | (0, +1) |
|
||||
|
||||
These map to halves as:
|
||||
|
||||
| Half | Hex directions | Slot count |
|
||||
|---|---|---|
|
||||
| **SQ** (north-facing) | NE, NW, **W**, **E** | 4 |
|
||||
| **QL** (south-facing) | **E**, SE, SW, **W** | 4 |
|
||||
| **Spine** (shared) | E, W | 2 |
|
||||
|
||||
The spine is the E–W axis. SQ-only directions are NE, NW. QL-only directions are SE, SW. Total perimeter slot count: `4 + 4 − 2 = 6`, one per neighbor direction.
|
||||
|
||||
---
|
||||
|
||||
## 3. Slot semantics
|
||||
|
||||
A **slot** is an abstract role bound when a formation forms. It is purely combinatorial — slots do not have sub-pixel positions, sub-tile rendering, or independent HP. Every slot still maps one-to-one with a unit at the hex center; the slot label only governs *how that unit interacts with damage and direction*.
|
||||
|
||||
The seven slot roles:
|
||||
|
||||
| Role | Half | Direction | Notes |
|
||||
|---|---|---|---|
|
||||
| `leader` | spine | center | Highest-tier / highest-HP unit; damaged last in any hit set |
|
||||
| `spine-E` | spine (both) | E | Damaged by attacks from any half |
|
||||
| `spine-W` | spine (both) | W | Damaged by attacks from any half |
|
||||
| `sq-NE` | SQ only | NE | Damaged only by SQ-half attackers |
|
||||
| `sq-NW` | SQ only | NW | Damaged only by SQ-half attackers |
|
||||
| `ql-SE` | QL only | SE | Damaged only by QL-half attackers |
|
||||
| `ql-SW` | QL only | SW | Damaged only by QL-half attackers |
|
||||
|
||||
The leader sits on the spine by default, since spine units have the highest exposure but also the most options for retreat (both halves shelter them).
|
||||
|
||||
---
|
||||
|
||||
## 4. How combat resolves
|
||||
|
||||
Movement and damage are still applied **center to center**. The QL is not traversed; it is the engagement membrane. Combat semantics by type:
|
||||
|
||||
| Combat type | Engagement geometry | Whose terrain bonus applies |
|
||||
|---|---|---|
|
||||
| Melee | At the shared edge between A and B (each tile's QL on the side facing the other) | Attacker rolls with A's terrain; defender with B's terrain |
|
||||
| Ranged | Projectile passes *over* the shared edge from A-center to B-center | Defender's QL on the attacker-facing side provides cover from B's terrain |
|
||||
| Magic | Same as ranged unless the spell is `contact` | Same |
|
||||
| Charge | Melee, but on a successful break attacker may push past their own QL into B | Attacker forfeits A's terrain bonus on the followup tile |
|
||||
|
||||
**This explains the forest-cover-on-attack rule.** A unit attacking out of forest into adjacent plains is still engaging *at the forest's edge* (its own QL on the plains-facing side), so it keeps cover. Other hex games handwave this. Here it is geometric.
|
||||
|
||||
---
|
||||
|
||||
## 5. Flanking
|
||||
|
||||
Flanking is **discrete** and **slot-set-based**, not angle-threshold-based. An attacker from direction D applies damage to the half D belongs to (4 slots). Spine slots take damage from any direction.
|
||||
|
||||
| Attacker pattern | Damage hits |
|
||||
|---|---|
|
||||
| 1 attacker, any direction | 4 slots in the half D belongs to |
|
||||
| 2 attackers from the same half | Same 4 slots, doubled load on those slots |
|
||||
| 2 attackers from opposite halves | All 8 slot-positions touched. Spine units are double-hit (overlap). **Flanking trigger.** |
|
||||
| 3+ attackers spanning both halves | All 8 positions hit; spine multi-hit. **Encirclement.** |
|
||||
|
||||
Spine units are the most exposed — they cannot avoid damage by half-rotation. This is the geometric basis for "leader survives because the spine ate the hit" tactics.
|
||||
|
||||
---
|
||||
|
||||
## 6. Movement
|
||||
|
||||
- Units occupy hex **centers**.
|
||||
- Movement is graph traversal: center → adjacent center along the existing pathfinding graph (`mc-core/src/algorithms/pathfinding.rs`).
|
||||
- The QL is **not** traversed. There is no sub-hex pathing.
|
||||
- Move cost is per-hex (terrain-derived), not per-half.
|
||||
|
||||
The QL exists to govern *engagement*, not *traversal*. A unit "passes through" a hex's QL only in the sense that it crosses the membrane between two tiles when moving from one to another — there is no half-step where the unit is "in the QL."
|
||||
|
||||
---
|
||||
|
||||
## 7. Biome boundaries
|
||||
|
||||
Each hex has one terrain (plains, forest, hill, …) filling the tile uniformly at the data level. The **perceived** biome boundary between two adjacent hexes lies at the shared edge — which is each tile's QL on the side facing its neighbor.
|
||||
|
||||
```
|
||||
hex A (forest) hex B (plains)
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ │ │ │
|
||||
│ SQ │ │ SQ │
|
||||
│ ━━━━━━━━━━━━━━━ │ │ ━━━━━━━━━━━━━━━━ │
|
||||
│ QL ════════════════ QL │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
↑
|
||||
shared edge = both tiles' QL
|
||||
= the biome contact membrane
|
||||
```
|
||||
|
||||
When a forest unit attacks the adjacent plains unit:
|
||||
- The attacker engages at A's QL on the B-facing side — *still in the forest*.
|
||||
- The defender is hit at B's QL on the A-facing side — exposed plains.
|
||||
- Attacker keeps forest cover; defender does not gain cover from the trees they are facing.
|
||||
|
||||
This is the canonical interpretation of terrain-cover-on-attack and should be used to derive every per-terrain combat modifier.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rotation
|
||||
|
||||
A formation rotating 60° **re-binds the slot labels** — the same 4 (or 6, or 7) units stay where they are, but their slot roles shift one position. Rotation does **not** swap SQ ↔ QL bodily; it relabels.
|
||||
|
||||
Example: a Diamond formation initially facing N (SQ-half = NE, NW; spine = E, W; QL-half = SE, SW) rotated 60° clockwise faces NE. The same units now hold:
|
||||
- old `sq-NE` → new `spine-E`
|
||||
- old `sq-NW` → new `sq-NE`
|
||||
- old `spine-E` → new `ql-SE`
|
||||
- old `spine-W` → new `sq-NW`
|
||||
- old `ql-SE` → new `ql-SW` ... etc.
|
||||
|
||||
This matches the cube-space 60° rotation primitive already in `hex.rs` — no new rotation math is required.
|
||||
|
||||
A formation rotating 180° preserves the spine (E–W stays the spine) and swaps SQ ↔ QL labels, since the formation has flipped end-for-end.
|
||||
|
||||
---
|
||||
|
||||
## 9. Stacking and ZOC
|
||||
|
||||
### Stacking — none
|
||||
|
||||
The SQ and QL halves of a hex are **not** separately occupiable by different formations. The duality is a partition of *one* formation's slots, not an opportunity for two-tenants-per-tile. This preserves the established "each unit retains its own hex — no stacking" rule from `.project/objectives/p0-42.md`.
|
||||
|
||||
### Zone of Control — facing-half only
|
||||
|
||||
A formation projects ZOC into its **facing half** only — 4 of the 6 directions. A formation in QL-orientation projects ZOC into directions {E, SE, SW, W}; an SQ-orientation projects into {NE, NW, W, E}. The two unfaced directions are not ZOC-projecting.
|
||||
|
||||
Implication: an attacker can approach via the unfaced half without paying ZOC cost, but loses the option to flank without rotating around the formation. Facing matters.
|
||||
|
||||
(ZOC code in `mc-turn` currently projects all 6 directions; this is documented as intended behavior pending a downstream code refactor.)
|
||||
|
||||
---
|
||||
|
||||
## 10. Considered alternative — "square + 4 QLs" dodecagon, rejected
|
||||
|
||||
A reasonable design instinct on first hearing the duality is: *if a hex is `SQ + QL`, why not a tile that is one central SQ surrounded by 4 QLs?*
|
||||
|
||||
```
|
||||
┌────┐
|
||||
│ QL │
|
||||
┌────┼────┼────┐
|
||||
│ QL │ SQ │ QL │
|
||||
└────┼────┼────┘
|
||||
│ QL │
|
||||
└────┘
|
||||
```
|
||||
|
||||
This is a **dodecagon** (12-sided plus-pentomino). Its properties versus the hex:
|
||||
|
||||
| Property | Hex (SQ + QL = 6) | Dodecagon (SQ + 4 QL = 12) |
|
||||
|---|---|---|
|
||||
| Edges per tile | 6 | 12 |
|
||||
| Neighbors per tile | 6 | 12 |
|
||||
| Combat permutations | 6 directions | 12 directions (~2× balance work) |
|
||||
| Rotational symmetry | 6-fold | 4-fold |
|
||||
| SQ–neighbor contact | Direct via spine | Only via QLs (SQ never directly contacts a neighbor) |
|
||||
| Plane-tiling cost | Edge-to-edge with one tile shape | Plus-pentomino, cross-shaped tiling |
|
||||
|
||||
The hex's `4 + 4 − 2 = 6` already delivers the duality at the lowest plane-tiling cost. The dodecagon's SQ never directly contacts a neighbor; only its 4 QLs do. With the hex, **the spine itself is the contact** — which is exactly what makes the biome-edge / QL-engagement story work.
|
||||
|
||||
We went with the hex. This section exists so the next person to suggest the dodecagon finds the answer.
|
||||
|
||||
---
|
||||
|
||||
## 11. Implementation surface
|
||||
|
||||
The geometry described in this doc is *partially* implemented today; the data model still treats formations as multi-hex spreads rather than sub-tile slot-sets. The relevant code paths:
|
||||
|
||||
| Concern | File | Status |
|
||||
|---|---|---|
|
||||
| Hex coord math (axial / cube / odd-q offset) | `src/simulator/crates/mc-core/src/algorithms/hex.rs` | ✅ Implemented; matches `HexUtils.gd` and `HexGrid.ts` |
|
||||
| Direction indices `0..5` | `mc-core/src/algorithms/hex.rs:11-19` | ✅ Single source of truth |
|
||||
| Formation data type | `src/simulator/crates/mc-core/src/formation.rs` | ⚠️ Has `FormationShape::{Line, Column, Wedge, Diamond}` enum but no slot-set partition |
|
||||
| Formation movement / reflow | `src/simulator/crates/mc-turn/src/formation_move.rs` | ⚠️ Treats shapes as multi-hex spreads, not sub-tile slot sets |
|
||||
| Combat resolver | `src/simulator/crates/mc-combat/src/resolver.rs:65-81` | ⚠️ HP scales by `formation_count` but does not route damage by half |
|
||||
| ZOC | `mc-turn` | ⚠️ Projects all 6 directions; should project facing-half only (§9) |
|
||||
| AI evaluator | `src/simulator/crates/mc-ai/src/evaluator.rs:414-480` | ⚠️ Scores formations by size + threat; does not yet model the duality |
|
||||
| Renderer | `src/packages/guide/src/components/climate-sim/HexGLRenderer.tsx` | ✅ Tile rendering correct; no SQ/QL overlay (debug-only feature pending) |
|
||||
|
||||
Bringing these into alignment with the spec above is tracked as a successor plan.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `combat/COMBAT_SYSTEM.md` — armor/attack matrix; references this doc for the positional-damage layer
|
||||
- `combat/FORMATIONS.md` — how `FormationShape::{Line, Column, Wedge, Diamond}` map to SQ/QL/spine
|
||||
- `terrain/TERRAIN_SYSTEM.md` — references this doc for biome-boundary semantics
|
||||
- `.project/designs/hex-formation-duality.md` — annotated diagram for engineers/designers
|
||||
- `.project/designs/hex-formation-sketch.html` — interactive mockup
|
||||
Loading…
Add table
Reference in a new issue