feat(controllers): #59 RPG systems — ludic.rpg 7-module suite (base + extensible)
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 <noreply@anthropic.com>
This commit is contained in:
parent
9ce69d23e3
commit
46c275c4c1
11 changed files with 1050 additions and 0 deletions
3
changes/controllers-rpg.md
Normal file
3
changes/controllers-rpg.md
Normal file
|
|
@ -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).
|
||||||
158
examples/games/rpg_demo.ludic
Normal file
158
examples/games/rpg_demo.ludic
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/ludic.rpg/crafting.ludic
Normal file
83
packages/ludic.rpg/crafting.ludic
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
130
packages/ludic.rpg/dialog.ludic
Normal file
130
packages/ludic.rpg/dialog.ludic
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
130
packages/ludic.rpg/inventory.ludic
Normal file
130
packages/ludic.rpg/inventory.ludic
Normal file
|
|
@ -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 ("<entity>/<item>"), 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) }
|
||||||
|
}
|
||||||
177
packages/ludic.rpg/mover.ludic
Normal file
177
packages/ludic.rpg/mover.ludic
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
19
packages/ludic.rpg/package.ludic
Normal file
19
packages/ludic.rpg/package.ludic
Normal file
|
|
@ -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"
|
||||||
152
packages/ludic.rpg/puzzles.ludic
Normal file
152
packages/ludic.rpg/puzzles.ludic
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
packages/ludic.rpg/quests.ludic
Normal file
118
packages/ludic.rpg/quests.ludic
Normal file
|
|
@ -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) }
|
||||||
79
packages/ludic.rpg/status.ludic
Normal file
79
packages/ludic.rpg/status.ludic
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/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/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/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/testing", "== 6 passed, 0 failed ==")
|
||||||
spec_case("library/coverage", "== 3 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)")
|
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue