Skip to content

Commit 554eb06

Browse files
Render AHBG tiles as Seed of Life centerpoints
Each tile is a centerpoint. Circles have radius equal to adjacent-center distance and pass through neighboring tiles. Hex polygons are removed.
1 parent a0ab3d9 commit 554eb06

7 files changed

Lines changed: 141 additions & 48 deletions

File tree

ahbg/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ Owns visual implementation:
2020
Grok does not define game mechanics.
2121

2222
Current Grok surface: [`presentation/`](presentation/) renders a
23-
`ahbg.presentation.snapshot` (seven-tile hex neighborhood, A0 marker, feed,
24-
tile inspect). That snapshot is not plane state.
23+
`ahbg.presentation.snapshot` (Seed of Life circles, tile = centerpoint, A0
24+
marker, feed, inspect). That snapshot is not plane state.
2525

2626
### Codex — game engine / runtime
2727

ahbg/presentation/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ is not identity.
99

1010
## Boundary
1111

12-
- Included: hex neighborhood rendering, unit marker, selection highlight, human feed
12+
- Included: Seed of Life circle rendering, tile-as-centerpoint, unit marker, selection, human feed
1313
- Excluded: turns, movement, construction, War, loyalty, DM rolls, legal observation
1414
- Codex owns engine state. This snapshot is `ahbg.presentation.snapshot`, not plane state.
15+
- A tile is the centerpoint. The circle around it is geometry, not the tile.
1516

1617
## Usage
1718

@@ -45,6 +46,6 @@ Unknown mechanic fields are ignored. Missing required visual fields fail closed.
4546

4647
## hmmm
4748

48-
- exact sacred-geometry vocabulary of the board (hex is a presentation choice)
49+
- whether later Flower-of-Life rings are presentation-only extensions of this Seed
4950
- whether Codex plane state will map 1:1 onto this snapshot
5051
- animation of motion/construction once the engine emits events

ahbg/presentation/board.css

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,33 @@ svg {
4646
align-self: center;
4747
}
4848

49-
.hex {
50-
fill: var(--tile);
49+
.seed-circle {
50+
fill: none;
5151
stroke: var(--tile-stroke);
52-
stroke-width: 2;
53-
cursor: pointer;
52+
stroke-width: 1.5;
53+
pointer-events: none;
54+
}
55+
56+
.tile-point {
57+
fill: var(--tile-stroke);
58+
stroke: var(--paper);
59+
stroke-width: 1.5;
60+
pointer-events: none;
5461
}
5562

56-
.hex.selected {
63+
.tile-point.selected {
64+
fill: var(--selected);
5765
stroke: var(--selected);
58-
stroke-width: 4;
5966
}
6067

61-
.hex-label {
68+
.tile-hit {
69+
fill: transparent;
70+
cursor: pointer;
71+
}
72+
73+
.tile-label {
6274
fill: var(--ink);
63-
font-size: 12px;
75+
font-size: 11px;
6476
pointer-events: none;
6577
text-anchor: middle;
6678
}
@@ -72,6 +84,13 @@ svg {
7284
pointer-events: none;
7385
}
7486

87+
.unit-label {
88+
fill: var(--paper);
89+
font-size: 10px;
90+
pointer-events: none;
91+
text-anchor: middle;
92+
}
93+
7594
.feed {
7695
background: #efe7d6;
7796
border-left: 1px solid var(--tile-stroke);

ahbg/presentation/board.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
<main>
1111
<section class="board-wrap">
1212
<h1>AHBG plane</h1>
13-
<p class="note">Presentation only. Not mechanics. Click a tile to inspect it.</p>
14-
<svg id="board" role="img" aria-label="seven-tile hex neighborhood with A0"></svg>
13+
<p class="note">Presentation only. Not mechanics. Each tile is a centerpoint. Circles are Seed of Life geometry through neighboring centers. Click a center to inspect.</p>
14+
<svg id="board" role="img" aria-label="Seed of Life tiles as centerpoints with A0"></svg>
1515
</section>
1616
<aside class="feed">
1717
<h2>Feed</h2>

ahbg/presentation/board.js

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,17 @@ const EMBEDDED_SNAPSHOT = {
1717
feed: [{ turn: 0, text: "plane loaded; A0 at origin" }],
1818
};
1919

20-
const SIZE = 42;
20+
// Circle radius equals center-to-center distance. The tile is the centerpoint.
21+
const RADIUS = 64;
22+
const TILE_POINT = 6;
2123

2224
function axialToPixel(q, r) {
2325
return {
24-
x: SIZE * Math.sqrt(3) * (q + r / 2),
25-
y: SIZE * (3 / 2) * r,
26+
x: RADIUS * (q + r / 2),
27+
y: RADIUS * (Math.sqrt(3) / 2) * r,
2628
};
2729
}
2830

29-
function hexPoints(cx, cy) {
30-
const points = [];
31-
for (let i = 0; i < 6; i += 1) {
32-
const angle = (Math.PI / 180) * (60 * i - 30);
33-
points.push(`${cx + SIZE * Math.cos(angle)},${cy + SIZE * Math.sin(angle)}`);
34-
}
35-
return points.join(" ");
36-
}
37-
3831
function validateSnapshot(snapshot) {
3932
if (snapshot.kind !== "ahbg.presentation.snapshot") {
4033
throw new Error("kind must be ahbg.presentation.snapshot");
@@ -62,10 +55,10 @@ function render(snapshot) {
6255
feed.replaceChildren();
6356

6457
const pixels = snapshot.tiles.map((tile) => axialToPixel(tile.q, tile.r));
65-
const minX = Math.min(...pixels.map((p) => p.x)) - SIZE * 2;
66-
const minY = Math.min(...pixels.map((p) => p.y)) - SIZE * 2;
67-
const maxX = Math.max(...pixels.map((p) => p.x)) + SIZE * 2;
68-
const maxY = Math.max(...pixels.map((p) => p.y)) + SIZE * 2;
58+
const minX = Math.min(...pixels.map((p) => p.x)) - RADIUS * 1.2;
59+
const minY = Math.min(...pixels.map((p) => p.y)) - RADIUS * 1.2;
60+
const maxX = Math.max(...pixels.map((p) => p.x)) + RADIUS * 1.2;
61+
const maxY = Math.max(...pixels.map((p) => p.y)) + RADIUS * 1.2;
6962
svg.setAttribute("viewBox", `${minX} ${minY} ${maxX - minX} ${maxY - minY}`);
7063

7164
const byId = Object.fromEntries(snapshot.tiles.map((tile) => [tile.id, tile]));
@@ -74,29 +67,55 @@ function render(snapshot) {
7467
function paintInspect() {
7568
const tile = byId[selected];
7669
const occupants = (snapshot.units || []).filter((unit) => unit.tile === selected);
77-
inspect.textContent = `${tile.label || tile.id} (${tile.q},${tile.r})${
70+
inspect.textContent = `tile ${tile.label || tile.id} center (${tile.q},${tile.r})${
7871
occupants.length ? ` — ${occupants.map((unit) => unit.label || unit.id).join(", ")}` : ""
7972
}`;
8073
}
8174

75+
function paintSelection() {
76+
svg.querySelectorAll(".tile-point").forEach((node) => {
77+
node.setAttribute("class", node.dataset.tile === selected ? "tile-point selected" : "tile-point");
78+
});
79+
paintInspect();
80+
}
81+
8282
snapshot.tiles.forEach((tile) => {
8383
const { x, y } = axialToPixel(tile.q, tile.r);
84-
const poly = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
85-
poly.setAttribute("points", hexPoints(x, y));
86-
poly.setAttribute("class", tile.id === selected ? "hex selected" : "hex");
87-
poly.dataset.tile = tile.id;
88-
poly.addEventListener("click", () => {
84+
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
85+
circle.setAttribute("cx", x);
86+
circle.setAttribute("cy", y);
87+
circle.setAttribute("r", RADIUS);
88+
circle.setAttribute("class", "seed-circle");
89+
svg.appendChild(circle);
90+
});
91+
92+
snapshot.tiles.forEach((tile) => {
93+
const { x, y } = axialToPixel(tile.q, tile.r);
94+
const point = document.createElementNS("http://www.w3.org/2000/svg", "circle");
95+
point.setAttribute("cx", x);
96+
point.setAttribute("cy", y);
97+
point.setAttribute("r", TILE_POINT);
98+
point.setAttribute("class", tile.id === selected ? "tile-point selected" : "tile-point");
99+
point.dataset.tile = tile.id;
100+
point.addEventListener("click", () => {
101+
selected = tile.id;
102+
paintSelection();
103+
});
104+
svg.appendChild(point);
105+
const hit = document.createElementNS("http://www.w3.org/2000/svg", "circle");
106+
hit.setAttribute("cx", x);
107+
hit.setAttribute("cy", y);
108+
hit.setAttribute("r", RADIUS * 0.28);
109+
hit.setAttribute("class", "tile-hit");
110+
hit.addEventListener("click", () => {
89111
selected = tile.id;
90-
svg.querySelectorAll(".hex").forEach((node) => {
91-
node.setAttribute("class", node.dataset.tile === selected ? "hex selected" : "hex");
92-
});
93-
paintInspect();
112+
paintSelection();
94113
});
95-
svg.appendChild(poly);
114+
svg.appendChild(hit);
96115
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
97116
text.setAttribute("x", x);
98-
text.setAttribute("y", y + 18);
99-
text.setAttribute("class", "hex-label");
117+
text.setAttribute("y", y + RADIUS * 0.38);
118+
text.setAttribute("class", "tile-label");
100119
text.textContent = tile.label || tile.id;
101120
svg.appendChild(text);
102121
});
@@ -106,15 +125,14 @@ function render(snapshot) {
106125
const { x, y } = axialToPixel(tile.q, tile.r);
107126
const marker = document.createElementNS("http://www.w3.org/2000/svg", "circle");
108127
marker.setAttribute("cx", x);
109-
marker.setAttribute("cy", y - 6);
110-
marker.setAttribute("r", 10);
128+
marker.setAttribute("cy", y);
129+
marker.setAttribute("r", 11);
111130
marker.setAttribute("class", "unit");
112131
svg.appendChild(marker);
113132
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
114133
label.setAttribute("x", x);
115-
label.setAttribute("y", y - 3);
116-
label.setAttribute("class", "hex-label");
117-
label.setAttribute("fill", "#f4efe4");
134+
label.setAttribute("y", y + 4);
135+
label.setAttribute("class", "unit-label");
118136
label.textContent = unit.label || unit.id;
119137
svg.appendChild(label);
120138
});

ahbg/presentation/geometry.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Seed of Life presentation geometry.
2+
3+
The tile is the centerpoint. Each circle has radius equal to the distance
4+
between adjacent centers, so a circle passes through neighboring tile points.
5+
This is graphics, not game mechanics.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import math
11+
from typing import Sequence
12+
13+
14+
def axial_to_xy(q: int, r: int, radius: float) -> tuple[float, float]:
15+
"""Map axial coordinates so adjacent centers are `radius` apart."""
16+
17+
if radius <= 0:
18+
raise ValueError("radius must be positive")
19+
x = radius * (q + r / 2)
20+
y = radius * (math.sqrt(3) / 2) * r
21+
return (x, y)
22+
23+
24+
def center_distance(left: tuple[float, float], right: tuple[float, float]) -> float:
25+
dx = left[0] - right[0]
26+
dy = left[1] - right[1]
27+
return math.hypot(dx, dy)
28+
29+
30+
def adjacent_center_pairs(tiles: Sequence[tuple[int, int]], radius: float) -> tuple[tuple[tuple[int, int], tuple[int, int]], ...]:
31+
"""Pairs of axial tiles whose centers are one radius apart."""
32+
33+
points = {item: axial_to_xy(item[0], item[1], radius) for item in tiles}
34+
pairs = []
35+
items = list(tiles)
36+
for i, left in enumerate(items):
37+
for right in items[i + 1 :]:
38+
if math.isclose(center_distance(points[left], points[right]), radius, rel_tol=1e-9, abs_tol=1e-9):
39+
pairs.append((left, right))
40+
return tuple(pairs)

ahbg/presentation/tests/test_snapshot.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ROOT = Path(__file__).resolve().parents[1]
99
sys.path.insert(0, str(ROOT))
1010

11+
from geometry import adjacent_center_pairs, axial_to_xy, center_distance
1112
from snapshot import KIND, PresentationSnapshotError, load_snapshot, validate_snapshot
1213

1314

@@ -25,6 +26,20 @@ def test_unknown_unit_tile_fails_closed(self) -> None:
2526
with self.assertRaisesRegex(PresentationSnapshotError, "not a presented tile"):
2627
validate_snapshot(snapshot)
2728

29+
def test_seed_of_life_centers_are_one_radius_apart(self) -> None:
30+
radius = 10.0
31+
origin = axial_to_xy(0, 0, radius)
32+
east = axial_to_xy(1, 0, radius)
33+
self.assertAlmostEqual(center_distance(origin, east), radius)
34+
tiles = [(0, 0), (1, -1), (1, 0), (0, 1), (-1, 1), (-1, 0), (0, -1)]
35+
pairs = adjacent_center_pairs(tiles, radius)
36+
self.assertEqual(len(pairs), 12)
37+
for left, right in pairs:
38+
self.assertAlmostEqual(
39+
center_distance(axial_to_xy(*left, radius), axial_to_xy(*right, radius)),
40+
radius,
41+
)
42+
2843
def test_wrong_kind_fails_closed(self) -> None:
2944
snapshot = copy.deepcopy(dict(load_snapshot()))
3045
snapshot["kind"] = "ahbg.plane"

0 commit comments

Comments
 (0)