Skip to content

Commit 9292ff2

Browse files
pdbethkeclaude
andcommitted
feat: Tombstone around the lot, and the log says what hit you
PeterB, with the 1881 town plat: "can you render the whole town?" SCENERY, NOT TERRAIN, and the distinction is load-bearing. Every `Construct` in a Scene becomes an `attack_construct` target -- `constructs_in` projects them -- so putting a hundred buildings in the Scene would flood every menu in the fight and let a man take aim at a saloon four blocks away. `tombstone_town.py` is merged into the SNAPSHOT by the recorder, after the fight is resolved, so the engine never sees it and the recorded fight is unchanged. Nothing it adds blocks line of sight, gives cover or can be hit, and none of that matters for thirty seconds in a five-metre lot: the nearest thing it draws is across the street. THE GRID IS THE PLAT'S, THE SCALE IS NOT, and the module says so. Tombstone's blocks run about 300 feet with 80-foot streets, a hundred times the fight -- at a zoom that shows the lot the town is off-screen, and at a zoom that shows the town the fight is one pixel. Blocks here are 44 metres. The ARRANGEMENT is the map's: Safford, Fremont, Allen and Toughnut east-west, Second through Sixth north-south, the lot on Fremont between Third and Fourth. Streets step out from the lot's own block rather than being placed by hand, which is how the first cut ended up with an eighty-metre block between Third and Fourth. Seeded, so it is the same town every render: a backdrop that reshuffled between takes would be worse than none. ORDER IS THE TRICK for the ground, since these all sit at elevation 0 and coplanar surfaces z-fight: the desert first, the streets over it, the fight's own ground last so it wins where the men stand. The fight's ground is now SMALL for that reason -- it used to blanket the area, from before there was a town to paint over. AND THE BLOW SAYS WHAT IT WAS. PeterB: "we need a 'blam' for a gunshot. not a 'pow'. how do we tie blam to using a firearm". `ActionResolved` now carries `power_name`, `damage_type` and `is_ranged` beside the xmlid, because the xmlid alone is too coarse to narrate -- a revolver, a crossbow and a laser pistol are all RKA -- and the renderer sees the event and never the build. The log is the only place it can learn this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lvepxj9B2mDc4cRN3jaWwn
1 parent bece0b7 commit 9292ff2

4 files changed

Lines changed: 234 additions & 8 deletions

File tree

