diff --git a/packages/README.md b/packages/README.md index fe0c5770..b40d3713 100644 --- a/packages/README.md +++ b/packages/README.md @@ -23,6 +23,7 @@ section. The rules are in [ludic.base](ludic.base/README.md). | [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) | | [ludic.fire](ludic.fire/README.md) | a camp fire: fuel, the rain on it, warmth at a distance, whether it will cook | | [ludic.fishing](ludic.fishing/README.md) | a rod at the water: cast, bite, the reel's fight, the landing; species as an open registry | +| [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.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse | diff --git a/packages/ludic.hints/README.md b/packages/ludic.hints/README.md new file mode 100644 index 00000000..1a66c3a1 --- /dev/null +++ b/packages/ludic.hints/README.md @@ -0,0 +1,75 @@ +# ludic.hints + +A rail of things that are true now and can be acted on now - thirsty, the fire burning low, a +bear near, a job ready to hand in - sorted by how much they matter, each one a card that teaches +it in three steps and can be muted for good. Uses [`ludic.base`](../ludic.base/README.md) and +nothing else. + +```ludic +import "ludic.hints" +``` + +The package never reads a key and never draws: the game's input calls the verbs (the key opens +and puts down, the arrows walk, a third key mutes), its interface reads the rail and the card, +and its words are the cards' own text keys, translated where they are drawn. + +## Cards are data + +```ludic +def Hints from "assets/data/hints.lres" # the game's cards, in an open registry +``` + +``` +thirsty { + urgency: HINTS_NOTE, icon: "bottle", item: IT_BOTTLE + line: "Thirsty - drink", title: "Water" + s1: "Drink at any shore, for nothing", pic1: "waterfull" + s2: "Fill a bottle there as well", item2: IT_BOTTLE + s3: "Boil it in the pot to be safe", item3: IT_BOILED + later: "Buy or make a bottle, fill it at the shore and boil it." +} +``` + +`urgency` is `HINTS_TIP`, `HINTS_NOTE`, `HINTS_WARN` or `HINTS_URGENT`. A picture is a glyph by +name (`icon`, `picN`) or a thing's picture by number (`item`, `itemN`, -1 for none) - both the +game's to interpret. + +## The rules it keeps + +1. **The most urgent few.** Once a pass (a second by default) the rail is the true, unmuted cards, + most urgent first and in registry order within an urgency, up to `hints_config`'s count. +2. **A card follows its subject.** As the rail re-sorts under an open card, the pick follows it; + when what it was about stops being true (or the rail stops showing) it is put down, with no fact. +3. **A lesson lands once.** `hints_mute_card()` takes the open card off the rail until + `hints_unmute_all()`. Mutes are kept **by key**, so a card added or removed moves nobody else's, + and a key this build does not know is carried through a load and a save. + +## The port + +```ludic +export port HintsWorld { + true_now: fn(int) -> bool # is card i true now? pure and cheap (unbound: never) + showing: fn() -> bool # the rail is wanted: in play, the player's switch on (unbound: yes) +} +``` + +## API + +| | | +| --- | --- | +| `HintCard { key, urgency, icon, item, line, title, s1..s3, pic1..pic3, item1..item3, later }`, `open registry Hints ... as HINT` | a card | +| `hints_config(shown, every)` | how many the rail holds (3) and seconds between passes (1) | +| `hints_tick(dt)`, `hints_sort()`, `hints_refresh()` | a pass when due, a pass now, the next tick's pass now | +| `hints_rail() -> []int`, `hints_count()`, `hints_at(k)`, `hints_pick()`, `hints_hot(k)` | the rail | +| `hints_card()`, `hints_key()`, `hints_open(k)`, `hints_close()`, `hints_move(d)` | the card being read (-1) and its verbs | +| `hints_step_text(id, n)`, `hints_step_icon(id, n)`, `hints_step_item(id, n)` | a step, n 1..3 | +| `hints_mute_card()`, `hints_mute(id, on)`, `hints_muted(id)`, `hints_mute_count()`, `hints_unmute_all()` | "I know this" | +| `hints_mutes_save() -> Val`, `hints_mutes_load(v)` | the mutes as a list of keys, for the game's settings file | +| `hints_facts() -> Queue` | `{ what, card }`: `HINTS_F_OPENED`, `_CLOSED`, `_MUTED`, `_UNMUTED` | +| `hints_system() -> System` | `"hints"`, `PH_PRESENT`: reset and the tick; nothing in a trip's save | + +## Tests + +```bash +ludic test packages/ludic.hints +``` diff --git a/packages/ludic.hints/card.ludic b/packages/ludic.hints/card.ludic new file mode 100644 index 00000000..285729b0 --- /dev/null +++ b/packages/ludic.hints/card.ludic @@ -0,0 +1,62 @@ +# card.ludic - a card read off the rail: the key opens the picked chip and puts it down, the arrows +# walk the rail with the card up, and "I know this" mutes the card being read +export function hints_card() -> int { return hn_card } + +# the key: the picked card opened, or the open one put down +export function hints_key() -> void { + if hn_card >= 0 { + hints_close() + return + } + hints_open(hn_pick) +} + +# the k-th chip on the rail opened as a card +export function hints_open(k: int) -> void { + if k < 0 or k >= hn_n { return } + hn_pick = k + hn_card = hn_top[k] + hn_fact(HINTS_F_OPENED, hn_card) +} + +export function hints_close() -> void { + if hn_card < 0 { return } + let c = hn_card + hn_card = -1 + hn_fact(HINTS_F_CLOSED, c) +} + +# the arrows, with a card up: the next chip along (d = 1) or the one before, round the ends +export function hints_move(d: int) -> void { + if hn_card < 0 or hn_n <= 1 { return } + hints_open((hn_pick + hn_n + d) % hn_n) +} + +# "I know this - do not show it again": the card off the rail until the mutes are given back +export function hints_mute_card() -> void { + if hn_card < 0 { return } + let c = hn_card + hints_mute(c, true) + hn_card = -1 + hn_t = 0.0 + hn_fact(HINTS_F_MUTED, c) +} + +# a step's words and its picture (n 1..3): the glyph's name, or the item (-1 when it is a glyph) +export function hints_step_text(id: int, n: int) -> string { + if n == 1 { return Hints[id].s1 } + if n == 2 { return Hints[id].s2 } + return Hints[id].s3 +} + +export function hints_step_icon(id: int, n: int) -> string { + if n == 1 { return Hints[id].pic1 } + if n == 2 { return Hints[id].pic2 } + return Hints[id].pic3 +} + +export function hints_step_item(id: int, n: int) -> int { + if n == 1 { return Hints[id].item1 } + if n == 2 { return Hints[id].item2 } + return Hints[id].item3 +} diff --git a/packages/ludic.hints/defs.ludic b/packages/ludic.hints/defs.ludic new file mode 100644 index 00000000..a5a9b1bf --- /dev/null +++ b/packages/ludic.hints/defs.ludic @@ -0,0 +1,39 @@ +# defs.ludic - a card as the game writes it, how much it matters, and the facts +export const HINTS_TIP: int = 0 # worth knowing +export const HINTS_NOTE: int = 1 # worth doing soon +export const HINTS_WARN: int = 2 # worth doing now +export const HINTS_URGENT: int = 3 # do it or lose the day + +# The words are the lesson's text keys (the game translates them where it draws). Pictures are +# the game's: `icon` / `picN` name a glyph, `item` / `itemN` a thing's picture instead (-1: none). +export property HintCard { + key: string = "" + urgency: int = HINTS_TIP + icon: string = "" + item: int = -1 + line: string = "" # the chip on the rail: five or six words + title: string = "" # the card's heading + s1: string = "" # three steps, each with a picture + s2: string = "" + s3: string = "" + pic1: string = "" + pic2: string = "" + pic3: string = "" + item1: int = -1 + item2: int = -1 + item3: int = -1 + later: string = "" # how not to be here next time +} + +# the game's cards; a card's mute is kept by its key, so their order is free +export open registry Hints of HintCard as HINT + +export const HINTS_F_OPENED: int = 0 # a card was opened, or the arrows moved to another +export const HINTS_F_CLOSED: int = 1 # put down by the player (not when it stopped being true) +export const HINTS_F_MUTED: int = 2 # "I know this": off the rail for good +export const HINTS_F_UNMUTED: int = 3 # every muted card given back (card -1) + +export property HintsFact { + what: int = 0 + card: int = -1 +} diff --git a/packages/ludic.hints/index.ludic b/packages/ludic.hints/index.ludic new file mode 100644 index 00000000..95bf889e --- /dev/null +++ b/packages/ludic.hints/index.ludic @@ -0,0 +1,11 @@ +# ludic.hints - cards as an open registry, a port that says which are true, the rail of the most +# urgent few, a card walked along it, and "I know this" kept by the card's key +module ludic_hints uses ludic_base +numbers float +import "ludic.base" +import "defs.ludic" +import "port.ludic" +import "rail.ludic" +import "card.ludic" +import "mute.ludic" +import "system.ludic" diff --git a/packages/ludic.hints/mute.ludic b/packages/ludic.hints/mute.ludic new file mode 100644 index 00000000..7c221404 --- /dev/null +++ b/packages/ludic.hints/mute.ludic @@ -0,0 +1,62 @@ +# mute.ludic - the cards a player has said they know, kept BY KEY: a card added, removed or moved in +# the registry leaves every other mute where it was, and a key this build does not know is carried +var hn_mute: []bool = null +var hn_foreign: []string = null # muted keys with no card in this build, written back as read + +function hn_mute_ensure() -> void { + if hn_mute != null { return } + hn_mute = new []bool + for i in 0 .. HINT_COUNT { push(hn_mute, false) } + hn_foreign = new []string +} + +export function hints_muted(id: int) -> bool { + hn_mute_ensure() + if id < 0 or id >= HINT_COUNT { return false } + return hn_mute[id] +} + +export function hints_mute(id: int, on: bool) -> void { + hn_mute_ensure() + if id < 0 or id >= HINT_COUNT { return } + hn_mute[id] = on +} + +export function hints_mute_count() -> int { + hn_mute_ensure() + var n = 0 + for i in 0 .. HINT_COUNT { if hn_mute[i] { n += 1 } } + return n +} + +# every muted card given back, from the next pass +export function hints_unmute_all() -> void { + hn_mute_ensure() + for i in 0 .. HINT_COUNT { hn_mute[i] = false } + hn_foreign = new []string + hn_t = 0.0 + hn_fact(HINTS_F_UNMUTED, -1) +} + +# the mutes as a list of keys, for wherever the game keeps the player's own choices +export function hints_mutes_save() -> Val { + hn_mute_ensure() + let l = Value.list() + for i in 0 .. HINT_COUNT { if hn_mute[i] { Value.add(l, Value.str(Hints[i].key)) } } + for i in 0 .. len(hn_foreign) { Value.add(l, Value.str(hn_foreign[i])) } + return l +} + +# a list of keys read back; anything that is not a list is no mutes +export function hints_mutes_load(l: Val) -> void { + hn_mute = null + hn_mute_ensure() + if l == null or Value.kind(l) != 5 { return } + for i in 0 .. Value.count(l) { + let e = Value.at(l, i) + if Value.kind(e) != 4 { continue } + let k = Value.as_str(e) + let id = hints_find(k) + if id >= 0 { hn_mute[id] = true } else { push(hn_foreign, k) } + } +} diff --git a/packages/ludic.hints/package.ludic b/packages/ludic.hints/package.ludic new file mode 100644 index 00000000..8c0380ae --- /dev/null +++ b/packages/ludic.hints/package.ludic @@ -0,0 +1,5 @@ +# ludic.hints - a rail of things that are true now and can be acted on now, sorted by how much they +# matter, each one a card that teaches it and can be muted for good. Uses ludic.base and nothing else. +package "ludic.hints" +version "0.1.0" +kind source diff --git a/packages/ludic.hints/port.ludic b/packages/ludic.hints/port.ludic new file mode 100644 index 00000000..23f6b6b8 --- /dev/null +++ b/packages/ludic.hints/port.ludic @@ -0,0 +1,8 @@ +# port.ludic - what the rail asks the game. Unbound: nothing is ever true, and the rail may show. +export port HintsWorld { + true_now: fn(int) -> bool = fn hn_never # is card i true right now? Pure and cheap: asked once a pass + showing: fn() -> bool = fn hn_yes # the rail is wanted (in play, the player's switch on) +} + +function hn_never(i: int) -> bool { return false } +function hn_yes() -> bool { return true } diff --git a/packages/ludic.hints/rail.ludic b/packages/ludic.hints/rail.ludic new file mode 100644 index 00000000..05460edb --- /dev/null +++ b/packages/ludic.hints/rail.ludic @@ -0,0 +1,102 @@ +# rail.ludic - the rail: once a pass (a second by default) the most urgent true cards, up to a few, +# muted ones left out; in a tie the registry's order decides +var hn_shown: int = 3 # how many the rail holds +var hn_every: float = 1.0 # seconds between passes +var hn_top: []int = null # the cards on the rail this pass +var hn_n: int = 0 +var hn_t: float = 0.0 # seconds to the next pass +var hn_pick: int = 0 # which chip the key would open +var hn_card: int = -1 # the card being read, or -1 +var hn_facts: Queue = null + +export function hints_facts() -> Queue { + if hn_facts == null { hn_facts = queue_new("hints.facts") } + return hn_facts +} + +function hn_fact(what: int, card: int) -> void { + let f = new HintsFact + f.what = what + f.card = card + q_push(hints_facts(), f) +} + +# how many the rail holds and how often it is sorted +export function hints_config(shown: int, every: float) -> void { + hn_shown = shown + hn_every = every + hn_top = null +} + +function hn_ensure() -> void { + if hn_top == null or len(hn_top) != hn_shown { + hn_top = new []int + for i in 0 .. hn_shown { push(hn_top, -1) } + } + hn_mute_ensure() +} + +# the next pass comes on the next tick rather than in a second +export function hints_refresh() -> void { hn_t = 0.0 } + +export function hints_tick(dt: float) -> void { + hn_ensure() + hn_t = hn_t - dt + if hn_t > 0.0 { return } + hn_t = hn_every + hints_sort() +} + +# one pass now: the rail re-sorted, the pick kept in range, and the card following its subject +# along the rail - or put down when what it was about stopped being true +export function hints_sort() -> void { + hn_ensure() + hn_n = 0 + let on = HintsWorld.showing() + for u in 0 .. 4 { + let want = HINTS_URGENT - u + for id in 0 .. HINT_COUNT { + if not on or hn_n >= hn_shown { break } + if Hints[id].urgency != want or hints_muted(id) { continue } + if not HintsWorld.true_now(id) { continue } + hn_top[hn_n] = id + hn_n += 1 + } + } + if hn_pick >= hn_n { hn_pick = 0 } + if hn_card < 0 { return } + if not on or hints_muted(hn_card) or not HintsWorld.true_now(hn_card) { + hn_card = -1 + return + } + for k in 0 .. hn_n { if hn_top[k] == hn_card { hn_pick = k } } +} + +# the cards on the rail, most urgent first (empty while the rail is not showing) +export function hints_rail() -> []int { + hn_ensure() + let out = new []int + if not HintsWorld.showing() { return out } + for k in 0 .. hn_n { push(out, hn_top[k]) } + return out +} + +export function hints_count() -> int { return hn_n } + +export function hints_at(k: int) -> int { + if k < 0 or k >= hn_n { return -1 } + return hn_top[k] +} + +export function hints_pick() -> int { return hn_pick } + +export function hints_hot(k: int) -> bool { return k == hn_pick } + +export function hints_reset() -> void { + hn_top = null + hn_n = 0 + hn_t = 0.0 + hn_pick = 0 + hn_card = -1 + q_clear(hints_facts()) +} diff --git a/packages/ludic.hints/system.ludic b/packages/ludic.hints/system.ludic new file mode 100644 index 00000000..c054d713 --- /dev/null +++ b/packages/ludic.hints/system.ludic @@ -0,0 +1,10 @@ +# system.ludic - the rail as a system in PH_PRESENT: it only reads the world, and a trip saves +# nothing of it (the mutes are the player's, kept wherever the game keeps its settings) +function hn_tick(t: Tick) -> void { hints_tick(t.dt) } + +export function hints_system() -> System { + let s = system_new("hints", PH_PRESENT) + s.reset = fn hints_reset + s.tick = fn hn_tick + return s +} diff --git a/packages/ludic.hints/tests/cards.lres b/packages/ludic.hints/tests/cards.lres new file mode 100644 index 00000000..30f1cd56 --- /dev/null +++ b/packages/ludic.hints/tests/cards.lres @@ -0,0 +1,3 @@ +# cards.lres - two cards from a file, after the test's own defs +bear { urgency: HINTS_URGENT, line: "Bear near", title: "A bear", icon: "bear" } +fire { urgency: HINTS_NOTE, line: "Light the fire", later: "Carry three logs." } diff --git a/packages/ludic.hints/tests/hints_test.ludic b/packages/ludic.hints/tests/hints_test.ludic new file mode 100644 index 00000000..ab95eded --- /dev/null +++ b/packages/ludic.hints/tests/hints_test.ludic @@ -0,0 +1,162 @@ +# hints_test.ludic - the rail sorted by urgency and capped, ties in registry order, the card walked +# along it and put down when it stops being true, muting by key across a save, cards from a file +import "ludic.hints" +import "ludic.base" +program HintsTest { + numbers float + def Hints thirsty { urgency: HINTS_NOTE, line: "Thirsty - drink", title: "Water", s1: "Drink at a shore", pic1: "water", item2: 7 } + def Hints parched { urgency: HINTS_URGENT, line: "You must drink now", title: "Water, now" } + def Hints rain { urgency: HINTS_TIP, line: "Rain coming" } + def Hints cold { urgency: HINTS_WARN, line: "Cold" } + def Hints night { urgency: HINTS_TIP, line: "Night soon" } + def Hints from "cards.lres" + + var on: []bool = null + var showing: bool = true + + function fake_true(i: int) -> bool { return on[i] } + function fake_showing() -> bool { return showing } + bind HintsWorld { true_now: fn fake_true, showing: fn fake_showing } + + function fresh() -> void { + on = new []bool + for i in 0 .. HINT_COUNT { push(on, false) } + showing = true + hints_config(3, 1.0) + hints_reset() + hints_mutes_load(Value.list()) + } + + function count(what: int) -> int { + let fs = q_drain(hints_facts()) + var n = 0 + for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } } + return n + } + + test "the registry is the game's, and a file adds to it" { + expect_eq(HINT_COUNT, 7) + expect_eq(hints_find("parched"), HINT_PARCHED) + expect_eq(Hints[HINT_BEAR].urgency, HINTS_URGENT) + expect(Hints[HINT_FIRE].later == "Carry three logs.") + expect(hints_step_icon(HINT_THIRSTY, 1) == "water") + expect_eq(hints_step_item(HINT_THIRSTY, 2), 7) + expect(hints_step_text(HINT_THIRSTY, 1) == "Drink at a shore") + } + + test "the most urgent true cards, capped, ties in registry order" { + fresh() + for i in 0 .. HINT_COUNT { on[i] = true } + hints_tick(1.0) + expect_eq(hints_count(), 3) + expect_eq(hints_at(0), HINT_PARCHED) + expect_eq(hints_at(1), HINT_BEAR) + expect_eq(hints_at(2), HINT_COLD) + on[HINT_PARCHED] = false + on[HINT_BEAR] = false + hints_tick(0.5) + expect_eq(hints_at(0), HINT_PARCHED) # not a second yet: the rail holds + hints_tick(0.6) + expect_eq(hints_at(0), HINT_COLD) + expect_eq(hints_at(1), HINT_THIRSTY) + expect_eq(hints_at(2), HINT_FIRE) + } + + test "nothing on the rail while it is not showing" { + fresh() + on[HINT_COLD] = true + showing = false + hints_sort() + expect_eq(len(hints_rail()), 0) + expect_eq(hints_count(), 0) + showing = true + hints_sort() + expect_eq(len(hints_rail()), 1) + } + + test "the key opens the picked chip, the arrows walk round, the key puts it down" { + fresh() + on[HINT_COLD] = true + on[HINT_THIRSTY] = true + on[HINT_RAIN] = true + hints_sort() + hints_key() + expect_eq(hints_card(), HINT_COLD) + hints_move(1) + expect_eq(hints_card(), HINT_THIRSTY) + expect(hints_hot(1)) + hints_move(-1) + hints_move(-1) + expect_eq(hints_card(), HINT_RAIN) + hints_key() + expect_eq(hints_card(), -1) + expect_eq(count(HINTS_F_OPENED), 4) + } + + test "the card follows its subject as the rail re-sorts, and goes when it is no longer true" { + fresh() + on[HINT_THIRSTY] = true + on[HINT_RAIN] = true + hints_sort() + hints_open(1) + expect_eq(hints_card(), HINT_RAIN) + on[HINT_COLD] = true + hints_sort() + expect_eq(hints_card(), HINT_RAIN) + expect_eq(hints_pick(), 2) + on[HINT_RAIN] = false + hints_sort() + expect_eq(hints_card(), -1) + expect_eq(count(HINTS_F_CLOSED), 0) # it went; nobody put it down + } + + test "I know this: muted off the rail, counted, given back" { + fresh() + on[HINT_COLD] = true + on[HINT_THIRSTY] = true + hints_sort() + hints_key() + hints_mute_card() + expect_eq(hints_card(), -1) + expect(hints_muted(HINT_COLD)) + expect_eq(hints_mute_count(), 1) + expect_eq(count(HINTS_F_MUTED), 1) + hints_tick(0.0) # the next pass comes at once + expect_eq(hints_at(0), HINT_THIRSTY) + expect_eq(hints_count(), 1) + hints_unmute_all() + hints_tick(0.0) + expect_eq(hints_at(0), HINT_COLD) + expect_eq(count(HINTS_F_UNMUTED), 1) + } + + test "mutes are kept by key, and a key this build lacks is carried through" { + fresh() + hints_mute(HINT_NIGHT, true) + hints_mute(HINT_BEAR, true) + let l = hints_mutes_save() + expect_eq(Value.count(l), 2) + Value.add(l, Value.str("gone_card")) + Value.add(l, Value.int(3)) # not a key: ignored + hints_mutes_load(l) + expect(hints_muted(HINT_NIGHT)) + expect(hints_muted(HINT_BEAR)) + expect(not hints_muted(HINT_RAIN)) + expect_eq(hints_mute_count(), 2) + let back = hints_mutes_save() + expect_eq(Value.count(back), 3) + expect(Value.as_str(Value.at(back, 2)) == "gone_card") + hints_mutes_load(null) + expect_eq(hints_mute_count(), 0) + } + + test "as a system it ticks the rail" { + fresh() + core_clear() + core_add(hints_system()) + core_reset_all() + on[HINT_FIRE] = true + core_tick_all(tick_new(1.0, 1, 0.0)) + expect_eq(hints_at(0), HINT_FIRE) + } +}