From 73a376ac6c08eb21cd9cbbd8ab43c35b3273594e Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Fri, 25 Sep 2026 09:45:59 +0300 Subject: [PATCH] feat(packages): ludic.minimap - a map's logic: the explored fog over a grid of cells round an origin (revealed within a reach, a version for rebaking, explored as a share of the land over a core square, the grid as text for an older save), the player's marks (a cap, a symbol, a colour, the day, a name; the open one and the followed one, rubbing out, the last symbol reused, the nearest for a compass) and the view (world to window and back, zoom in steps and about a point, a drag, pick and place, the camp, you, a focus that never pulls back out, a scale bar, bounds); the world through a port; seen and marks in its own save section by key Co-Authored-By: Claude Opus 5.5 --- packages/README.md | 1 + packages/ludic.minimap/README.md | 80 ++++++++ packages/ludic.minimap/explored.ludic | 100 +++++++++ packages/ludic.minimap/grid.ludic | 47 +++++ packages/ludic.minimap/index.ludic | 13 ++ packages/ludic.minimap/package.ludic | 6 + packages/ludic.minimap/pin_edit.ludic | 68 ++++++ packages/ludic.minimap/pins.ludic | 104 ++++++++++ packages/ludic.minimap/ports.ludic | 23 +++ packages/ludic.minimap/system.ludic | 53 +++++ .../ludic.minimap/tests/minimap_test.ludic | 193 ++++++++++++++++++ packages/ludic.minimap/view.ludic | 81 ++++++++ packages/ludic.minimap/view_moves.ludic | 61 ++++++ 13 files changed, 830 insertions(+) create mode 100644 packages/ludic.minimap/README.md create mode 100644 packages/ludic.minimap/explored.ludic create mode 100644 packages/ludic.minimap/grid.ludic create mode 100644 packages/ludic.minimap/index.ludic create mode 100644 packages/ludic.minimap/package.ludic create mode 100644 packages/ludic.minimap/pin_edit.ludic create mode 100644 packages/ludic.minimap/pins.ludic create mode 100644 packages/ludic.minimap/ports.ludic create mode 100644 packages/ludic.minimap/system.ludic create mode 100644 packages/ludic.minimap/tests/minimap_test.ludic create mode 100644 packages/ludic.minimap/view.ludic create mode 100644 packages/ludic.minimap/view_moves.ludic diff --git a/packages/README.md b/packages/README.md index b40d3713..67120cae 100644 --- a/packages/README.md +++ b/packages/README.md @@ -26,6 +26,7 @@ section. The rules are in [ludic.base](ludic.base/README.md). | [ludic.hints](ludic.hints/README.md) | a rail of what is true now, most urgent first, each a card that teaches it and can be muted by key | | [ludic.i18n](ludic.i18n/README.md) | a game in any language: gettext `.po` files, patterns with holes, plurals, a mod folder, a font per language | | [ludic.inventory](ludic.inventory/README.md) | a pack: a count per kind, with room the game decides | +| [ludic.minimap](ludic.minimap/README.md) | a map's logic: the explored fog over a grid and its land share, the player's marks, and the view (world to map and back, zoom, pan, the camp, you, focus) | | [ludic.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse | | [ludic.photo](ludic.photo/README.md) | a camera's photographs: what is in the frame, a grade on size, framing, light and the moment, the roll, its worth, the best of each subject | | [ludic.save](ludic.save/README.md) | versioned save files: a migration chain the game declares, torn writes told apart, a backup, a newer file refused and read-only | diff --git a/packages/ludic.minimap/README.md b/packages/ludic.minimap/README.md new file mode 100644 index 00000000..5439cf5a --- /dev/null +++ b/packages/ludic.minimap/README.md @@ -0,0 +1,80 @@ +# ludic.minimap + +What a map knows without drawing anything: which cells of a grid have been walked, the marks the +player put on it, and where its window looks. The game paints the picture - the ground, the fog, +the icons, the legend - from the package's queries. Uses [`ludic.base`](../ludic.base/README.md) +and nothing else. + +```ludic +import "ludic.minimap" +``` + +## The explored fog + +A square grid of `cells` per side, `cell_m` metres each, centred on `MinimapWorld.origin`. +`minimap_reveal(x, z, r)` marks every cell any part of which lies within `r`; `minimap_version()` +goes up whenever what has been seen changes, so a baked fog texture is rebuilt only then. +**Explored** is a share of LAND, over a core square of the grid (`minimap_config_core`), so open +water is not unexplored and a bigger grid does not change what a chapter's "walk 30%" asks for. + +## The marks + +A mark (`MinimapPin { x, z, icon, colour, day, label }`) is a note at a place: it does not route +anyone and it does not move. There is a cap; `minimap_pin_add` answers -1 past it and the game +says so. One mark may be **open** (the legend edits it) and one **followed** (the game puts it on +its compass); a new mark takes the symbol last chosen. Rubbing one out moves the later ones up, +and the open and followed indices with them. + +## The view + +A square window of `size` pixels showing `span` metres across at zoom 1, centred on a world point +and kept inside `MinimapWorld`'s bounds. World to window (`minimap_px`, `minimap_py`) and back +(`minimap_wx`, `minimap_wz`, from a point measured from the window's corner), zoom in steps or +about a point (what is under the pointer stays under it), a drag that pans, a press that picks the +mark under it or drops a new one, "the camp" and "you", and a focus that goes there and closes in +but never pulls a closer view back out. + +## The port + +```ludic +export port MinimapWorld { # every member has a default + origin_x, origin_z # the grid's centre + land(x, z) -> bool # what "explored" counts + you_x, you_z, camp_x, camp_z # the two places a view recentres on + day() -> int # a mark remembers the day it was made + lo_x, lo_z, hi_x, hi_z # the view's centre stays inside these +} +``` + +## API + +| | | +| --- | --- | +| `minimap_config(cells, cell_m)`, `minimap_config_core(from, n)`, `minimap_land_changed()` | the grid, the square explored is measured over, and a new ground under it | +| `minimap_reveal(x, z, r)`, `minimap_reveal_cell(c)` | walking | +| `minimap_cell(x, z)`, `minimap_cells()`, `minimap_cell_m()`, `minimap_cell_x(i)`, `minimap_cell_z(j)`, `minimap_grid_x0()`, `minimap_grid_z0()`, `minimap_grid_half()` | the grid's geometry | +| `minimap_seen(c)`, `minimap_seen_at(x, z)`, `minimap_seen_count()`, `minimap_version()`, `minimap_explored_pct()` | what has been seen | +| `minimap_seen_text()`, `minimap_seen_from_text(s)` | the grid as one string of 0 / 1 (an older save's shape) | +| `minimap_config_pins(max, symbols, colours)`, `minimap_pins_clear()` | the marks' cap and ranges | +| `minimap_pin_add(x, z, icon, label) -> int`, `minimap_pin_put(x, z, icon, colour, day, label)`, `minimap_pin_remove(i)` | a mark made today, one as saved, and rubbing one out | +| `minimap_pin_count()`, `minimap_pin_max()`, `minimap_pin_full()`, `minimap_pin(i) -> MinimapPin` | the marks | +| `minimap_pin_set_label/icon/colour(i, v)`, `minimap_pin_last_icon()` | editing one | +| `minimap_pin_open()`, `minimap_pin_select(i)`, `minimap_pin_followed()`, `minimap_pin_follow(i)`, `minimap_pin_follow_toggle()` | the open one and the followed one | +| `minimap_pins_near(x, z, n, within) -> []int` | the nearest marks, not the followed one (for a compass) | +| `minimap_config_view(span, zoom_min, zoom_max, focus)`, `minimap_window(x0, y0, size)` | the view's range, and where the window is this frame | +| `minimap_px(x)`, `minimap_py(z)`, `minimap_wx(rx)`, `minimap_wz(ry)`, `minimap_span()`, `minimap_scale()`, `minimap_scale_metres()` | projection, and a scale bar's round distance | +| `minimap_zoom()`, `minimap_zoom_set(z)`, `minimap_zoom_step(dir)`, `minimap_zoom_about(rx, ry, factor)` | zoom | +| `minimap_view_x()`, `minimap_view_z()`, `minimap_have_view()`, `minimap_centre(x, z)`, `minimap_clamp()`, `minimap_recentre(on_you)`, `minimap_focus(on_you)` | where it looks | +| `minimap_pan_begin(rx, ry)`, `minimap_pan_to(rx, ry)`, `minimap_pan_end()`, `minimap_panning()` | a drag | +| `minimap_pick(rx, ry, r) -> int`, `minimap_placing()`, `minimap_place_toggle()`, `minimap_place(rx, ry, label) -> int` | a press: open the mark under it, or drop the next one there | +| `minimap_reset()`, `minimap_system() -> System` | `"minimap"`: the seen grid, the marks, the followed one and the last symbol in its own section, by key; the view is not saved | + +## Tests + +```bash +ludic test packages/ludic.minimap +``` + +A 20 x 20 grid with sea on one side: the cells, revealing and the land's share, the marks' cap, +editing and rubbing out, the nearest for a compass, projection, zoom about a point, a pan, focus +and the clamp, picking and placing, the scale bar, and the save. diff --git a/packages/ludic.minimap/explored.ludic b/packages/ludic.minimap/explored.ludic new file mode 100644 index 00000000..75cdc591 --- /dev/null +++ b/packages/ludic.minimap/explored.ludic @@ -0,0 +1,100 @@ +# explored.ludic - walking reveals the cells round you; "explored" is the share of the core's LAND +# that has been seen, since open water has nothing on it to miss +var mm_land_n: int = 0 # land cells in the core, counted once per origin +var mm_land_ox: float = 0.0 +var mm_land_oz: float = 0.0 + +# every cell any part of which is within r of (x, z) +export function minimap_reveal(x: float, z: float, r: float) -> void { + mm_grid() + let c = minimap_cell(x, z) + let reach = int(r / mm_cell_m) + 1 + let x0 = minimap_grid_x0() + let z0 = minimap_grid_z0() + let ci = int(Math.floor((x - x0) / mm_cell_m)) + let cj = int(Math.floor((z - z0) / mm_cell_m)) + for j in cj - reach .. cj + reach + 1 { + for i in ci - reach .. ci + reach + 1 { + if i < 0 or j < 0 or i >= mm_cells or j >= mm_cells { continue } + let nx = Math.clamp(x, x0 + float(i) * mm_cell_m, x0 + float(i + 1) * mm_cell_m) + let nz = Math.clamp(z, z0 + float(j) * mm_cell_m, z0 + float(j + 1) * mm_cell_m) + if (nx - x) * (nx - x) + (nz - z) * (nz - z) <= r * r { minimap_reveal_cell(j * mm_cells + i) } + } + } + if c >= 0 { minimap_reveal_cell(c) } +} + +export function minimap_reveal_cell(c: int) -> void { + mm_grid() + if c < 0 or c >= mm_cells * mm_cells or mm_seen[c] != 0 { return } + mm_seen[c] = 1 + mm_version += 1 +} + +export function minimap_seen(c: int) -> bool { + if mm_seen == null or c < 0 or c >= mm_cells * mm_cells { return false } + return mm_seen[c] != 0 +} + +export function minimap_seen_at(x: float, z: float) -> bool { return minimap_seen(minimap_cell(x, z)) } + +export function minimap_seen_count() -> int { + var n = 0 + if mm_seen == null { return 0 } + for c in 0 .. len(mm_seen) { if mm_seen[c] != 0 { n += 1 } } + return n +} + +# goes up whenever what has been seen changes: a picture of the fog is baked again when it does +export function minimap_version() -> int { return mm_version } + +export function minimap_explored_pct() -> int { + mm_land_count() + var n = 0 + for j in mm_core0 .. mm_core0 + mm_core_n { + for i in mm_core0 .. mm_core0 + mm_core_n { + if minimap_seen(j * mm_cells + i) and mm_land(i, j) { n += 1 } + } + } + return Math.min(n * 100 / mm_land_n, 100) +} + +function mm_land(i: int, j: int) -> bool { return MinimapWorld.land(minimap_cell_x(i), minimap_cell_z(j)) } + +function mm_land_count() -> void { + let ox = MinimapWorld.origin_x() + let oz = MinimapWorld.origin_z() + if mm_land_n > 0 and ox == mm_land_ox and oz == mm_land_oz { return } + mm_land_ox = ox + mm_land_oz = oz + mm_land_n = 0 + for j in mm_core0 .. mm_core0 + mm_core_n { + for i in mm_core0 .. mm_core0 + mm_core_n { if mm_land(i, j) { mm_land_n += 1 } } + } + mm_land_n = Math.max(mm_land_n, 1) +} + +# the ground under the grid is not what it was (a new world): count its land again +export function minimap_land_changed() -> void { mm_land_n = 0 } + +# the grid as one string of 0 / 1, row by row, and back (a shorter string leaves the rest unseen) +export function minimap_seen_text() -> string { + mm_grid() + var s = "" + for j in 0 .. mm_cells { + var row = "" + for i in 0 .. mm_cells { if mm_seen[j * mm_cells + i] != 0 { row = row + "1" } else { row = row + "0" } } + s = s + row + } + return s +} + +export function minimap_seen_from_text(s: string) -> void { + mm_grid() + let n = len(s) + for c in 0 .. mm_cells * mm_cells { + mm_seen[c] = 0 + if c < n and s[c] == 49 { mm_seen[c] = 1 } + } + mm_version += 1 +} diff --git a/packages/ludic.minimap/grid.ludic b/packages/ludic.minimap/grid.ludic new file mode 100644 index 00000000..5483dd60 --- /dev/null +++ b/packages/ludic.minimap/grid.ludic @@ -0,0 +1,47 @@ +# grid.ludic - the explored grid: square cells round the origin, and the part of it "explored" is +# measured over (a core, so a bigger grid does not change what a share of it means) +var mm_cells: int = 128 # per side +var mm_cell_m: float = 100.0 # metres per cell +var mm_core0: int = 0 # the measured square: from this cell, this many per side +var mm_core_n: int = 128 +var mm_seen: []int = null # 0 / 1 per cell, row by row +var mm_version: int = 0 # goes up whenever a cell is revealed or the grid is replaced + +# the grid: `cells` per side of `cell_m` metres, centred on MinimapWorld's origin; the whole of it +# is measured until minimap_config_core says otherwise +export function minimap_config(cells: int, cell_m: float) -> void { + mm_cells = Math.max(cells, 1) + mm_cell_m = Math.max(cell_m, 0.001) + mm_core0 = 0 + mm_core_n = mm_cells + mm_seen = new []int + for i in 0 .. mm_cells * mm_cells { push(mm_seen, 0) } + mm_land_n = 0 + mm_version += 1 +} + +export function minimap_config_core(from: int, n: int) -> void { + mm_core0 = Math.clamp(from, 0, mm_cells - 1) + mm_core_n = Math.clamp(n, 1, mm_cells - mm_core0) + mm_land_n = 0 +} + +export function minimap_cells() -> int { return mm_cells } +export function minimap_cell_m() -> float { return mm_cell_m } +export function minimap_grid_half() -> float { return float(mm_cells) * mm_cell_m * 0.5 } + +# the grid's west and north edges, and a column's or a row's +export function minimap_grid_x0() -> float { return MinimapWorld.origin_x() - minimap_grid_half() } +export function minimap_grid_z0() -> float { return MinimapWorld.origin_z() - minimap_grid_half() } +export function minimap_cell_x(i: int) -> float { return minimap_grid_x0() + float(i) * mm_cell_m } +export function minimap_cell_z(j: int) -> float { return minimap_grid_z0() + float(j) * mm_cell_m } + +# the cell a point is in, -1 off the grid +export function minimap_cell(x: float, z: float) -> int { + let i = int(Math.floor((x - minimap_grid_x0()) / mm_cell_m)) + let j = int(Math.floor((z - minimap_grid_z0()) / mm_cell_m)) + if i < 0 or j < 0 or i >= mm_cells or j >= mm_cells { return -1 } + return j * mm_cells + i +} + +function mm_grid() -> void { if mm_seen == null { minimap_config(mm_cells, mm_cell_m) } } diff --git a/packages/ludic.minimap/index.ludic b/packages/ludic.minimap/index.ludic new file mode 100644 index 00000000..f1bd0af3 --- /dev/null +++ b/packages/ludic.minimap/index.ludic @@ -0,0 +1,13 @@ +# ludic.minimap - what a map knows without drawing anything: which cells have been walked, the +# marks the player put on it, and where its window looks. The game paints the picture. +module ludic_minimap uses ludic_base +numbers float +import "ludic.base" +import "ports.ludic" +import "grid.ludic" +import "explored.ludic" +import "pins.ludic" +import "pin_edit.ludic" +import "view.ludic" +import "view_moves.ludic" +import "system.ludic" diff --git a/packages/ludic.minimap/package.ludic b/packages/ludic.minimap/package.ludic new file mode 100644 index 00000000..1a01c7da --- /dev/null +++ b/packages/ludic.minimap/package.ludic @@ -0,0 +1,6 @@ +# ludic.minimap - a map's logic: the explored fog over a grid of cells, the player's own marks, +# and the view onto it (world to map and back, zoom, pan, the camp, you, focus). Uses ludic.base +# and nothing else. See README.md. +package "ludic.minimap" +version "0.1.0" +kind source diff --git a/packages/ludic.minimap/pin_edit.ludic b/packages/ludic.minimap/pin_edit.ludic new file mode 100644 index 00000000..12f4c715 --- /dev/null +++ b/packages/ludic.minimap/pin_edit.ludic @@ -0,0 +1,68 @@ +# pin_edit.ludic - changing a mark: its name, symbol and colour, rubbing it out, which one is open +# and which followed, and whether the map is waiting for a place to drop the next +export function minimap_pin_remove(i: int) -> void { + if i < 0 or i >= minimap_pin_count() { return } + let rest = new []MinimapPin + for k in 0 .. len(mm_pins) { if k != i { push(rest, mm_pins[k]) } } + mm_pins = rest + if mm_open == i { mm_open = -1 } else if mm_open > i { mm_open -= 1 } + if mm_followed == i { mm_followed = -1 } else if mm_followed > i { mm_followed -= 1 } +} + +export function minimap_pin_set_label(i: int, s: string) -> void { if i >= 0 and i < minimap_pin_count() { mm_pins[i].label = s } } + +# the symbol chosen is the one the next mark takes +export function minimap_pin_set_icon(i: int, k: int) -> void { + if i < 0 or i >= minimap_pin_count() { return } + mm_pins[i].icon = Math.clamp(k, 0, mm_symbols - 1) + mm_last = mm_pins[i].icon +} + +export function minimap_pin_set_colour(i: int, k: int) -> void { if i >= 0 and i < minimap_pin_count() { mm_pins[i].colour = Math.clamp(k, 0, mm_colours - 1) } } + +export function minimap_pin_last_icon() -> int { return mm_last } + +# the mark the map's legend has open, -1 none +export function minimap_pin_open() -> int { + if mm_open >= minimap_pin_count() { mm_open = -1 } + return mm_open +} + +export function minimap_pin_select(i: int) -> void { + mm_open = -1 + if i >= 0 and i < minimap_pin_count() { mm_open = i } +} + +# the mark the compass carries, -1 none +export function minimap_pin_followed() -> int { + if mm_followed >= minimap_pin_count() { mm_followed = -1 } + return mm_followed +} + +export function minimap_pin_follow(i: int) -> void { + mm_followed = -1 + if i >= 0 and i < minimap_pin_count() { mm_followed = i } +} + +# follow the open mark, or stop following it +export function minimap_pin_follow_toggle() -> void { + let i = minimap_pin_open() + if i < 0 { return } + if mm_followed == i { mm_followed = -1 } else { mm_followed = i } +} + +export function minimap_placing() -> bool { return mm_placing } + +# the next press on the map drops a mark; asked again, it does not +export function minimap_place_toggle() -> void { + mm_placing = not mm_placing + mm_open = -1 +} + +# the press that drops it, at a point on the window: the new mark is open, -1 if there was no room +export function minimap_place(rx: float, ry: float, label: string) -> int { + mm_placing = false + let i = minimap_pin_add(minimap_wx(rx), minimap_wz(ry), mm_last, label) + if i >= 0 { mm_open = i } + return i +} diff --git a/packages/ludic.minimap/pins.ludic b/packages/ludic.minimap/pins.ludic new file mode 100644 index 00000000..f187da77 --- /dev/null +++ b/packages/ludic.minimap/pins.ludic @@ -0,0 +1,104 @@ +# pins.ludic - the player's own marks: a note at a place, with a symbol, a colour, the day it was +# made and a name. It does not route anyone and it does not move. One may be open (on the map's +# legend) and one followed (on the compass); the symbol last chosen is the next mark's. +export property MinimapPin { + x: float = 0.0 + z: float = 0.0 + icon: int = 0 # 0 .. symbols - 1, the game's + colour: int = 0 # 0 .. colours - 1, the game's + day: int = 1 + label: string = "" +} + +var mm_pins: []MinimapPin = null +var mm_pin_max: int = 32 +var mm_symbols: int = 8 +var mm_colours: int = 5 +var mm_open: int = -1 +var mm_followed: int = -1 +var mm_last: int = 0 +var mm_placing: bool = false + +export function minimap_config_pins(max: int, symbols: int, colours: int) -> void { + mm_pin_max = Math.max(max, 1) + mm_symbols = Math.max(symbols, 1) + mm_colours = Math.max(colours, 1) + minimap_pins_clear() +} + +export function minimap_pins_clear() -> void { + mm_pins = new []MinimapPin + mm_open = -1 + mm_followed = -1 + mm_last = 0 + mm_placing = false +} + +export function minimap_pin_count() -> int { + if mm_pins == null { return 0 } + return len(mm_pins) +} + +export function minimap_pin_max() -> int { return mm_pin_max } +export function minimap_pin_full() -> bool { return minimap_pin_count() >= mm_pin_max } + +# a mark at (x, z) made today, with the colour 0; -1 when the map holds all it will +export function minimap_pin_add(x: float, z: float, icon: int, label: string) -> int { + if mm_pins == null { minimap_pins_clear() } + if minimap_pin_full() { return -1 } + let p = new MinimapPin + p.x = x + p.z = z + p.icon = Math.clamp(icon, 0, mm_symbols - 1) + p.day = MinimapWorld.day() + p.label = label + push(mm_pins, p) + mm_last = p.icon + return len(mm_pins) - 1 +} + +# as saved: everything given, the symbol last chosen left alone +export function minimap_pin_put(x: float, z: float, icon: int, colour: int, day: int, label: string) -> int { + if mm_pins == null { minimap_pins_clear() } + if minimap_pin_full() { return -1 } + let p = new MinimapPin + p.x = x + p.z = z + p.icon = Math.clamp(icon, 0, mm_symbols - 1) + p.colour = Math.clamp(colour, 0, mm_colours - 1) + p.day = day + p.label = label + push(mm_pins, p) + return len(mm_pins) - 1 +} + +# the i-th mark, or an empty one past the end +export function minimap_pin(i: int) -> MinimapPin { + if i < 0 or i >= minimap_pin_count() { return new MinimapPin } + return mm_pins[i] +} + +# the nearest n marks within `within` of (x, z), nearest first, leaving out the followed one +export function minimap_pins_near(x: float, z: float, n: int, within: float) -> []int { + let out = new []int + for k in 0 .. n { + var best = -1 + var bd = within * within + for i in 0 .. minimap_pin_count() { + if i == mm_followed or mm_has(out, i) { continue } + let d = (mm_pins[i].x - x) * (mm_pins[i].x - x) + (mm_pins[i].z - z) * (mm_pins[i].z - z) + if d < bd { + bd = d + best = i + } + } + if best < 0 { break } + push(out, best) + } + return out +} + +function mm_has(xs: []int, v: int) -> bool { + for i in 0 .. len(xs) { if xs[i] == v { return true } } + return false +} diff --git a/packages/ludic.minimap/ports.ludic b/packages/ludic.minimap/ports.ludic new file mode 100644 index 00000000..b3ce2c8c --- /dev/null +++ b/packages/ludic.minimap/ports.ludic @@ -0,0 +1,23 @@ +# ports.ludic - what a map asks the game: where its grid is centred, what is land, where you and +# the camp are, the day, and how far the view may be pushed. Unbound: a grid round the origin, all +# land, you and the camp at the origin on day 1, and nowhere the view may not go. +export port MinimapWorld { + origin_x: fn() -> float = fn mm__zero # the grid's centre + origin_z: fn() -> float = fn mm__zero + land: fn(float, float) -> bool = fn mm__land # "explored" counts land only + you_x: fn() -> float = fn mm__zero + you_z: fn() -> float = fn mm__zero + camp_x: fn() -> float = fn mm__zero + camp_z: fn() -> float = fn mm__zero + day: fn() -> int = fn mm__day # a mark remembers the day it was made + lo_x: fn() -> float = fn mm__lo # the view's centre stays inside these + lo_z: fn() -> float = fn mm__lo + hi_x: fn() -> float = fn mm__hi + hi_z: fn() -> float = fn mm__hi +} + +function mm__zero() -> float { return 0.0 } +function mm__land(x: float, z: float) -> bool { return true } +function mm__day() -> int { return 1 } +function mm__lo() -> float { return -1000000000.0 } +function mm__hi() -> float { return 1000000000.0 } diff --git a/packages/ludic.minimap/system.ludic b/packages/ludic.minimap/system.ludic new file mode 100644 index 00000000..ff87652b --- /dev/null +++ b/packages/ludic.minimap/system.ludic @@ -0,0 +1,53 @@ +# system.ludic - the map as a system: what has been seen and the marks, in its own save section by +# key. The view is where the player left it this session and is not saved. +export function minimap_reset() -> void { + mm_grid() + for c in 0 .. len(mm_seen) { mm_seen[c] = 0 } + mm_version += 1 + minimap_pins_clear() + mm_have_view = false + mm_panning = false +} + +export function minimap_save() -> Val { + let v = Value.object() + sv_put_str(v, "seen", minimap_seen_text()) + let l = Value.list() + for i in 0 .. minimap_pin_count() { + let p = mm_pins[i] + let o = Value.object() + sv_put_float(o, "x", p.x) + sv_put_float(o, "z", p.z) + sv_put_int(o, "icon", p.icon) + sv_put_int(o, "colour", p.colour) + sv_put_int(o, "day", p.day) + sv_put_str(o, "label", p.label) + Value.add(l, o) + } + Value.put(v, "pins", l) + sv_put_int(v, "followed", minimap_pin_followed()) + sv_put_int(v, "last", mm_last) + return v +} + +export function minimap_load(v: Val, version: int) -> void { + minimap_reset() + minimap_seen_from_text(sv_str(v, "seen", "")) + if Value.has(v, "pins") != 0 { + let l = Value.get(v, "pins") + for i in 0 .. Value.count(l) { + let o = Value.at(l, i) + minimap_pin_put(sv_float(o, "x", 0.0), sv_float(o, "z", 0.0), sv_int(o, "icon", 0), sv_int(o, "colour", 0), sv_int(o, "day", 1), sv_str(o, "label", "")) + } + } + minimap_pin_follow(sv_int(v, "followed", -1)) + mm_last = Math.clamp(sv_int(v, "last", 0), 0, mm_symbols - 1) +} + +export function minimap_system() -> System { + let s = system_new("minimap", PH_COMMIT) + s.reset = fn minimap_reset + s.save = fn minimap_save + s.load = fn minimap_load + return s +} diff --git a/packages/ludic.minimap/tests/minimap_test.ludic b/packages/ludic.minimap/tests/minimap_test.ludic new file mode 100644 index 00000000..9040ccba --- /dev/null +++ b/packages/ludic.minimap/tests/minimap_test.ludic @@ -0,0 +1,193 @@ +# minimap_test.ludic - a 20 x 20 grid of 10 m cells round (100, 100), sea west of x = 50, you and the +# camp where the test puts them: revealing and the land's share, the marks and their cap, editing +# and rubbing out, the nearest for a compass, the view's projection, zoom, pan, focus and pick, save +import "ludic.minimap" +import "ludic.base" +program MinimapTest { + numbers float + + var you_x: float = 100.0 + var you_z: float = 100.0 + var day: int = 3 + + function fk_ox() -> float { return 100.0 } + function fk_land(x: float, z: float) -> bool { return x >= 50.0 } + function fk_you_x() -> float { return you_x } + function fk_you_z() -> float { return you_z } + function fk_camp() -> float { return 120.0 } + function fk_day() -> int { return day } + function fk_lo() -> float { return 0.0 } + function fk_hi() -> float { return 200.0 } + bind MinimapWorld { origin_x: fn fk_ox, origin_z: fn fk_ox, land: fn fk_land, you_x: fn fk_you_x, you_z: fn fk_you_z, camp_x: fn fk_camp, camp_z: fn fk_camp, day: fn fk_day, lo_x: fn fk_lo, lo_z: fn fk_lo, hi_x: fn fk_hi, hi_z: fn fk_hi } + + function fresh() -> void { + minimap_config(20, 10.0) + minimap_config_pins(3, 8, 5) + minimap_config_view(200.0, 0.5, 8.0, 4.0) + minimap_window(0.0, 0.0, 400.0) + minimap_reset() + you_x = 100.0 + you_z = 100.0 + } + + test "the grid sits round its origin, and a point has its cell" { + fresh() + expect_near(minimap_grid_x0(), 0.0, 0.001) + expect_eq(minimap_cell(5.0, 5.0), 0) + expect_eq(minimap_cell(15.0, 25.0), 41) + expect_eq(minimap_cell(-1.0, 5.0), -1) + expect_eq(minimap_cell(200.0, 5.0), -1) + } + + test "walking reveals the cells within reach, and explored counts the land only" { + fresh() + let v0 = minimap_version() + minimap_reveal(105.0, 105.0, 10.0) + expect(minimap_seen_at(105.0, 105.0)) + expect(minimap_seen_at(95.0, 105.0)) + expect(not minimap_seen_at(125.0, 105.0)) + expect_eq(minimap_seen_count(), 9) + expect(minimap_version() > v0) + let v1 = minimap_version() + minimap_reveal(105.0, 105.0, 10.0) + expect_eq(minimap_version(), v1) + expect_eq(minimap_explored_pct(), 3) + minimap_reveal(5.0, 5.0, 0.0) + expect_eq(minimap_explored_pct(), 3) + minimap_config_core(5, 10) + minimap_land_changed() + expect_eq(minimap_explored_pct(), 9) + } + + test "a mark is made today with the last symbol, and the map holds only so many" { + fresh() + expect_eq(minimap_pin_add(10.0, 20.0, 2, "pool"), 0) + expect_eq(minimap_pin(0).day, 3) + expect_eq(minimap_pin_last_icon(), 2) + expect_eq(minimap_pin_add(30.0, 20.0, 99, "rock"), 1) + expect_eq(minimap_pin(1).icon, 7) + expect_eq(minimap_pin_add(50.0, 20.0, 0, "bend"), 2) + expect(minimap_pin_full()) + expect_eq(minimap_pin_add(60.0, 20.0, 0, "one more"), -1) + expect_eq(minimap_pin_count(), 3) + } + + test "the open mark is named, coloured and followed, and rubbing one out moves the others up" { + fresh() + minimap_pin_add(10.0, 20.0, 0, "a") + minimap_pin_add(30.0, 20.0, 0, "b") + minimap_pin_add(50.0, 20.0, 0, "c") + minimap_pin_select(2) + minimap_pin_follow_toggle() + expect_eq(minimap_pin_followed(), 2) + minimap_pin_set_label(2, "deer pool") + minimap_pin_set_colour(2, 9) + minimap_pin_set_icon(2, 3) + expect(minimap_pin(2).label == "deer pool") + expect_eq(minimap_pin(2).colour, 4) + expect_eq(minimap_pin_last_icon(), 3) + minimap_pin_remove(0) + expect_eq(minimap_pin_count(), 2) + expect_eq(minimap_pin_open(), 1) + expect_eq(minimap_pin_followed(), 1) + minimap_pin_remove(1) + expect_eq(minimap_pin_open(), -1) + expect_eq(minimap_pin_followed(), -1) + } + + test "the nearest marks for a compass leave out the followed one and anything too far" { + fresh() + minimap_config_pins(8, 8, 5) + minimap_pin_add(110.0, 100.0, 0, "near") + minimap_pin_add(130.0, 100.0, 0, "next") + minimap_pin_add(105.0, 100.0, 0, "followed") + minimap_pin_add(900.0, 100.0, 0, "far") + minimap_pin_follow(2) + let near = minimap_pins_near(100.0, 100.0, 3, 400.0) + expect_eq(len(near), 2) + expect_eq(near[0], 0) + expect_eq(near[1], 1) + } + + test "the view: the world on the window and back, zoom about a point, a pan, focus and the clamp" { + fresh() + minimap_centre(100.0, 100.0) + expect_near(minimap_px(100.0), 200.0, 0.01) + expect_near(minimap_px(150.0), 300.0, 0.01) + expect_near(minimap_wx(300.0), 150.0, 0.01) + minimap_zoom_about(300.0, 200.0, 2.0) + expect_near(minimap_zoom(), 2.0, 0.001) + expect_near(minimap_wx(300.0), 150.0, 0.01) + expect_near(minimap_span(), 100.0, 0.01) + minimap_pan_begin(200.0, 200.0) + minimap_pan_to(160.0, 200.0) + expect_near(minimap_view_x(), 135.0, 0.01) + minimap_pan_end() + minimap_pan_to(0.0, 0.0) + expect_near(minimap_view_x(), 135.0, 0.01) + minimap_centre(900.0, -5.0) + expect_near(minimap_view_x(), 200.0, 0.01) + expect_near(minimap_view_z(), 0.0, 0.01) + minimap_focus(false) + expect_near(minimap_view_x(), 120.0, 0.01) + expect_near(minimap_zoom(), 4.0, 0.001) + minimap_zoom_set(6.0) + you_x = 80.0 + minimap_focus(true) + expect_near(minimap_view_x(), 80.0, 0.01) + expect_near(minimap_zoom(), 6.0, 0.001) + minimap_zoom_step(1) + expect_near(minimap_zoom(), 7.8, 0.001) + minimap_zoom_step(1) + expect_near(minimap_zoom(), 8.0, 0.001) + } + + test "a press picks the mark under it, and placing drops one where the press lands" { + fresh() + minimap_centre(100.0, 100.0) + minimap_pin_add(150.0, 100.0, 4, "here") + expect_eq(minimap_pick(301.0, 199.0, 18.0), 0) + expect_eq(minimap_pin_open(), 0) + expect_eq(minimap_pick(10.0, 10.0, 18.0), -1) + minimap_place_toggle() + expect(minimap_placing()) + let i = minimap_place(100.0, 200.0, "Day 3") + expect_eq(i, 1) + expect(not minimap_placing()) + expect_near(minimap_pin(1).x, 50.0, 0.01) + expect_eq(minimap_pin(1).icon, 4) + expect_eq(minimap_pin_open(), 1) + } + + test "a scale bar is a round distance no wider than a quarter of the window" { + fresh() + expect_eq(minimap_scale_metres(), 50) + minimap_zoom_set(8.0) + expect_eq(minimap_scale_metres(), 25) + } + + test "what was seen and the marks come back from the save, and a missing section is a reset" { + fresh() + minimap_reveal(105.0, 105.0, 10.0) + minimap_pin_add(10.0, 20.0, 2, "pool") + minimap_pin_add(30.5, 20.25, 5, "rock") + minimap_pin_set_colour(1, 3) + minimap_pin_follow(1) + let v = minimap_save() + minimap_reset() + expect_eq(minimap_seen_count(), 0) + minimap_load(v, 1) + expect_eq(minimap_seen_count(), 9) + expect_eq(minimap_pin_count(), 2) + expect(minimap_pin(1).label == "rock") + expect_near(minimap_pin(1).x, 30.5, 0.001) + expect_eq(minimap_pin(1).colour, 3) + expect_eq(minimap_pin_followed(), 1) + expect_eq(minimap_pin_last_icon(), 5) + minimap_seen_from_text("0011") + expect_eq(minimap_seen_count(), 2) + let t = minimap_seen_text() + expect_eq(len(t), 400) + expect(t[0..4] == "0011") + } +} diff --git a/packages/ludic.minimap/view.ludic b/packages/ludic.minimap/view.ludic new file mode 100644 index 00000000..69918483 --- /dev/null +++ b/packages/ludic.minimap/view.ludic @@ -0,0 +1,81 @@ +# view.ludic - the window onto the map: a square of `size` pixels at (x0, y0) showing `span` metres +# across at zoom 1, centred on a world point. World to window and back, and a scale bar's length. +var mm_x0: float = 0.0 +var mm_y0: float = 0.0 +var mm_size: float = 512.0 +var mm_span: float = 6400.0 +var mm_zoom_min: float = 0.5 +var mm_zoom_max: float = 8.0 +var mm_zoom_focus: float = 4.0 +var mm_zoom: float = 1.0 +var mm_vx: float = 0.0 +var mm_vz: float = 0.0 +var mm_have_view: bool = false + +# metres across the window at zoom 1, the zoom's range, and how close a focus goes at least +export function minimap_config_view(span: float, zoom_min: float, zoom_max: float, focus: float) -> void { + mm_span = Math.max(span, 1.0) + mm_zoom_min = zoom_min + mm_zoom_max = Math.max(zoom_max, zoom_min) + mm_zoom_focus = focus + mm_zoom = Math.clamp(1.0, mm_zoom_min, mm_zoom_max) + mm_have_view = false +} + +# where the window is on the screen this frame +export function minimap_window(x0: float, y0: float, size: float) -> void { + mm_x0 = x0 + mm_y0 = y0 + mm_size = Math.max(size, 1.0) +} + +export function minimap_size() -> float { return mm_size } +export function minimap_zoom() -> float { return mm_zoom } +export function minimap_view_x() -> float { return mm_vx } +export function minimap_view_z() -> float { return mm_vz } +export function minimap_have_view() -> bool { return mm_have_view } + +# the metres the window shows across at the zoom +export function minimap_span() -> float { return mm_span / mm_zoom } + +# pixels per metre +export function minimap_scale() -> float { return mm_size / minimap_span() } + +# a world point on the screen +export function minimap_px(x: float) -> float { return mm_x0 + mm_size * 0.5 + (x - mm_vx) * minimap_scale() } +export function minimap_py(z: float) -> float { return mm_y0 + mm_size * 0.5 + (z - mm_vz) * minimap_scale() } + +# the world under a point of the window (pixels from its corner) +export function minimap_wx(rx: float) -> float { return mm_vx + (rx - mm_size * 0.5) / minimap_scale() } +export function minimap_wz(ry: float) -> float { return mm_vz + (ry - mm_size * 0.5) / minimap_scale() } + +# a round distance whose bar is no wider than a quarter of the window +export function minimap_scale_metres() -> int { + let steps = [2000, 1000, 500, 250, 100, 50, 25] + for k in 0 .. len(steps) { if float(steps[k]) * minimap_scale() <= mm_size / 4.0 { return steps[k] } } + return 25 +} + +# the view's centre is kept where MinimapWorld allows, so the land can reach the edge but not leave +export function minimap_clamp() -> void { + mm_vx = Math.clamp(mm_vx, MinimapWorld.lo_x(), MinimapWorld.hi_x()) + mm_vz = Math.clamp(mm_vz, MinimapWorld.lo_z(), MinimapWorld.hi_z()) +} + +export function minimap_centre(x: float, z: float) -> void { + mm_vx = x + mm_vz = z + mm_have_view = true + minimap_clamp() +} + +# the view on the camp, or on you +export function minimap_recentre(on_you: bool) -> void { + if on_you { minimap_centre(MinimapWorld.you_x(), MinimapWorld.you_z()) } else { minimap_centre(MinimapWorld.camp_x(), MinimapWorld.camp_z()) } +} + +# go there AND close in; never out, since a player already closer asked for that +export function minimap_focus(on_you: bool) -> void { + minimap_recentre(on_you) + if mm_zoom < mm_zoom_focus { mm_zoom = Math.clamp(mm_zoom_focus, mm_zoom_min, mm_zoom_max) } +} diff --git a/packages/ludic.minimap/view_moves.ludic b/packages/ludic.minimap/view_moves.ludic new file mode 100644 index 00000000..8f654bb7 --- /dev/null +++ b/packages/ludic.minimap/view_moves.ludic @@ -0,0 +1,61 @@ +# view_moves.ludic - moving the view: zoom in steps or about a point (so what is under the pointer +# stays under it), a drag that pans, and a press that picks the mark under it +var mm_panning: bool = false +var mm_drag_rx: float = 0.0 +var mm_drag_ry: float = 0.0 +var mm_drag_vx: float = 0.0 +var mm_drag_vz: float = 0.0 + +export function minimap_zoom_set(z: float) -> void { + mm_zoom = Math.clamp(z, mm_zoom_min, mm_zoom_max) + minimap_clamp() +} + +# a step in (dir > 0) or out, a third at a time +export function minimap_zoom_step(dir: int) -> void { + if dir > 0 { minimap_zoom_set(mm_zoom * 1.3) } else { minimap_zoom_set(mm_zoom / 1.3) } +} + +# zoom by `factor` about a point of the window +export function minimap_zoom_about(rx: float, ry: float, factor: float) -> void { + let wx = minimap_wx(rx) + let wz = minimap_wz(ry) + mm_zoom = Math.clamp(mm_zoom * factor, mm_zoom_min, mm_zoom_max) + mm_vx = mm_vx + wx - minimap_wx(rx) + mm_vz = mm_vz + wz - minimap_wz(ry) + minimap_clamp() +} + +export function minimap_pan_begin(rx: float, ry: float) -> void { + mm_panning = true + mm_drag_rx = rx + mm_drag_ry = ry + mm_drag_vx = mm_vx + mm_drag_vz = mm_vz +} + +# the point pressed follows the pointer +export function minimap_pan_to(rx: float, ry: float) -> void { + if not mm_panning { return } + mm_vx = mm_drag_vx - (rx - mm_drag_rx) / minimap_scale() + mm_vz = mm_drag_vz - (ry - mm_drag_ry) / minimap_scale() + minimap_clamp() +} + +export function minimap_pan_end() -> void { mm_panning = false } +export function minimap_panning() -> bool { return mm_panning } + +# the mark within r pixels of a point of the window is opened (the last drawn, so the top one); +# -1 and nothing changes when there is none +export function minimap_pick(rx: float, ry: float, r: float) -> int { + let sx = mm_x0 + rx + let sy = mm_y0 + ry + var hit = -1 + for i in 0 .. minimap_pin_count() { + let px = minimap_px(mm_pins[i].x) + let py = minimap_py(mm_pins[i].z) + if sx >= px - r and sx < px + r and sy >= py - r and sy < py + r { hit = i } + } + if hit >= 0 { mm_open = hit } + return hit +}