examples/the_shootout_we_can_publish.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,15 @@ def building(oid, poly, *, h):
235235
# running underneath it. Both extend well past the camera
236236
# bounds: the buildings sat on nothing before this, and a man
237237
# who ran out of the lot stood on black.
238-
Surface(id="tombstone-dirt", name="Tombstone",
239-
polygon_xy=[(-24.0, -20.0), (30.0, -20.0),
240-
(30.0, 4.0), (-24.0, 4.0)],
238+
# SMALL ON PURPOSE. These used to blanket the whole area, from
239+
# before there was a town: the recorder now lays Tombstone's
240+
# streets down first and the fight's own ground on top, so a
241+
# wide tile here paints over the town it is standing in. This
242+
# is the lot and the ground the men fight on, and no more.
243+
Surface(id="lot-ground", name="The vacant lot",
244+
polygon_xy=[(-6.0, -8.0), (14.0, -8.0),
245+
(14.0, 4.0), (-6.0, 4.0)],
241246
elevation_m=0.0, surface_type="ground", cover_level=0),
242-
Surface(id="fremont", name="Fremont Street",
243-
polygon_xy=[(-24.0, 4.0), (30.0, 4.0),
244-
(30.0, 24.0), (-24.0, 24.0)],
245-
elevation_m=0.0, surface_type="street", cover_level=0),
246247
],
247248
walls=[
248249
# The two faces that front the lot, eighteen feet apart.

examples/tombstone_town.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Tombstone, Arizona Territory, 1881 --- as scenery.
2+
3+
WHAT THIS IS FOR. The showcase fight happens in a five-metre lot, and the
4+
replay showed that lot standing alone on a patch of dirt. PeterB, with the
5+
1881 town plat in hand: "can you render the whole town?"
6+
7+
SCENERY, NOT TERRAIN, and the distinction is load-bearing. Every
8+
`Construct` in a Scene becomes an `attack_construct` target ---
9+
`constructs_in` projects the lot, so before this the barrels and the
10+
boarding house were things you could shoot. Putting ninety buildings in
11+
the Scene would flood every menu in the fight and let a man take aim at a
12+
saloon four blocks away. So the town never enters the engine's Scene at
13+
all: the recorder merges it into the SNAPSHOT the renderer reads, and the
14+
fight is resolved exactly as it was before.
15+
16+
Nothing here blocks line of sight, gives cover or can be hit, and none of
17+
that matters for a thirty-second gunfight in a lot: the nearest thing this
18+
module draws is across the street.
19+
20+
THE GRID IS THE PLAT'S, THE SCALE IS NOT. Tombstone's blocks run about
21+
300 feet with 80-foot streets, which is a hundred times the fight --- at a
22+
zoom that shows the lot the town is off-screen, and at a zoom that shows
23+
the town the fight is one pixel. Blocks here are 44 metres and streets 9,
24+
which is a deliberate compression that keeps a few blocks in frame around
25+
the men. The ARRANGEMENT is the map's: Safford, Fremont, Allen and
26+
Toughnut running east-west, Second through Seventh running north-south,
27+
and the lot on the south side of Fremont between Third and Fourth.
28+
"""
29+
from __future__ import annotations
30+
31+
import random
32+
33+
#: The lot sits at x 0.6-5.6, y -3-4 and Fremont runs along its north
34+
#: side. Everything here is laid out around that, so the fight's own
35+
#: coordinates never move.
36+
FREMONT_S, FREMONT_N = 4.0, 13.0
37+
38+
BLOCK = 44.0
39+
STREET = 9.0
40+
41+
#: East-west streets, north to south, as (name, south edge, north edge).
42+
EW_STREETS = [
43+
("Safford Street", FREMONT_N + BLOCK, FREMONT_N + BLOCK + STREET),
44+
("Fremont Street", FREMONT_S, FREMONT_N),
45+
("Allen Street", FREMONT_S - BLOCK - STREET, FREMONT_S - BLOCK),
46+
("Toughnut Street", FREMONT_S - 2 * (BLOCK + STREET),
47+
FREMONT_S - 2 * BLOCK - STREET),
48+
]
49+
50+
#: North-south streets, west to east, as (name, west edge, east edge). The
51+
#: lot is between Third and Fourth, so those two bracket it.
52+
#: The lot's block runs from Third to Fourth, so those two bracket it and
53+
#: everything else steps out from there by one block and one street. Laid
54+
#: out this way rather than by absolute coordinates so the blocks are
55+
#: actually BLOCK wide -- the first cut left an eighty-metre block between
56+
#: Third and Fourth because both were placed by hand.
57+
_THIRD_E = -36.0
58+
_FOURTH_W = _THIRD_E + BLOCK
59+
60+
61+
def _ns(name: str, step: int) -> tuple[str, float, float]:
62+
"""A north-south street `step` blocks east of Third."""
63+
west = _THIRD_E - STREET + step * (BLOCK + STREET)
64+
return (name, west, west + STREET)
65+
66+
67+
NS_STREETS = [
68+
_ns("Second Street", -1),
69+
_ns("Third Street", 0),
70+
_ns("Fourth Street", 1),
71+
_ns("Fifth Street", 2),
72+
_ns("Sixth Street", 3),
73+
]
74+
75+
76+
def ground() -> dict:
77+
"""The desert the town sits on.
78+
79+
One tile under everything, drawn FIRST so the streets and the lot paint
80+
over it. Without it the block interiors are holes: buildings line the
81+
frontages and the yards behind them were void, so the town read as a
82+
grid of rectangles floating in black.
83+
"""
84+
west = NS_STREETS[0][1] - BLOCK * 1.5
85+
east = NS_STREETS[-1][2] + BLOCK * 1.5
86+
south = EW_STREETS[-1][1] - BLOCK * 1.5
87+
north = EW_STREETS[0][2] + BLOCK * 1.5
88+
return {"id": "desert", "name": "Tombstone", "surface_type": "ground",
89+
"polygon": [(west, south), (east, south), (east, north), (west, north)]}
90+
91+
92+
def streets() -> list[dict]:
93+
"""The roadway itself, as flat surfaces the renderer paints.
94+
95+
Drawn as one wide band per street rather than a road network with
96+
junctions: at this scale the crossings look right anyway, and a
97+
polygon per intersection would be a lot of geometry for nothing.
98+
"""
99+
out = []
100+
west = NS_STREETS[0][1] - BLOCK
101+
east = NS_STREETS[-1][2] + BLOCK
102+
south = EW_STREETS[-1][1] - BLOCK
103+
north = EW_STREETS[0][2] + BLOCK
104+
for name, y0, y1 in EW_STREETS:
105+
out.append({"id": f"st-{name}", "name": name, "surface_type": "street",
106+
"polygon": [(west, y0), (east, y0), (east, y1), (west, y1)]})
107+
for name, x0, x1 in NS_STREETS:
108+
out.append({"id": f"st-{name}", "name": name, "surface_type": "street",
109+
"polygon": [(x0, south), (x1, south), (x1, north), (x0, north)]})
110+
return out
111+
112+
113+
def _storefronts(x0: float, x1: float, y0: float, y1: float, rng, *,
114+
skip=()) -> list[dict]:
115+
"""One block, cut into buildings along the street frontages.
116+
117+
The plat's blocks are solid rows of narrow storefronts facing the
118+
street with yards behind, which is what makes the map read as a town
119+
rather than a set of rectangles. This walks each frontage laying down
120+
buildings of varying width and depth, and leaves the middle empty.
121+
"""
122+
out = []
123+
for edge, (a0, a1) in (("s", (x0, x1)), ("n", (x0, x1))):
124+
cursor = a0 + rng.uniform(0.0, 3.0)
125+
while cursor < a1 - 4.0:
126+
width = rng.uniform(4.0, 11.0)
127+
depth = rng.uniform(7.0, 15.0)
128+
if cursor + width > a1:
129+
width = a1 - cursor
130+
near_y = y0 if edge == "s" else y1 - depth
131+
rect = (cursor, near_y, cursor + width, near_y + depth)
132+
if not any(_overlaps(rect, s) for s in skip):
133+
out.append({
134+
"id": f"b{len(out)}-{edge}-{int(cursor)}-{int(y0)}",
135+
"height": rng.choice([3.5, 4.0, 5.0, 6.0]),
136+
"polygon": [(rect[0], rect[1]), (rect[2], rect[1]),
137+
(rect[2], rect[3]), (rect[0], rect[3])],
138+
})
139+
cursor += width + rng.uniform(0.0, 1.5)
140+
return out
141+
142+
143+
def _overlaps(rect, other) -> bool:
144+
ax0, ay0, ax1, ay1 = rect
145+
bx0, by0, bx1, by1 = other
146+
return not (ax1 <= bx0 or bx1 <= ax0 or ay1 <= by0 or by1 <= ay0)
147+
148+
149+
def buildings(seed: int = 7) -> list[dict]:
150+
"""Every building in town, as {id, height, polygon}.
151+
152+
Seeded, so the town is the same town every time it is rendered --- a
153+
backdrop that reshuffled between takes would be worse than none.
154+
"""
155+
rng = random.Random(seed)
156+
#: Where the fight is. No generated building may land on the lot, the
157+
#: two houses that front it, or the stretch of Fremont the Earps
158+
#: walked in along.
159+
keep_clear = [(-14.0, -12.0, 20.0, 14.0)]
160+
161+
out: list[dict] = []
162+
ns_edges = [(NS_STREETS[i][2], NS_STREETS[i + 1][1])
163+
for i in range(len(NS_STREETS) - 1)]
164+
ew_edges = [(EW_STREETS[i + 1][2], EW_STREETS[i][1])
165+
for i in range(len(EW_STREETS) - 1)]
166+
for x0, x1 in ns_edges:
167+
for y0, y1 in ew_edges:
168+
out.extend(_storefronts(x0, x1, y0, y1, rng, skip=keep_clear))
169+
# Unique ids across blocks.
170+
for i, b in enumerate(out):
171+
b["id"] = f"town-{i}"
172+
return out

kirby_combat/actions/recording.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,15 @@ def resolve_attack_in_session(
348348
# his Colt Peacemaker are both RKA, so an ammunition count keyed on
349349
# xmlid would empty one gun by firing the other. Identity is an id.
350350
"power_source_id": getattr(attack.power, "source_id", None),
351+
# WHAT IT WAS, for anything narrating the blow. `power_xmlid` is a
352+
# TYPE and a coarse one -- a revolver, a crossbow and a laser are
353+
# all RKA -- so a reader that wants to say BLAM rather than POW
354+
# needs the weapon's name and its shape. Cheap, and the log is the
355+
# only place a replay can learn it: the renderer sees the event and
356+
# never the build.
357+
"power_name": getattr(attack.power, "name", None),
358+
"damage_type": getattr(attack.power, "damage_type", None),
359+
"is_ranged": bool(getattr(attack.power, "is_ranged", False)),
351360
"target_id": target_id,
352361
# WHY it was harder than the bare CVs suggest. A to-hit that moved
353362
# silently is indistinguishable from a bad roll, both to a reader

scripts/record_for_krackle.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,49 @@ def _construct_dict(k) -> dict:
149149
}
150150

151151

152+
def _with_town(snapshot: dict) -> dict:
153+
"""Add Tombstone around the lot, for the renderer only.
154+
155+
SCENERY, NOT TERRAIN. Every `Construct` in a Scene becomes an
156+
`attack_construct` target, so putting ninety buildings in the Scene
157+
would flood every menu in the fight and let a man aim at a saloon four
158+
blocks away. The town is merged in HERE, after the fight is resolved,
159+
so the engine never sees it and the recording of the fight is
160+
unchanged.
161+
162+
Nothing it adds blocks line of sight, gives cover or can be hit, and
163+
none of that matters for thirty seconds in a five-metre lot: the
164+
nearest thing it draws is across the street.
165+
"""
166+
from tombstone_town import buildings, ground, streets
167+
168+
scene = snapshot.get("scene")
169+
if scene is None:
170+
return snapshot
171+
# The streets go UNDER the fight's own ground, which is drawn last and
172+
# wins where they overlap.
173+
# ORDER IS THE WHOLE TRICK, since these all sit at elevation 0 and
174+
# coplanar surfaces z-fight: the desert first, the streets over it, and
175+
# the fight's own ground last so it wins where the men are standing.
176+
scene["surfaces"] = [
177+
{"id": s["id"], "name": s["name"], "surface_type": s["surface_type"],
178+
"elevation_m": 0.0, "cover_level": 0, "is_supporting": True,
179+
"polygon_xy": _flat(s["polygon"])}
180+
for s in [ground()] + streets()
181+
] + scene["surfaces"]
182+
snapshot["constructs"] = list(snapshot.get("constructs") or []) + [
183+
{"id": b["id"], "obj_id": b["id"], "kind": "wall",
184+
"start": [b["polygon"][0][0], b["polygon"][0][1], 0.0],
185+
"end": [b["polygon"][2][0], b["polygon"][2][1], 0.0],
186+
"height_m": b["height"], "blocks_los": False,
187+
"blocks_movement": False, "cover_level": 0,
188+
"def_value": None, "body": None,
189+
"polygon_xy": _flat(b["polygon"])}
190+
for b in buildings()
191+
]
192+
return snapshot
193+
194+
152195
def opening_snapshot(session, scene) -> dict:
153196
"""The fight as it stood before anybody acted."""
154197
return {
@@ -209,7 +252,8 @@ def main() -> None:
209252
opening = _replace(session, combatants=fresh)
210253
snapshot = opening_snapshot(opening, the_scene())
211254

212-
recording = {"snapshot": snapshot, "events": events_of(session)}
255+
recording = {"snapshot": _with_town(snapshot),
256+
"events": events_of(session)}
213257
path = pathlib.Path(args.out)
214258
path.write_text(json.dumps(recording, indent=1))
215259
print(f"wrote {path} ({len(recording['events'])} events, "

0 commit comments

Comments
 (0)