From 46c275c4c16ebce2a3632f370d9b3a8fd5f8ec69 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 1 Sep 2026 17:25:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(controllers):=20#59=20RPG=20systems=20?= =?UTF-8?q?=E2=80=94=20ludic.rpg=207-module=20suite=20(base=20+=20extensib?= =?UTF-8?q?le)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven independently-usable modules on the six-lever contract with name-keyed data registries (zero-code content): movement (grid/free/tween, 4/8-axis, veto + interact), inventory + equipment (stat bonuses via the gameplay modifier stack), crafting, event-driven quests + flags, an Ink/Yarn dialog graph, Sokoban pushables + a switch/plate/gate signal graph, and over-time status effects through the Combat pipeline. Reuses ludic.gameplay Stats/Combat. Deterministic. 21-check example, in the suite (103 passed, 0 failed). Co-Authored-By: Claude Opus 4.8 --- changes/controllers-rpg.md | 3 + examples/games/rpg_demo.ludic | 158 +++++++++++++++++++++++++ packages/ludic.rpg/crafting.ludic | 83 ++++++++++++++ packages/ludic.rpg/dialog.ludic | 130 +++++++++++++++++++++ packages/ludic.rpg/inventory.ludic | 130 +++++++++++++++++++++ packages/ludic.rpg/mover.ludic | 177 +++++++++++++++++++++++++++++ packages/ludic.rpg/package.ludic | 19 ++++ packages/ludic.rpg/puzzles.ludic | 152 +++++++++++++++++++++++++ packages/ludic.rpg/quests.ludic | 118 +++++++++++++++++++ packages/ludic.rpg/status.ludic | 79 +++++++++++++ tools/x/test.ludic | 1 + 11 files changed, 1050 insertions(+) create mode 100644 changes/controllers-rpg.md create mode 100644 examples/games/rpg_demo.ludic create mode 100644 packages/ludic.rpg/crafting.ludic create mode 100644 packages/ludic.rpg/dialog.ludic create mode 100644 packages/ludic.rpg/inventory.ludic create mode 100644 packages/ludic.rpg/mover.ludic create mode 100644 packages/ludic.rpg/package.ludic create mode 100644 packages/ludic.rpg/puzzles.ludic create mode 100644 packages/ludic.rpg/quests.ludic create mode 100644 packages/ludic.rpg/status.ludic diff --git a/changes/controllers-rpg.md b/changes/controllers-rpg.md new file mode 100644 index 00000000..bca66e3e --- /dev/null +++ b/changes/controllers-rpg.md @@ -0,0 +1,3 @@ +bump: minor +type: feat +Builtin RPG systems suite (#59) — the source package **ludic.rpg**, seven independently-usable modules on the six-lever contract, with name-keyed data registries so a game or mod adds content with zero code. **A Movement** — one `Mover` with a `mode` selector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, and `cancellable MoveRequested` (locked doors/ice) / `TileEntered` (encounters) / `Interacted` (the action-button raycast). **B Inventory** — a name-keyed item registry, per-owner counts, gold, `ItemUse` veto, and `Equipment` whose bonuses flow through the gameplay Stats modifier stack. **C Crafting** — a data-driven recipe + ingredient registry; `Craft.can`/`Craft.make` consume from the inventory. **D Quests** — quests + objectives whose progress is driven by `Quest.notify` (route any gameplay signal in), auto-completing when met, plus a global flag store for branching. **E Dialog** — an Ink/Yarn-style graph registry (nodes + choices) with a per-speaker `Dialog` component and `cancellable DialogChoice` for skill-check gating. **F Puzzles** — Sokoban `Pushable` + a switch / pressure-plate / gate signal graph (logic puzzles with no code). **G Status** — over-time poison/regen effects routed through the shared Combat pipeline. Everything is integer-deterministic, so save/load (world_save) and rollback hold. Example: `examples/games/rpg_demo.ludic` (21 self-checks across all seven modules). diff --git a/examples/games/rpg_demo.ludic b/examples/games/rpg_demo.ludic new file mode 100644 index 00000000..92555aa9 --- /dev/null +++ b/examples/games/rpg_demo.ludic @@ -0,0 +1,158 @@ +# rpg_demo.ludic — the ludic.rpg suite (#59), all seven modules exercised headlessly +# and deterministically: grid movement (+ wall block, MoveRequested veto, interact), +# inventory + equipment stat bonus, crafting, event-driven quests, a dialog graph, +# Sokoban pushables + a pressure-plate/gate signal graph, and a poison status effect. +# +# A full run prints: 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 (21 checks) +# +# Build (from the repo root): +# LUDIC_MODULES=packages ludicc --headless examples/games/rpg_demo.ludic +program RpgDemo { + import "ludic.rpg/mover.ludic" + import "ludic.rpg/inventory.ludic" + import "ludic.rpg/crafting.ludic" + import "ludic.rpg/quests.ludic" + import "ludic.rpg/dialog.ludic" + import "ludic.rpg/puzzles.ludic" + import "ludic.rpg/status.ludic" + + property Position { x: int = 0, y: int = 0 } + property Body { + vx: fixed = 0.0, vy: fixed = 0.0, gravity: fixed = 0.0, max_fall: fixed = 0.0, + rx: fixed = 0.0, ry: fixed = 0.0, policy: int = 1, + on_ground: int = 0, hit_wall: int = 0, hit_ceiling: int = 0 + } + property Solids { tile: int = 0, wall: int = 0, oneway: int = 0 } + + model Hero { Position, Body, Mover, Inventory, Equipment, Stats, Dialog } + model Npc { Position } + model Block { Position, Pushable } + model Plate { Position, Pressure } + model Door { Position, Gate } + model Cfg { Solids } + @OnSpawn(Hero) handler HI { } + @OnSpawn(Npc) handler NI { } + @OnSpawn(Block) handler BI { } + @OnSpawn(Plate) handler PLI { } + @OnSpawn(Door) handler DOI { } + @OnSpawn(Cfg) handler CI { } + + # a locked-door rule: veto a rightward grid step while `locked` is set (lever 4). + var locked: int = 0 + @On(MoveRequested) handler LockDoor { if (locked == 1) and (dx == 1) { cancel } } + var entered: int = 0 + @On(TileEntered) handler CountEnter { entered = entered + 1 } + var talked: int = 0 + @On(Interacted) handler OnTalk { if target >= 0 { talked = talked + 1 } } + var quest_done: int = 0 + @On(QuestCompleted) handler OnQuestDone { quest_done = quest_done + 1 } + + function tick_n(n: int) -> void { var i = 0; while i < n { tick_fixed(); i = i + 1 } } + function bi(b: bool) -> int { if b { return 1 }; return 0 } + function px(e: int) -> int { let P = World.prop_id("Position"); return World.get(e, P, World.field_id(P, "x")) } + function py(e: int) -> int { let P = World.prop_id("Position"); return World.get(e, P, World.field_id(P, "y")) } + function hp(e: int) -> int { let P = World.prop_id("Stats"); return World.get(e, P, World.field_id(P, "hp")) } + + entry { + let PM = World.prop_id("Mover") + + # a 6x3 tile world; a wall glyph at cell (3,1) + Map.size(6, 3) + Map.row(0, "......") + Map.row(1, "...#..") + Map.row(2, "......") + spawn Cfg { Solids { tile: 16, wall: '#', oneway: 0 } } + + spawn Hero { Position { x: 16, y: 16 }, Mover { mode: 0, step: 16, move_cd: 0, axes: 4 }, + Stats { hp: 20, max_hp: 20, atk: 5, def: 0 } } + let h = World.query_next(PM, 0) + + # ================= Module A: grid movement ================= + # move down into the open cell (16,32) + Mover.move(h, 0, 1); tick_n(1); Mover.move(h, 0, 0); tick_n(1) + print(bi((px(h) == 16) and (py(h) == 32))) # 1 — stepped one cell down + print(bi(entered >= 1)) # 1 — TileEntered fired + + # try to walk into the wall at cell (3,1)=(48,16): from (32,16) moving right blocked + World.set(h, World.prop_id("Position"), World.field_id(World.prop_id("Position"), "x"), 32) + World.set(h, World.prop_id("Position"), World.field_id(World.prop_id("Position"), "y"), 16) + Mover.move(h, 1, 0); tick_n(1); Mover.move(h, 0, 0) + print(bi(px(h) == 32)) # 1 — wall blocked the step + + # MoveRequested veto: a locked door forbids the rightward step (open cell now) + World.set(h, World.prop_id("Position"), World.field_id(World.prop_id("Position"), "y"), 32) # row 2 open + locked = 1 + Mover.move(h, 1, 0); tick_n(1); Mover.move(h, 0, 0) + print(bi(px(h) == 32)) # 1 — vetoed, no move + locked = 0 + Mover.move(h, 1, 0); tick_n(1); Mover.move(h, 0, 0) + print(bi(px(h) == 48)) # 1 — now allowed + + # interact: an NPC one cell ahead (facing right after the last move) + spawn Npc { Position { x: 64, y: 32 } } + print(bi(Mover.interact(h) >= 0)) # 1 — found the NPC ahead + print(bi(talked == 1)) # 1 — Interacted fired + + # ================= Module B: inventory + equipment ================= + Item.def("herb", 99, 1, 2) + Item.def("sword", 1, 10, 50) + Inventory.add(h, "herb", 5) + print(bi(Inventory.count(h, "herb") == 5)) # 1 + print(bi(Inventory.use(h, "herb") == 1)) # 1 — consumed one (now 4) + let atk0 = Stats.total(h, 1) # base atk 5 + Inventory.equip(h, 0, 1, 0, 10) # weapon: +10 flat atk + print(bi(Stats.total(h, 1) == atk0 + 10)) # 1 — equipment bonus applies + Inventory.unequip(h, 0) + print(bi(Stats.total(h, 1) == atk0)) # 1 — removed on unequip + + # ================= Module C: crafting ================= + Craft.recipe("potion", "herb", 1) # 2 herb -> 1 (better) herb, toy recipe + Craft.ingredient("potion", "herb", 2) + Inventory.add(h, "herb", 2) # ensure >= 2 + let before = Inventory.count(h, "herb") + print(bi(Craft.can(h, "potion") == 1)) # 1 + print(bi(Craft.make(h, "potion") == 1)) # 1 — consumed 2, produced 1 + print(bi(Inventory.count(h, "herb") == before - 1)) # 1 — net -2 + 1 + + # ================= Module D: quests ================= + Quest.define("hunt") + Quest.objective("hunt", 0, 0, 3) # kill any x3 + Quest.start("hunt") + Quest.notify(0, 0, 1); Quest.notify(0, 0, 1); Quest.notify(0, 0, 1) + print(bi(Quest.state_of("hunt") == 3)) # 1 — auto-completed + print(bi(quest_done == 1)) # 1 — QuestCompleted fired + + # ================= Module E: dialog ================= + let dn0 = Dialog.node("elder", "hello") + let dn1 = Dialog.node("elder", "farewell") + Dialog.choice(dn0, "greet", dn1) + Dialog.choice(dn1, "leave", 0 - 1) + Dialog.start(h, "elder") + print(bi(Dialog.line(h) == "hello")) # 1 — entry node text + Dialog.choose(h, 0) + print(bi(Dialog.line(h) == "farewell")) # 1 — advanced + + # ================= Module F: puzzles ================= + spawn Block { Position { x: 16, y: 0 }, Pushable { } } # cell (1,0), push to (2,0) — both open + let blk = World.query_next(World.prop_id("Pushable"), 0) + print(bi(Puzzle.push(16, 0, 1, 0, 16) == 1)) # 1 — pushed the block right + spawn Plate { Position { x: 200, y: 200 }, Pressure { step: 16 } } + let plate = World.query_next(World.prop_id("Pressure"), 0) + spawn Door { Position { x: 300, y: 300 }, Gate { } } + let door = World.query_next(World.prop_id("Gate"), 0) + Puzzle.link(door, plate) + tick_n(2) + let closed = Puzzle.is_open(door) # 0 — nothing on the plate + spawn Block { Position { x: 200, y: 200 }, Pushable { } } # weight on the plate + tick_n(2) + print(bi((closed == 0) and (Puzzle.is_open(door) == 1))) # 1 — plate -> gate opened + + # ================= Module G: status ================= + let hp0 = hp(h) + Status.apply(h, 0, 3, 2, 8) # poison: 3 dmg every 2 frames for 8 frames + tick_n(8) + print(bi(hp(h) < hp0)) # 1 — poison ticked damage + + quit() + } +} diff --git a/packages/ludic.rpg/crafting.ludic b/packages/ludic.rpg/crafting.ludic new file mode 100644 index 00000000..57f42fb5 --- /dev/null +++ b/packages/ludic.rpg/crafting.ludic @@ -0,0 +1,83 @@ +# crafting.ludic — RPG Module C: a data-driven crafting registry (#59). +# +# Recipes are content, not code: register with Craft.recipe + Craft.ingredient, so a +# game or mod adds crafting with zero code. Craft.make consumes ingredients from the +# ludic.rpg Inventory and yields the output; every step is a veto-able event. + +import "ludic.rpg/inventory.ludic" + +const CRAFT_MAX: int = 128 +const CRAFT_ING_MAX: int = 512 + +var recipe_reg: Dict = null # recipe name -> id +var recipe_n: int = 0 +var recipe_out: pointers = null # output item name +var recipe_outqty: words = null +var ing_n: int = 0 +var ing_rid: words = null # owning recipe id +var ing_name: pointers = null # ingredient item name +var ing_qty: words = null + +function craft_ensure() -> void { + if recipe_reg == null { + recipe_reg = Dict.new() + recipe_out = bytes(CRAFT_MAX * 8); recipe_outqty = words(CRAFT_MAX) + ing_rid = words(CRAFT_ING_MAX); ing_name = bytes(CRAFT_ING_MAX * 8); ing_qty = words(CRAFT_ING_MAX) + } +} + +event cancellable CraftRequested { e: int = 0, recipe: int = 0 } +event CraftCompleted { e: int = 0, recipe: int = 0 } + +# Craft.recipe(name, out_item, out_qty) — declare a recipe; returns its id. +@Namespace(Craft) function craft_recipe(name: pointer, out_item: pointer, out_qty: int) -> int { + craft_ensure() + if recipe_n >= CRAFT_MAX { return 0 - 1 } + let id = recipe_n + recipe_out[id] = out_item; recipe_outqty[id] = out_qty + recipe_n = recipe_n + 1 + Dict.set(recipe_reg, name, id) + return id +} +@Namespace(Craft) function craft_id(name: pointer) -> int { craft_ensure(); return Dict.get_or(recipe_reg, name, 0 - 1) } +# Craft.ingredient(recipe_name, item, qty) — add an ingredient requirement. +@Namespace(Craft) function craft_ingredient(recipe_name: pointer, item: pointer, qty: int) -> void { + craft_ensure() + let rid = craft_id(recipe_name) + if rid < 0 { return } + if ing_n >= CRAFT_ING_MAX { return } + ing_rid[ing_n] = rid; ing_name[ing_n] = item; ing_qty[ing_n] = qty + ing_n = ing_n + 1 +} + +# Craft.can(e, recipe_name) — does the owner hold every ingredient? +@Namespace(Craft) function craft_can(e: int, recipe_name: pointer) -> int { + craft_ensure() + let rid = craft_id(recipe_name) + if rid < 0 { return 0 } + var i = 0 + while i < ing_n { + if ing_rid[i] == rid { + if Inventory.count(e, ing_name[i]) < ing_qty[i] { return 0 } + } + i = i + 1 + } + return 1 +} + +# Craft.make(e, recipe_name) — consume ingredients + produce output; 1 on success. +@Namespace(Craft) function craft_make(e: int, recipe_name: pointer) -> int { + craft_ensure() + let rid = craft_id(recipe_name) + if rid < 0 { return 0 } + if craft_can(e, recipe_name) == 0 { return 0 } + if emit CraftRequested(e: e, recipe: rid) != 0 { return 0 } + var i = 0 + while i < ing_n { + if ing_rid[i] == rid { Inventory.remove(e, ing_name[i], ing_qty[i]) } + i = i + 1 + } + Inventory.add(e, recipe_out[rid], recipe_outqty[rid]) + emit CraftCompleted(e: e, recipe: rid) + return 1 +} diff --git a/packages/ludic.rpg/dialog.ludic b/packages/ludic.rpg/dialog.ludic new file mode 100644 index 00000000..1e47decc --- /dev/null +++ b/packages/ludic.rpg/dialog.ludic @@ -0,0 +1,130 @@ +# dialog.ludic — RPG Module E: a data-driven dialog graph (Ink/Yarn style) (#59). +# +# A tree is nodes (a line of text) + choices (label -> next node). Writers register +# trees with zero engine code; a `Dialog` component tracks the current node per +# speaker. Choices are veto-able (skill-check gating) via cancellable DialogChoice. + +const DLG_TREE_MAX: int = 64 +const DLG_NODE_MAX: int = 512 +const DLG_CHOICE_MAX: int = 1024 + +var dlg_reg: Dict = null # tree name -> tree id +var dlg_tree_n: int = 0 +var dlg_tree_start: words = null # first node id per tree +var dlg_node_n: int = 0 +var dlg_node_tree: words = null +var dlg_node_text: pointers = null +var dlg_choice_n: int = 0 +var dlg_choice_node: words = null # owning node +var dlg_choice_label: pointers = null +var dlg_choice_next: words = null # next node id, or -1 to end + +function dlg_ensure() -> void { + if dlg_reg == null { + dlg_reg = Dict.new() + dlg_tree_start = words(DLG_TREE_MAX) + dlg_node_tree = words(DLG_NODE_MAX); dlg_node_text = bytes(DLG_NODE_MAX * 8) + dlg_choice_node = words(DLG_CHOICE_MAX); dlg_choice_label = bytes(DLG_CHOICE_MAX * 8); dlg_choice_next = words(DLG_CHOICE_MAX) + } +} + +property Dialog { tree: int = 0, node: int = 0, active: int = 0 } + +event DialogStarted { e: int = 0, tree: int = 0 } +event DialogLine { e: int = 0, node: int = 0 } +event cancellable DialogChoice { e: int = 0, node: int = 0, choice: int = 0 } +event DialogEnded { e: int = 0 } + +# Dialog.tree(name) — register (or fetch) a tree id. +@Namespace(Dialog) function dialog_tree(name: pointer) -> int { + dlg_ensure() + let ex = Dict.get_or(dlg_reg, name, 0 - 1) + if ex >= 0 { return ex } + if dlg_tree_n >= DLG_TREE_MAX { return 0 - 1 } + let id = dlg_tree_n + dlg_tree_start[id] = 0 - 1 + dlg_tree_n = dlg_tree_n + 1 + Dict.set(dlg_reg, name, id) + return id +} +# Dialog.node(tree_name, text) — add a line node; the first node added to a tree is +# its entry point. Returns the node id. +@Namespace(Dialog) function dialog_node(tree_name: pointer, text: pointer) -> int { + dlg_ensure() + let tid = dialog_tree(tree_name) + if tid < 0 { return 0 - 1 } + if dlg_node_n >= DLG_NODE_MAX { return 0 - 1 } + let id = dlg_node_n + dlg_node_tree[id] = tid; dlg_node_text[id] = text + dlg_node_n = dlg_node_n + 1 + if dlg_tree_start[tid] < 0 { dlg_tree_start[tid] = id } + return id +} +# Dialog.choice(node, label, next_node) — add a branch (next_node -1 ends). +@Namespace(Dialog) function dialog_choice(node: int, label: pointer, next_node: int) -> int { + dlg_ensure() + if dlg_choice_n >= DLG_CHOICE_MAX { return 0 - 1 } + let id = dlg_choice_n + dlg_choice_node[id] = node; dlg_choice_label[id] = label; dlg_choice_next[id] = next_node + dlg_choice_n = dlg_choice_n + 1 + return id +} + +function dlg_get(e: int, name: pointer) -> int { + let P = World.prop_id("Dialog"); if P < 0 { return 0 } + return World.get(e, P, World.field_id(P, name)) +} +function dlg_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Dialog"); if P < 0 { return } + World.set(e, P, World.field_id(P, name), v) +} + +# Dialog.start(e, tree_name) — open the tree on a speaker (DialogStarted + Line). +@Namespace(Dialog) function dialog_start(e: int, tree_name: pointer) -> void { + dlg_ensure() + let tid = dialog_tree(tree_name) + if tid < 0 { return } + dlg_set(e, "tree", tid) + dlg_set(e, "node", dlg_tree_start[tid]) + dlg_set(e, "active", 1) + emit DialogStarted(e: e, tree: tid) + emit DialogLine(e: e, node: dlg_tree_start[tid]) +} +# Dialog.line(e) — text of the current node (empty when inactive). +@Namespace(Dialog) function dialog_line(e: int) -> pointer { + if dlg_get(e, "active") == 0 { return "" } + return dlg_node_text[dlg_get(e, "node")] +} +# Dialog.count(e) — number of choices at the current node. +@Namespace(Dialog) function dialog_count(e: int) -> int { + dlg_ensure() + if dlg_get(e, "active") == 0 { return 0 } + let node = dlg_get(e, "node") + var n = 0; var i = 0 + while i < dlg_choice_n { if dlg_choice_node[i] == node { n = n + 1 }; i = i + 1 } + return n +} +# Dialog.choose(e, i) — take the i-th choice (veto-able); advance or end. +@Namespace(Dialog) function dialog_choose(e: int, idx: int) -> int { + dlg_ensure() + if dlg_get(e, "active") == 0 { return 0 } + let node = dlg_get(e, "node") + var seen = 0; var i = 0; var pick = 0 - 1 + while i < dlg_choice_n { + if dlg_choice_node[i] == node { + if seen == idx { pick = i; i = dlg_choice_n } else { seen = seen + 1 } + } + i = i + 1 + } + if pick < 0 { return 0 } + if emit DialogChoice(e: e, node: node, choice: idx) != 0 { return 0 } + let nxt = dlg_choice_next[pick] + if nxt < 0 { + dlg_set(e, "active", 0) + emit DialogEnded(e: e) + } else { + dlg_set(e, "node", nxt) + emit DialogLine(e: e, node: nxt) + } + return 1 +} diff --git a/packages/ludic.rpg/inventory.ludic b/packages/ludic.rpg/inventory.ludic new file mode 100644 index 00000000..9e133c54 --- /dev/null +++ b/packages/ludic.rpg/inventory.ludic @@ -0,0 +1,130 @@ +# inventory.ludic — RPG Module B: inventory + a name-keyed item registry (#59). +# +# Items are data (a registry entry, like Anim.clip), so a game/mod adds items with +# zero code. Per-owner counts live in one string-keyed Dict ("/"), so +# the whole inventory is captured by world_save. Equipment routes its stat effects +# through the ludic.gameplay Stats modifier stack. + +import "ludic.gameplay/stats.ludic" + +const INV_MAX: int = 256 + +# ---- item registry -------------------------------------------------------- +var item_reg: Dict = null +var item_n: int = 0 +var item_stackmax: words = null +var item_weight: words = null +var item_value: words = null + +function item_ensure() -> void { + if item_reg == null { + item_reg = Dict.new() + item_stackmax = words(INV_MAX); item_weight = words(INV_MAX); item_value = words(INV_MAX) + } +} + +# Item.def(name, stack_max, weight, value) — register an item template. Returns id. +@Namespace(Item) function item_def(name: pointer, stack_max: int, weight: int, value: int) -> int { + item_ensure() + if item_n >= INV_MAX { return 0 - 1 } + let id = item_n + item_stackmax[id] = stack_max; item_weight[id] = weight; item_value[id] = value + item_n = item_n + 1 + Dict.set(item_reg, name, id) + return id +} +@Namespace(Item) function item_id(name: pointer) -> int { item_ensure(); return Dict.get_or(item_reg, name, 0 - 1) } +@Namespace(Item) function item_value_of(name: pointer) -> int { + let id = item_id(name); if id < 0 { return 0 } + return item_value[id] +} + +# ---- per-owner counts ----------------------------------------------------- +property Inventory { gold: int = 0, weight_max: int = 0, slots: int = 0 } + +var inv_counts: Dict = null +function inv_ensure() -> void { if inv_counts == null { inv_counts = Dict.new() } } +function inv_key(e: int, name: pointer) -> pointer { return `{e}/{name}` } + +event ItemAdded { e: int = 0, item: int = 0, qty: int = 0 } # item = registry id +event ItemRemoved { e: int = 0, item: int = 0, qty: int = 0 } +event cancellable ItemUse { e: int = 0, item: int = 0 } # veto a use (e.g. cursed / requirement) + +# Inventory.add(e, name, n) — add n of an item; emits ItemAdded. +@Namespace(Inventory) function inventory_add(e: int, name: pointer, n: int) -> void { + inv_ensure() + let k = inv_key(e, name) + Dict.set(inv_counts, k, Dict.get_or(inv_counts, k, 0) + n) + emit ItemAdded(e: e, item: item_id(name), qty: n) +} +# Inventory.count(e, name) — how many the owner holds. +@Namespace(Inventory) function inventory_count(e: int, name: pointer) -> int { + inv_ensure() + return Dict.get_or(inv_counts, inv_key(e, name), 0) +} +@Namespace(Inventory) function inventory_has(e: int, name: pointer) -> int { + if inventory_count(e, name) > 0 { return 1 } + return 0 +} +# Inventory.remove(e, name, n) — remove up to n; returns 1 if it had enough. +@Namespace(Inventory) function inventory_remove(e: int, name: pointer, n: int) -> int { + inv_ensure() + let k = inv_key(e, name) + let have = Dict.get_or(inv_counts, k, 0) + if have < n { return 0 } + Dict.set(inv_counts, k, have - n) + emit ItemRemoved(e: e, item: item_id(name), qty: n) + return 1 +} +# Inventory.use(e, name) — consume one item unless a listener vetoes ItemUse. +@Namespace(Inventory) function inventory_use(e: int, name: pointer) -> int { + if inventory_count(e, name) <= 0 { return 0 } + if emit ItemUse(e: e, item: item_id(name)) != 0 { return 0 } + return inventory_remove(e, name, 1) +} +# Inventory.gold / add_gold / spend_gold +@Namespace(Inventory) function inventory_gold(e: int) -> int { + let P = World.prop_id("Inventory") + if P < 0 { return 0 } + return World.get(e, P, World.field_id(P, "gold")) +} +@Namespace(Inventory) function inventory_add_gold(e: int, n: int) -> void { + let P = World.prop_id("Inventory") + if P < 0 { return } + let f = World.field_id(P, "gold") + World.set(e, P, f, World.get(e, P, f) + n) +} +@Namespace(Inventory) function inventory_spend_gold(e: int, n: int) -> int { + let g = inventory_gold(e) + if g < n { return 0 } + inventory_add_gold(e, 0 - n) + return 1 +} + +# ---- equipment: stat effects flow through the gameplay modifier stack ------ +# Equipment holds one modifier-entity id per slot (0 = empty); equipping pushes a +# Stats modifier, unequipping drops it. slot ids: 0 weapon, 1 armor, 2 trinket. +property Equipment { weapon: int = 0, armor: int = 0, trinket: int = 0 } + +# Inventory.equip(e, slot, stat, op, amount) — apply an equipment bonus and record +# the modifier so it can be removed on unequip. Returns the modifier entity. +@Namespace(Inventory) function inventory_equip(e: int, slot: int, stat: int, op: int, amount: int) -> int { + inventory_unequip(e, slot) # replace whatever was there + let m = Stats.modify(e, stat, op, amount, 0) # permanent until unequipped + let P = World.prop_id("Equipment") + if P >= 0 { + if slot == 0 { World.set(e, P, World.field_id(P, "weapon"), m + 1) } + if slot == 1 { World.set(e, P, World.field_id(P, "armor"), m + 1) } + if slot == 2 { World.set(e, P, World.field_id(P, "trinket"), m + 1) } + } + return m +} +@Namespace(Inventory) function inventory_unequip(e: int, slot: int) -> void { + let P = World.prop_id("Equipment") + if P < 0 { return } + var f = World.field_id(P, "weapon") + if slot == 1 { f = World.field_id(P, "armor") } + if slot == 2 { f = World.field_id(P, "trinket") } + let stored = World.get(e, P, f) + if stored > 0 { despawn (stored - 1); World.set(e, P, f, 0) } +} diff --git a/packages/ludic.rpg/mover.ludic b/packages/ludic.rpg/mover.ludic new file mode 100644 index 00000000..69daf74e --- /dev/null +++ b/packages/ludic.rpg/mover.ludic @@ -0,0 +1,177 @@ +# mover.ludic — RPG Module A: movement (grid + free, 4/8-axis). One component with +# a `mode` selector, so a game picks its movement feel as data (#59). +# +# mode 0 grid discrete cell steps (RPG Maker / Pokemon), cooldown-gated +# mode 1 free continuous Body velocity (top-down Zelda), reuses esys_move +# mode 2 grid-tween grid-discrete logic, positions eased between cells +# +# Events at every decision: cancellable MoveRequested (locked doors, ice, cutscene +# lock), TileEntered (encounters, traps), Interacted (the action-button raycast). + +const RPG_Q: int = 65536 + +property Mover { + mode: int = 0, # 0 grid, 1 free, 2 grid-tween + axes: int = 8, # 4 cardinal only, 8 with diagonals + speed: int = 3, # free: px/frame; grid-tween: px/frame slide + step: int = 16, # grid: cell size in px + facing_x: int = 0, facing_y: int = 1, # last-faced direction (for interact) + want_x: int = 0, want_y: int = 0, # intent (-1/0/1), input OR ai + cooldown: int = 0, move_cd: int = 8, # grid: frames between cell steps + tx: int = 0, ty: int = 0, sliding: int = 0, # grid-tween: target cell + state + policy: int = 0 +} + +event cancellable MoveRequested { e: int = 0, dx: int = 0, dy: int = 0 } +event TileEntered { e: int = 0, x: int = 0, y: int = 0 } +event Interacted { e: int = 0, target: int = 0 } + +function mv_get(e: int, name: pointer) -> int { + let P = World.prop_id("Mover"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} +function mv_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Mover"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} +function mv_pos(e: int, name: pointer) -> int { + let P = World.prop_id("Position"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} +function mv_pos_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Position"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} +function mv_body_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Body"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} +function mv_sign(a: int) -> int { if a > 0 { return 1 }; if a < 0 { return 0 - 1 }; return 0 } + +# is the destination cell walkable? Uses the Solids tilemap when configured, +# otherwise the whole field is open. +function mv_walkable(nx: int, ny: int, step: int) -> int { + let ps = World.prop_id("Solids") + if ps < 0 { return 1 } + let se = World.query_next(ps, 0) + if se < 0 { return 1 } + let ft = World.field_id(ps, "tile") + let fw = World.field_id(ps, "wall") + if (ft < 0) or (fw < 0) { return 1 } + let ts = World.get(se, ps, ft) + if ts <= 0 { return 1 } + let wall = World.get(se, ps, fw) + if Grid.blocked(nx / ts, ny / ts, wall) { return 0 } + return 1 +} + +# =========================================================================== +# esys_mover (FixedUpdate) — one active branch per mode. +# =========================================================================== +@EngineSystem(Mover, FixedUpdate) function esys_mover() -> void { + let P = World.prop_id("Mover") + if P < 0 { return } + var e = World.query_next(P, 0) + while e >= 0 { + let mode = mv_get(e, "mode") + if mode == 1 { mover_free(e) } + else { if mode == 2 { mover_grid_tween(e) } else { mover_grid(e) } } + e = World.query_next(P, e + 1) + } +} + +# clamp the intent to the allowed axes (4 = no diagonal: prefer the horizontal). +function mover_intent(e: int) -> int { + var wx = mv_get(e, "want_x") + var wy = mv_get(e, "want_y") + if (mv_get(e, "axes") == 4) and (wx != 0) and (wy != 0) { wy = 0 } + mv_set(e, "facing_x", 0); mv_set(e, "facing_y", 0) + if (wx != 0) or (wy != 0) { mv_set(e, "facing_x", mv_sign(wx)); mv_set(e, "facing_y", mv_sign(wy)) } + # pack signed x,y into one int for return: (wx+1)*4 + (wy+1) + return (mv_sign(wx) + 1) * 4 + (mv_sign(wy) + 1) +} + +function mover_grid(e: int) -> void { + let cd = mv_get(e, "cooldown") + if cd > 0 { mv_set(e, "cooldown", cd - 1); return } + let packed = mover_intent(e) + let dx = packed / 4 - 1 + let dy = packed - (packed / 4) * 4 - 1 + if (dx == 0) and (dy == 0) { return } + let step = mv_get(e, "step") + let nx = mv_pos(e, "x") + dx * step + let ny = mv_pos(e, "y") + dy * step + if mv_walkable(nx, ny, step) == 0 { return } + if emit MoveRequested(e: e, dx: dx, dy: dy) != 0 { return } + mv_pos_set(e, "x", nx) + mv_pos_set(e, "y", ny) + mv_set(e, "cooldown", mv_get(e, "move_cd")) + emit TileEntered(e: e, x: nx, y: ny) +} + +# grid-tween: same discrete logic, but slide the sprite between cells so it looks +# smooth while staying lockstep-discrete (the target cell is the source of truth). +function mover_grid_tween(e: int) -> void { + if mv_get(e, "sliding") == 1 { + let step = mv_get(e, "speed") + let tx = mv_get(e, "tx"); let ty = mv_get(e, "ty") + let px = mv_pos(e, "x"); let py = mv_pos(e, "y") + var nxp = px + mv_sign(tx - px) * step + var nyp = py + mv_sign(ty - py) * step + if mv_sign(tx - px) > 0 { if nxp > tx { nxp = tx } } + if mv_sign(tx - px) < 0 { if nxp < tx { nxp = tx } } + if mv_sign(ty - py) > 0 { if nyp > ty { nyp = ty } } + if mv_sign(ty - py) < 0 { if nyp < ty { nyp = ty } } + mv_pos_set(e, "x", nxp); mv_pos_set(e, "y", nyp) + if (nxp == tx) and (nyp == ty) { mv_set(e, "sliding", 0); emit TileEntered(e: e, x: tx, y: ty) } + return + } + let packed = mover_intent(e) + let dx = packed / 4 - 1 + let dy = packed - (packed / 4) * 4 - 1 + if (dx == 0) and (dy == 0) { return } + let step = mv_get(e, "step") + let nx = mv_pos(e, "x") + dx * step + let ny = mv_pos(e, "y") + dy * step + if mv_walkable(nx, ny, step) == 0 { return } + if emit MoveRequested(e: e, dx: dx, dy: dy) != 0 { return } + mv_set(e, "tx", nx); mv_set(e, "ty", ny); mv_set(e, "sliding", 1) +} + +function mover_free(e: int) -> void { + let packed = mover_intent(e) + let dx = packed / 4 - 1 + let dy = packed - (packed / 4) * 4 - 1 + let sp = mv_get(e, "speed") + mv_body_set(e, "vx", dx * sp * RPG_Q) + mv_body_set(e, "vy", dy * sp * RPG_Q) +} + +# --------------------------------------------------------------------------- +# Mover.* convenience +# --------------------------------------------------------------------------- +@Namespace(Mover) function mover_move(e: int, dx: int, dy: int) -> void { mv_set(e, "want_x", dx); mv_set(e, "want_y", dy) } +# interact: the entity one cell ahead in the facing direction (Interacted event). +@Namespace(Mover) function mover_interact(e: int) -> int { + let step = mv_get(e, "step") + let ax = mv_pos(e, "x") + mv_get(e, "facing_x") * step + let ay = mv_pos(e, "y") + mv_get(e, "facing_y") * step + let PP = World.prop_id("Position") + if PP < 0 { return 0 - 1 } + var o = World.query_next(PP, 0) + while o >= 0 { + if o != e { + let ox = World.get(o, PP, World.field_id(PP, "x")) + let oy = World.get(o, PP, World.field_id(PP, "y")) + let dx = ox - ax; let dy = oy - ay + var adx = dx; if adx < 0 { adx = 0 - adx } + var ady = dy; if ady < 0 { ady = 0 - ady } + if (adx < step) and (ady < step) { emit Interacted(e: e, target: o); return o } + } + o = World.query_next(PP, o + 1) + } + emit Interacted(e: e, target: 0 - 1) + return 0 - 1 +} diff --git a/packages/ludic.rpg/package.ludic b/packages/ludic.rpg/package.ludic new file mode 100644 index 00000000..99f8a944 --- /dev/null +++ b/packages/ludic.rpg/package.ludic @@ -0,0 +1,19 @@ +# ludic.rpg — the builtin RPG systems suite (#59). +# +# Not one controller but seven independently-usable modules, each conforming to the +# six-lever contract (#57): movement (grid + free, 4/8-axis), inventory, crafting, +# quests, dialog, puzzles, and a status/combat backbone. Content is name-keyed data +# registries (items, recipes, quests, dialog trees) so a game or mod adds content +# with zero code. Reuses ludic.gameplay Stats/Combat/Cooldown. Deterministic +# (integer only), so save/load (world_save), replay and rollback all hold. +package "ludic.rpg" +version "0.1.0" +kind source +provides "Mover" +provides "Inventory" +provides "Craft" +provides "Quest" +provides "Dialog" +provides "Puzzle" +provides "Status" +require "ludic.gameplay" "0.1.0" diff --git a/packages/ludic.rpg/puzzles.ludic b/packages/ludic.rpg/puzzles.ludic new file mode 100644 index 00000000..67662225 --- /dev/null +++ b/packages/ludic.rpg/puzzles.ludic @@ -0,0 +1,152 @@ +# puzzles.ludic — RPG Module F: the recurring 2D puzzle primitives (#59). +# +# Sokoban pushables on the grid, plus a signal graph of switches / pressure plates +# driving gates — logic puzzles with no code: wire a Gate to a source and the gate +# opens when the source is on. Composes with the grid Mover (Module A). + +const PZ_Q: int = 65536 + +property Pushable { } +property Switch { on: int = 0 } +property Pressure { on: int = 0, step: int = 16 } +property Gate { open: int = 0, src: int = 0 - 1 } + +event BlockPushed { block: int = 0, dx: int = 0, dy: int = 0 } +event cancellable PushBlocked { block: int = 0 } +event SignalChanged { gate: int = 0, open: int = 0 } + +function pz_pos(e: int, name: pointer) -> int { + let P = World.prop_id("Position"); if P < 0 { return 0 } + return World.get(e, P, World.field_id(P, name)) +} +function pz_pos_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Position"); if P < 0 { return } + World.set(e, P, World.field_id(P, name), v) +} + +# the pushable whose cell is exactly (x, y), or -1. +function pz_block_at(x: int, y: int) -> int { + let P = World.prop_id("Pushable") + if P < 0 { return 0 - 1 } + var e = World.query_next(P, 0) + while e >= 0 { + if (pz_pos(e, "x") == x) and (pz_pos(e, "y") == y) { return e } + e = World.query_next(P, e + 1) + } + return 0 - 1 +} + +# reuse the Mover module's tilemap walkability check when present. +function pz_walkable(nx: int, ny: int, step: int) -> int { + let ps = World.prop_id("Solids") + if ps < 0 { return 1 } + let se = World.query_next(ps, 0) + if se < 0 { return 1 } + let ft = World.field_id(ps, "tile") + let fw = World.field_id(ps, "wall") + if (ft < 0) or (fw < 0) { return 1 } + let ts = World.get(se, ps, ft) + if ts <= 0 { return 1 } + let wall = World.get(se, ps, fw) + if Grid.blocked(nx / ts, ny / ts, wall) { return 0 } + return 1 +} + +# Puzzle.push(bx, by, dx, dy, step) — push the block at (bx,by) one cell along +# (dx,dy) if the destination is free (no wall, no other block). Returns 1 on a move. +@Namespace(Puzzle) function puzzle_push(bx: int, by: int, dx: int, dy: int, step: int) -> int { + let b = pz_block_at(bx, by) + if b < 0 { return 0 } + let nx = bx + dx * step + let ny = by + dy * step + if (pz_walkable(nx, ny, step) == 0) or (pz_block_at(nx, ny) >= 0) { + emit PushBlocked(block: b) + return 0 + } + pz_pos_set(b, "x", nx); pz_pos_set(b, "y", ny) + emit BlockPushed(block: b, dx: dx, dy: dy) + return 1 +} + +# Puzzle.link(gate, source) — a gate opens when its source (a Switch or Pressure) is on. +@Namespace(Puzzle) function puzzle_link(gate: int, source: int) -> void { + let P = World.prop_id("Gate") + if P < 0 { return } + World.set(gate, P, World.field_id(P, "src"), source) +} +# Puzzle.switch(e, on) — set a manual switch. +@Namespace(Puzzle) function puzzle_switch(e: int, on: int) -> void { + let P = World.prop_id("Switch") + if P < 0 { return } + World.set(e, P, World.field_id(P, "on"), on) +} +@Namespace(Puzzle) function puzzle_is_open(gate: int) -> int { + let P = World.prop_id("Gate") + if P < 0 { return 0 } + return World.get(gate, P, World.field_id(P, "open")) +} + +# read the "on" state of a signal source (Switch or Pressure), 0 if neither. +function pz_source_on(src: int) -> int { + if src < 0 { return 0 } + let PS = World.prop_id("Switch") + if (PS >= 0) and (World.has(src, PS) != 0) { return World.get(src, PS, World.field_id(PS, "on")) } + let PP = World.prop_id("Pressure") + if (PP >= 0) and (World.has(src, PP) != 0) { return World.get(src, PP, World.field_id(PP, "on")) } + return 0 +} + +# =========================================================================== +# esys_pressure (Update) — a plate is on while a Pushable or a Position-carrying +# body sits on its cell. +# =========================================================================== +@EngineSystem(Pressure, Update) function esys_pressure() -> void { + let P = World.prop_id("Pressure") + if P < 0 { return } + let fon = World.field_id(P, "on") + let fstep = World.field_id(P, "step") + var e = World.query_next(P, 0) + while e >= 0 { + let px = pz_pos(e, "x"); let py = pz_pos(e, "y") + let step = World.get(e, P, fstep) + var on = 0 + # a pushable on the plate? + if pz_block_at(px, py) >= 0 { on = 1 } + # or any other Position entity within the same cell? + if on == 0 { + let PP = World.prop_id("Position") + var o = World.query_next(PP, 0) + while o >= 0 { + if (o != e) and (World.has(o, P) == 0) { + let ox = World.get(o, PP, World.field_id(PP, "x")) + let oy = World.get(o, PP, World.field_id(PP, "y")) + var adx = ox - px; if adx < 0 { adx = 0 - adx } + var ady = oy - py; if ady < 0 { ady = 0 - ady } + if (adx < step) and (ady < step) { on = 1; o = 0 - 1 } + } + if o >= 0 { o = World.query_next(PP, o + 1) } + } + } + World.set(e, P, fon, on) + e = World.query_next(P, e + 1) + } +} + +# =========================================================================== +# esys_gate (Update, after pressure) — a gate mirrors its source's on-state. +# =========================================================================== +@EngineSystem(Gate, Update) function esys_gate() -> void { + let P = World.prop_id("Gate") + if P < 0 { return } + let fopen = World.field_id(P, "open") + let fsrc = World.field_id(P, "src") + var e = World.query_next(P, 0) + while e >= 0 { + let want = pz_source_on(World.get(e, P, fsrc)) + if World.get(e, P, fopen) != want { + World.set(e, P, fopen, want) + emit SignalChanged(gate: e, open: want) + } + e = World.query_next(P, e + 1) + } +} diff --git a/packages/ludic.rpg/quests.ludic b/packages/ludic.rpg/quests.ludic new file mode 100644 index 00000000..5403eab9 --- /dev/null +++ b/packages/ludic.rpg/quests.ludic @@ -0,0 +1,118 @@ +# quests.ludic — RPG Module D: quests + objectives, driven by events (#59). +# +# A quest is data: a state plus a list of objectives (kind + needed count). Progress +# is reported through Quest.notify — a game routes its existing gameplay signals +# (a kill, a pickup, a tile entered, a dialog choice) into it, and matching active +# objectives tick up; when all of a quest's objectives are met it auto-completes. +# A global flag store (Dict) records world state for branching. All state is in +# module Dicts/arrays, so world_save captures the journal for free. +# +# objective kind convention: 0 kill, 1 collect, 2 reach, 3 talk, 4 flag (extend +# with your own kind ints + a Quest.notify from the matching listener). +# quest state: 0 hidden, 1 available, 2 active, 3 done, 4 failed. + +const QUEST_MAX: int = 128 +const OBJ_MAX: int = 512 + +var quest_reg: Dict = null # quest name -> id +var quest_n: int = 0 +var quest_state: words = null +var obj_n: int = 0 +var obj_qid: words = null # owning quest id +var obj_kind: words = null +var obj_target: words = null # a target id/tag (kind-specific; 0 = any) +var obj_need: words = null +var obj_have: words = null +var quest_flags: Dict = null # global flag store + +function quest_ensure() -> void { + if quest_reg == null { + quest_reg = Dict.new(); quest_flags = Dict.new() + quest_state = words(QUEST_MAX) + obj_qid = words(OBJ_MAX); obj_kind = words(OBJ_MAX); obj_target = words(OBJ_MAX) + obj_need = words(OBJ_MAX); obj_have = words(OBJ_MAX) + } +} + +event QuestStarted { quest: int = 0 } +event QuestCompleted { quest: int = 0 } +event ObjectiveProgressed { quest: int = 0, objective: int = 0, have: int = 0, need: int = 0 } + +# Quest.define(name) — register a quest (state hidden). Returns id. +@Namespace(Quest) function quest_define(name: pointer) -> int { + quest_ensure() + if quest_n >= QUEST_MAX { return 0 - 1 } + let id = quest_n + quest_state[id] = 0 + quest_n = quest_n + 1 + Dict.set(quest_reg, name, id) + return id +} +@Namespace(Quest) function quest_id(name: pointer) -> int { quest_ensure(); return Dict.get_or(quest_reg, name, 0 - 1) } +# Quest.objective(quest_name, kind, target, need) — add an objective. +@Namespace(Quest) function quest_objective(quest_name: pointer, kind: int, target: int, need: int) -> int { + quest_ensure() + let qid = quest_id(quest_name) + if qid < 0 { return 0 - 1 } + if obj_n >= OBJ_MAX { return 0 - 1 } + let id = obj_n + obj_qid[id] = qid; obj_kind[id] = kind; obj_target[id] = target + obj_need[id] = need; obj_have[id] = 0 + obj_n = obj_n + 1 + return id +} +# Quest.start(name) — activate a quest (emits QuestStarted). +@Namespace(Quest) function quest_start(name: pointer) -> void { + quest_ensure() + let qid = quest_id(name) + if qid < 0 { return } + quest_state[qid] = 2 + emit QuestStarted(quest: qid) +} +@Namespace(Quest) function quest_state_of(name: pointer) -> int { + quest_ensure() + let qid = quest_id(name) + if qid < 0 { return 0 - 1 } + return quest_state[qid] +} + +# is every objective of a quest satisfied? +function quest_all_done(qid: int) -> int { + var i = 0 + while i < obj_n { + if obj_qid[i] == qid { if obj_have[i] < obj_need[i] { return 0 } } + i = i + 1 + } + return 1 +} + +# Quest.notify(kind, target, amount) — report a gameplay signal. Every ACTIVE +# quest's matching objectives tick up (target 0 on the objective = "any"); a quest +# whose objectives are all met auto-completes. +@Namespace(Quest) function quest_notify(kind: int, target: int, amount: int) -> void { + quest_ensure() + var i = 0 + while i < obj_n { + let qid = obj_qid[i] + if quest_state[qid] == 2 { + if obj_kind[i] == kind { + if (obj_target[i] == 0) or (obj_target[i] == target) { + if obj_have[i] < obj_need[i] { + obj_have[i] = obj_have[i] + amount + if obj_have[i] > obj_need[i] { obj_have[i] = obj_need[i] } + emit ObjectiveProgressed(quest: qid, objective: i, have: obj_have[i], need: obj_need[i]) + if quest_all_done(qid) == 1 { + quest_state[qid] = 3 + emit QuestCompleted(quest: qid) + } + } + } + } + } + i = i + 1 + } +} + +# ---- global flag store (branching / world state) -------------------------- +@Namespace(Quest) function quest_flag(name: pointer, value: int) -> void { quest_ensure(); Dict.set(quest_flags, name, value) } +@Namespace(Quest) function quest_flag_get(name: pointer) -> int { quest_ensure(); return Dict.get_or(quest_flags, name, 0) } diff --git a/packages/ludic.rpg/status.ludic b/packages/ludic.rpg/status.ludic new file mode 100644 index 00000000..d75d4896 --- /dev/null +++ b/packages/ludic.rpg/status.ludic @@ -0,0 +1,79 @@ +# status.ludic — RPG Module G: status effects over the shared combat backbone (#59). +# +# A status is its own small entity (like a gameplay Modifier), so an actor can carry +# any number and world_save captures them. esys_status ticks each on its interval, +# routing poison/regen through the ludic.gameplay Combat pipeline (so armor, veto +# and Died all still apply), and expires it. Timed stat buffs/debuffs are already +# ludic.gameplay modifiers (Stats.modify with a ttl); this module adds the +# over-time damage/heal effects. +# +# kind: 0 poison (damage over time), 1 regen (heal over time). + +import "ludic.gameplay/combat.ludic" + +property Status { target: int = 0, kind: int = 0, amount: int = 0, interval: int = 30, ttl: int = 0, t: int = 0 } +model StatusFx { Status } +@OnSpawn(StatusFx) handler StatusFxInit { } + +event StatusApplied { target: int = 0, kind: int = 0 } +event StatusExpired { target: int = 0, kind: int = 0 } + +# Status.apply(target, kind, amount, interval, ttl) — attach a timed effect. +@Namespace(Status) function status_apply(target: int, kind: int, amount: int, interval: int, ttl: int) -> int { + let m = World.model_id("StatusFx") + if m < 0 { return 0 - 1 } + let e = World.spawn(m) + let P = World.prop_id("Status") + World.set(e, P, World.field_id(P, "target"), target) + World.set(e, P, World.field_id(P, "kind"), kind) + World.set(e, P, World.field_id(P, "amount"), amount) + World.set(e, P, World.field_id(P, "interval"), interval) + World.set(e, P, World.field_id(P, "ttl"), ttl) + World.set(e, P, World.field_id(P, "t"), interval) + emit StatusApplied(target: target, kind: kind) + return e +} +# Status.clear(target) — drop every status on an actor (dispel). +@Namespace(Status) function status_clear(target: int) -> void { + let P = World.prop_id("Status") + if P < 0 { return } + let ft = World.field_id(P, "target") + var e = World.query_next(P, 0) + while e >= 0 { + var nxt = World.query_next(P, e + 1) + if World.get(e, P, ft) == target { despawn e } + e = nxt + } +} + +# =========================================================================== +# esys_status (Update) — tick intervals, apply the effect through Combat, expire. +# =========================================================================== +@EngineSystem(Status, Update) function esys_status() -> void { + let P = World.prop_id("Status") + if P < 0 { return } + let ftgt = World.field_id(P, "target") + let fkind = World.field_id(P, "kind") + let famt = World.field_id(P, "amount") + let fint = World.field_id(P, "interval") + let fttl = World.field_id(P, "ttl") + let ft = World.field_id(P, "t") + var e = World.query_next(P, 0) + while e >= 0 { + var nxt = World.query_next(P, e + 1) + let tgt = World.get(e, P, ftgt) + let kind = World.get(e, P, fkind) + let amt = World.get(e, P, famt) + # interval tick + var t = World.get(e, P, ft) - 1 + if t <= 0 { + if kind == 1 { Combat.heal(tgt, amt) } else { Combat.damage(tgt, tgt, amt) } + t = World.get(e, P, fint) + } + World.set(e, P, ft, t) + # lifetime + let ttl = World.get(e, P, fttl) - 1 + if ttl <= 0 { emit StatusExpired(target: tgt, kind: kind); despawn e } else { World.set(e, P, fttl, ttl) } + e = nxt + } +} diff --git a/tools/x/test.ludic b/tools/x/test.ludic index 99f4a243..62f5f2f4 100644 --- a/tools/x/test.ludic +++ b/tools/x/test.ludic @@ -245,6 +245,7 @@ function cmd_test() -> int { controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)") controller_case("games/shooter_demo", "", "1 1 1 1 1 1 1 1 1 1 1", "shooter_demo.ludic (ludic.shooter #60: decoupled move/aim, weapon registry, projectile faction-hit, spread/ring, homing, wave spawner)") controller_case("games/npcai_demo", "", "1 1 1 1 1 1 1 1", "npcai_demo.ludic (ludic.npcai #61: perception/spot, FSM chase+attack driving the shooter weapon, utility flee, companion follower)") + controller_case("games/rpg_demo", "", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1", "rpg_demo.ludic (ludic.rpg #59 all 7 modules: grid move+veto+interact, inventory+equip, crafting, quests, dialog graph, sokoban+signal gate, poison status)") spec_case("library/testing", "== 6 passed, 0 failed ==") spec_case("library/coverage", "== 3 passed, 0 failed ==") feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")