docs(grid): document grid-world child coordinates and the overlay pattern

Grid children are positioned in the grid's pixel-world coordinates — the same
origin as Entity positions — while Frame children are frame-local. Neither was
documented, so the difference read as an inconsistency (it surfaced during #355,
which had to decide whether to preserve it).

It is not an inconsistency. A Frame has no camera and cannot pan its content, so
frame-local is effectively screen space; a Grid does have a camera, and its
children are deliberately anchored to world content, not to the viewport. Testing
grid children at their visible coordinates would break all positioning the moment
the camera moved. The use case is diegetic UI — speech bubbles, damage numbers,
range indicators — that belongs to a place or an entity in the world.

Documents the convention on both Grid.children and Frame.children (each pointing
at the other), and adds docs/grid-coordinate-spaces.md with the overlay pattern
for screen-space UI over a grid: a sibling Frame matching the Grid's pos/size,
rather than Grid.children.

Note: docs/ is gitignored wholesale with tracked files force-added, so the guide
needed `git add -f` — it would otherwise have been silently dropped.

closes #360

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-12 18:02:29 -04:00
commit 540c793e4e
4 changed files with 117 additions and 3 deletions

View file

@ -0,0 +1,91 @@
# Grid Coordinate Spaces & the Overlay Pattern
Clarifies which coordinate space `UIDrawable` children are positioned in depending on
their parent, and how to build screen-space UI (HUDs, dialogs) that sits on top of a
panning/zooming `Grid`. See issue #360.
## The short version
- **`Frame.children`** are positioned **frame-local**: relative to the Frame's
top-left corner. A `Frame` has no camera and cannot pan its content, so
frame-local is effectively already screen space.
- **`Grid.children`** are positioned in the grid's **pixel-world coordinates** — the
same origin as `Entity` positions — **not** in the grid widget's on-screen pixels.
They pan and zoom with the grid's camera (`grid.center`, `grid.zoom`), exactly like
entities and tiles do.
This is not an inconsistency to "fix": a `Frame` genuinely has no notion of a camera,
so there is no other coordinate space its children could reasonably use. A `Grid`
does have a camera, and its children are deliberately anchored to grid content (the
world), not to the viewport. Entities are positioned by cell; grid-world pixel
coordinates share that same origin, so a `Grid` child can be placed precisely next to
an entity (using `entity.pos`, itself documented as "pixel position relative to
grid") and will continue to track that entity's neighborhood as the camera moves.
The use case for `Grid.children` is **diegetic** UI: speech bubbles, damage numbers,
range indicators, path markers — things that belong to a place or an entity in the
world and should move/zoom with it.
```python
# A speech bubble anchored near an entity, in grid-world pixels.
# entity.pos is already "pixel position relative to grid" -- the same origin
# Grid.children use.
bubble = mcrfpy.Caption(text="Hello!", pos=(entity.pos.x, entity.pos.y - 20))
grid.children.append(bubble)
```
If you instead want UI that floats over the grid at a fixed screen position
regardless of where the camera is pointed — a health bar HUD, an inventory panel, a
modal dialog — **`Grid.children` is the wrong tool**, because those children would
pan and zoom right along with everything else and drift off screen.
## The overlay pattern
Put a `Frame` as a **sibling** of the `Grid` (not a child of it), sized and
positioned to match the `Grid`, and draw your screen-space UI as children of that
Frame instead. Give the overlay Frame a later `z_index` (or simply append it after
the Grid) so it draws on top.
```
Scene (or a container Frame)
├── Grid # pan, zoom, entities, diegetic children
│ ├── entities: positioned by cell
│ └── children: positioned in grid-world pixels (pan/zoom with camera)
└── Frame (overlay) # same pos/size as the Grid, drawn after it
└── children: positioned in frame-local coordinates (screen space)
```
```python
import mcrfpy
scene = mcrfpy.Scene("game")
ui = scene.children
grid = mcrfpy.Grid(grid_size=(40, 30), pos=(0, 0), size=(640, 480))
ui.append(grid)
# Diegetic marker: lives in the grid's world, pans/zooms with the camera.
marker = mcrfpy.Caption(text="!", pos=(160, 96))
grid.children.append(marker)
# Screen-space HUD: same pos/size as the grid, but its own children are
# frame-local, so they stay put no matter how grid.center/grid.zoom change.
overlay = mcrfpy.Frame(pos=(0, 0), size=(640, 480))
overlay.fill_color = mcrfpy.Color(0, 0, 0, 0) # transparent container
ui.append(overlay) # appended after grid -> draws on top
health_bar = mcrfpy.Frame(pos=(10, 10), size=(200, 20))
health_bar.fill_color = mcrfpy.Color(200, 40, 40)
overlay.children.append(health_bar)
```
Panning or zooming `grid` (`grid.center = ...`, `grid.zoom = ...`) moves `marker`
along with the world; `health_bar` never moves, because it belongs to `overlay`, not
to `grid`.
## See also
- `Grid.children` / `Frame.children` docstrings (`mcrfpy.Grid.children.__doc__`,
`mcrfpy.Frame.children.__doc__`) for the API-level statement of this contract.
- `Entity.draw_pos` / `Entity.cell_pos` for the cell-vs-pixel distinction on the
entity side of the same coordinate system.