diff --git a/packages/ludic.crafting/README.md b/packages/ludic.crafting/README.md new file mode 100644 index 00000000..7ae326b2 --- /dev/null +++ b/packages/ludic.crafting/README.md @@ -0,0 +1,72 @@ +# ludic.crafting + +Recipes and making them: what goes in, what comes out, where it is made and how long it takes, +whether it can be made right now, taking the makings, giving them back, and a recipe made by +holding a button rather than clicking it. Uses [`ludic.base`](../ludic.base/README.md) and +nothing else - the pack and the stations are the game's, asked through ports. + +```ludic +import "ludic.crafting" +``` + +## The recipes + +`CraftRecipes` is an `open registry` the game fills, one `def` per recipe; `CRAFT_` is its +index. A station that holds a recipe holds its index, so a game that saves that appends. + +```ludic +def CraftRecipes torch { out: IT_TORCH, ins: [IT_WOOD, IT_CLOTH], ns: [1, 1] } +def CraftRecipes rope { out: IT_ROPE, n: 2, ins: [IT_WILLOW], ns: [4], hold: 2.6 } +def CraftRecipes stew { out: IT_STEW, ins: [IT_MUSHROOM, IT_WATER], ns: [2, 1], station: POT, minutes: 25 } +``` + +| field | | +| --- | --- | +| `out`, `n` | what it makes, and how many | +| `ins`, `ns` | the makings and how many of each, side by side | +| `station` | where it is made: `CRAFT_HANDS` (0, the default) is anywhere; any other number is the game's | +| `minutes` | game minutes at its station (0: made at once); a timed recipe at a station is started there | +| `hold` | seconds of holding to make it (0: a click) | +| `group` | the game's grouping (a tab, a heading) | +| `learned` | false: never made | + +## The ports + +```ludic +export port CraftPack { + count: fn(int) -> int # how many of an item + take: fn(int, int) -> bool + add: fn(int, int) -> int + room: fn(int) -> int # how many more fit (unbound: no limit) + leaves: fn(int) -> int # what an item leaves once used, -1 nothing (unbound: nothing) +} +export port CraftStations { # unbound: only the hands; nothing can be started + ready: fn(int) -> bool # can this station be used right now? + start: fn(int, int) -> bool # put recipe in station; false gives the makings back +} +``` + +A game with an inventory package binds `CraftPack` to it; this package never names one. + +## API + +| | | +| --- | --- | +| `craft_count()`, `craft_ok(i)`, `craft_recipe(i)`, `craft_find(item)`, `craft_by_key(key)` | the table | +| `craft_inputs(i)`, `craft_input(i, k)`, `craft_input_n(i, k)` | a recipe's makings | +| `craft_has(i)` | every making is in the pack | +| `craft_can(i)` | learned, the makings, its station ready, and room for what it makes | +| `craft_take(i)`, `craft_refund(i)` | the makings out (and what they leave in), and back again | +| `craft_make(i) -> bool` | make it: at once into the pack, or started at its station; false and nothing taken when it cannot | +| `craft_holds(i)`, `craft_hold_start(i) -> bool`, `craft_hold_stop()`, `craft_holding()`, `craft_hold_frac(i)`, `craft_hold_run(dt) -> bool` | the hold: `run` is true on the frame it is made; it stops itself when the recipe can no longer be made | +| `craft_facts() -> Queue` | `{ recipe, item, n, station, queued }`, one per making (`queued`: started at a station) | +| `craft_reset()`, `craft_system() -> System` | `"crafting"`, `PH_SIMULATE`: a reset; nothing is saved | + +The hold is run by whatever screen shows it (`craft_hold_run(dt)` from that screen's tick), since a +screen may pause the world. + +## Tests + +```bash +ludic test packages/ludic.crafting +``` diff --git a/packages/ludic.crafting/hold.ludic b/packages/ludic.crafting/hold.ludic new file mode 100644 index 00000000..12e46f16 --- /dev/null +++ b/packages/ludic.crafting/hold.ludic @@ -0,0 +1,42 @@ +# hold.ludic - a recipe made by holding a button for its `hold` seconds: letting go wastes +# nothing, and it stops by itself the moment it can no longer be made +var cr_hold: int = -1 +var cr_hold_t: float = 0.0 + +export function craft_holds(i: int) -> bool { return craft_ok(i) and CraftRecipes[i].hold > 0.0 } + +# the recipe being held, or -1 +export function craft_holding() -> int { return cr_hold } + +# starts holding one that is made by holding and can be made now +export function craft_hold_start(i: int) -> bool { + if not craft_holds(i) or not craft_can(i) { return false } + cr_hold = i + cr_hold_t = 0.0 + return true +} + +export function craft_hold_stop() -> void { + cr_hold = -1 + cr_hold_t = 0.0 +} + +# how far through the hold on `i` is, 0 to 1 +export function craft_hold_frac(i: int) -> float { + if cr_hold != i or not craft_holds(i) { return 0.0 } + return Math.clamp(cr_hold_t / CraftRecipes[i].hold, 0.0, 1.0) +} + +# `dt` real seconds of holding; true on the frame it is made +export function craft_hold_run(dt: float) -> bool { + if cr_hold < 0 { return false } + if not craft_can(cr_hold) { + craft_hold_stop() + return false + } + cr_hold_t = cr_hold_t + dt + if cr_hold_t <= CraftRecipes[cr_hold].hold { return false } + let i = cr_hold + craft_hold_stop() + return craft_make(i) +} diff --git a/packages/ludic.crafting/index.ludic b/packages/ludic.crafting/index.ludic new file mode 100644 index 00000000..d6319cfa --- /dev/null +++ b/packages/ludic.crafting/index.ludic @@ -0,0 +1,10 @@ +# ludic.crafting - recipes as an open registry; the pack and the stations are the game's, asked +# through ports; making one takes its makings and either hands the result over or starts a station +module ludic_crafting uses ludic_base +numbers float +import "ludic.base" +import "recipes.ludic" +import "ports.ludic" +import "verbs.ludic" +import "hold.ludic" +import "system.ludic" diff --git a/packages/ludic.crafting/package.ludic b/packages/ludic.crafting/package.ludic new file mode 100644 index 00000000..c65785af --- /dev/null +++ b/packages/ludic.crafting/package.ludic @@ -0,0 +1,5 @@ +# ludic.crafting - recipes (what goes in, what comes out, where and how long), whether one can be +# made, making it, giving it back, and the hold-to-make. Uses ludic.base and nothing else. +package "ludic.crafting" +version "0.1.0" +kind source diff --git a/packages/ludic.crafting/ports.ludic b/packages/ludic.crafting/ports.ludic new file mode 100644 index 00000000..4b2433f9 --- /dev/null +++ b/packages/ludic.crafting/ports.ludic @@ -0,0 +1,20 @@ +# ports.ludic - what crafting asks the game: the pack, and the stations +# the pack. `leaves` is what an item leaves behind once used (water leaves its bottle), -1 nothing. +export port CraftPack { + count: fn(int) -> int + take: fn(int, int) -> bool + add: fn(int, int) -> int + room: fn(int) -> int = fn cr_no_limit + leaves: fn(int) -> int = fn cr_leaves_nothing +} +function cr_no_limit(it: int) -> int { return 2147483647 } +function cr_leaves_nothing(it: int) -> int { return -1 } + +# the stations. Unbound, there are none: only the hands are ready, and nothing can be started. +# `start(station, recipe)` puts a timed recipe in; false gives the makings back. +export port CraftStations { + ready: fn(int) -> bool = fn cr_hands_only + start: fn(int, int) -> bool = fn cr_no_start +} +function cr_hands_only(station: int) -> bool { return station == CRAFT_HANDS } +function cr_no_start(station: int, recipe: int) -> bool { return false } diff --git a/packages/ludic.crafting/recipes.ludic b/packages/ludic.crafting/recipes.ludic new file mode 100644 index 00000000..62a63143 --- /dev/null +++ b/packages/ludic.crafting/recipes.ludic @@ -0,0 +1,59 @@ +# recipes.ludic - a recipe, the registry the game fills, and the fact a making leaves +# station 0 is the hands: anywhere, always ready +export const CRAFT_HANDS: int = 0 + +# `ins` are items and `ns` how many of each, side by side; `minutes` is game time at a station +# (0 is made at once) and `hold` seconds of holding the button (0 is a click) +export property CraftRecipe { + key: string = "" + out: int = 0 + n: int = 1 + ins: []int = null + ns: []int = null + station: int = 0 + minutes: int = 0 + hold: float = 0.0 + group: int = 0 + learned: bool = true +} + +# the game fills it: `def CraftRecipes torch { out: IT_TORCH, ins: [IT_WOOD], ns: [1] }`. A +# station holds a recipe by its index, so a game that saves that appends, never inserts. +export open registry CraftRecipes of CraftRecipe as CRAFT + +# a recipe was made: handed over (`queued` false) or put into its station (`queued` true) +export property Crafted { + recipe: int = 0 + item: int = 0 + n: int = 0 + station: int = 0 + queued: bool = false +} + +export function craft_ok(i: int) -> bool { return i >= 0 and i < CRAFT_COUNT } + +export function craft_count() -> int { return CRAFT_COUNT } + +export function craft_recipe(i: int) -> CraftRecipe { return CraftRecipes[i] } + +# how many makings a recipe has +export function craft_inputs(i: int) -> int { + if not craft_ok(i) or CraftRecipes[i].ins == null { return 0 } + return len(CraftRecipes[i].ins) +} + +export function craft_input(i: int, k: int) -> int { return CraftRecipes[i].ins[k] } + +export function craft_input_n(i: int, k: int) -> int { return CraftRecipes[i].ns[k] } + +# the first recipe that makes `item`, or -1 +export function craft_find(item: int) -> int { + for i in 0 .. CRAFT_COUNT { if CraftRecipes[i].out == item { return i } } + return -1 +} + +# the recipe the save calls `key`, or -1 +export function craft_by_key(key: string) -> int { + for i in 0 .. CRAFT_COUNT { if CraftRecipes[i].key == key { return i } } + return -1 +} diff --git a/packages/ludic.crafting/system.ludic b/packages/ludic.crafting/system.ludic new file mode 100644 index 00000000..fb233a5c --- /dev/null +++ b/packages/ludic.crafting/system.ludic @@ -0,0 +1,12 @@ +# system.ludic - crafting as a system: a reset, and nothing saved (a hold never outlives its +# screen; what a station holds is the station's) +export function craft_reset() -> void { + craft_hold_stop() + q_clear(craft_facts()) +} + +export function craft_system() -> System { + let s = system_new("crafting", PH_SIMULATE) + s.reset = fn craft_reset + return s +} diff --git a/packages/ludic.crafting/tests/crafting_test.ludic b/packages/ludic.crafting/tests/crafting_test.ludic new file mode 100644 index 00000000..c000b949 --- /dev/null +++ b/packages/ludic.crafting/tests/crafting_test.ludic @@ -0,0 +1,178 @@ +# crafting_test.ludic - a recipe made from a fake pack: the makings, the station's say, room for +# the result, what water leaves behind, a station that refuses, the hold, and the facts +import "ludic.crafting" +import "ludic.base" +program CraftingTest { + numbers float + const WOOD: int = 0 + const CLOTH: int = 1 + const TORCH: int = 2 + const ROPE: int = 3 + const WATER: int = 4 + const BOTTLE: int = 5 + const TEA: int = 6 + const HERB: int = 7 + const BENCH: int = 1 + const POT: int = 2 + + def CraftRecipes torch { out: TORCH, ins: [WOOD, CLOTH], ns: [1, 1] } + def CraftRecipes rope { out: ROPE, n: 2, ins: [HERB], ns: [4], hold: 2.0 } + def CraftRecipes tea { out: TEA, ins: [HERB, WATER], ns: [2, 1], station: POT, minutes: 8 } + def CraftRecipes chair { out: TORCH, ins: [WOOD], ns: [3], station: BENCH } + def CraftRecipes secret { out: TORCH, ins: [WOOD], ns: [1], learned: false } + + var pack: []int = null + var bench_near: bool = true + var pot_takes: bool = true + var pot_got: int = -1 + + function count(it: int) -> int { return pack[it] } + function take(it: int, n: int) -> bool { + if pack[it] < n { return false } + pack[it] = pack[it] - n + return true + } + function add(it: int, n: int) -> int { + pack[it] = pack[it] + n + return n + } + function room(it: int) -> int { + if it == TORCH { return 3 - pack[it] } + return 99 + } + function leaves(it: int) -> int { + if it == WATER { return BOTTLE } + return -1 + } + function ready(st: int) -> bool { + if st == BENCH { return bench_near } + return true + } + function start(st: int, recipe: int) -> bool { + if not pot_takes { return false } + pot_got = recipe + return true + } + + bind CraftPack { count: fn count, take: fn take, add: fn add, room: fn room, leaves: fn leaves } + bind CraftStations { ready: fn ready, start: fn start } + + function fresh() -> void { + pack = new []int + for i in 0 .. 8 { push(pack, 0) } + craft_reset() + } + + function facts() -> []Crafted { return q_drain(craft_facts()) } + + test "the registry is the game's, in the order written" { + expect_eq(CRAFT_COUNT, 5) + expect_eq(CRAFT_TEA, 2) + expect_eq(craft_find(ROPE), CRAFT_ROPE) + expect_eq(craft_by_key("chair"), CRAFT_CHAIR) + expect_eq(craft_inputs(CRAFT_TEA), 2) + expect_eq(craft_find(99), -1) + } + + test "made at once from the makings, and said" { + fresh() + expect(not craft_can(CRAFT_TORCH)) + pack[WOOD] = 2 + pack[CLOTH] = 1 + expect(craft_make(CRAFT_TORCH)) + expect_eq(pack[WOOD], 1) + expect_eq(pack[CLOTH], 0) + expect_eq(pack[TORCH], 1) + let fs = facts() + expect_eq(len(fs), 1) + expect_eq(fs[0].item, TORCH) + expect(not fs[0].queued) + } + + test "nothing is taken when it cannot be made" { + fresh() + pack[WOOD] = 1 + expect(not craft_make(CRAFT_TORCH)) + expect_eq(pack[WOOD], 1) + expect_eq(len(facts()), 0) + } + + test "the station decides, and so does the room for what it makes" { + fresh() + pack[WOOD] = 9 + bench_near = false + expect(craft_has(CRAFT_CHAIR)) + expect(not craft_can(CRAFT_CHAIR)) + bench_near = true + expect(craft_can(CRAFT_CHAIR)) + pack[TORCH] = 3 + expect(not craft_can(CRAFT_CHAIR)) + } + + test "an unlearned recipe is never made" { + fresh() + pack[WOOD] = 5 + expect(not craft_can(CRAFT_SECRET)) + } + + test "a timed recipe goes into its station, and water leaves its bottle" { + fresh() + pack[HERB] = 2 + pack[WATER] = 1 + expect(craft_make(CRAFT_TEA)) + expect_eq(pot_got, CRAFT_TEA) + expect_eq(pack[TEA], 0) + expect_eq(pack[WATER], 0) + expect_eq(pack[BOTTLE], 1) + let fs = facts() + expect(fs[0].queued) + expect_eq(fs[0].station, POT) + } + + test "a station that says no gives everything back" { + fresh() + pot_takes = false + pack[HERB] = 2 + pack[WATER] = 1 + expect(not craft_make(CRAFT_TEA)) + expect_eq(pack[HERB], 2) + expect_eq(pack[WATER], 1) + expect_eq(pack[BOTTLE], 0) + expect_eq(len(facts()), 0) + } + + test "a hold makes it after its seconds, and not before" { + fresh() + pack[HERB] = 4 + expect(craft_holds(CRAFT_ROPE)) + expect(not craft_holds(CRAFT_TORCH)) + expect(craft_hold_start(CRAFT_ROPE)) + expect(not craft_hold_run(1.0)) + expect(craft_hold_frac(CRAFT_ROPE) == 0.5) + expect(not craft_hold_run(0.9)) + expect(craft_hold_run(0.2)) + expect_eq(pack[ROPE], 2) + expect_eq(craft_holding(), -1) + } + + test "letting go wastes nothing, and losing the makings stops the hold" { + fresh() + pack[HERB] = 4 + craft_hold_start(CRAFT_ROPE) + craft_hold_run(1.5) + craft_hold_stop() + expect_eq(pack[HERB], 4) + craft_hold_start(CRAFT_ROPE) + pack[HERB] = 3 + expect(not craft_hold_run(5.0)) + expect_eq(craft_holding(), -1) + expect_eq(pack[ROPE], 0) + } + + test "a click recipe cannot be held" { + fresh() + pack[WOOD] = 1 + pack[CLOTH] = 1 + expect(not craft_hold_start(CRAFT_TORCH)) + } +} diff --git a/packages/ludic.crafting/verbs.ludic b/packages/ludic.crafting/verbs.ludic new file mode 100644 index 00000000..b4528dbb --- /dev/null +++ b/packages/ludic.crafting/verbs.ludic @@ -0,0 +1,77 @@ +# verbs.ludic - can it be made, taking and giving back its makings, and making it +var cr_facts: Queue = null + +export function craft_facts() -> Queue { + if cr_facts == null { cr_facts = queue_new("crafting.crafted") } + return cr_facts +} + +# every making is in the pack +export function craft_has(i: int) -> bool { + if not craft_ok(i) { return false } + for k in 0 .. craft_inputs(i) { + if CraftPack.count(craft_input(i, k)) < craft_input_n(i, k) { return false } + } + return true +} + +# learned, the makings in the pack, its station ready, and room for what it makes +export function craft_can(i: int) -> bool { + if not craft_ok(i) { return false } + let r = CraftRecipes[i] + if not r.learned or not craft_has(i) { return false } + if not CraftStations.ready(r.station) { return false } + return CraftPack.room(r.out) >= r.n +} + +# the makings out of the pack, and what they leave behind into it +export function craft_take(i: int) -> void { + for k in 0 .. craft_inputs(i) { + let it = craft_input(i, k) + let n = craft_input_n(i, k) + CraftPack.take(it, n) + let left = CraftPack.leaves(it) + if left >= 0 { CraftPack.add(left, n) } + } +} + +# the makings back, and what they left behind out again (a station said no after all) +export function craft_refund(i: int) -> void { + for k in 0 .. craft_inputs(i) { + let it = craft_input(i, k) + let n = craft_input_n(i, k) + CraftPack.add(it, n) + let left = CraftPack.leaves(it) + if left >= 0 { CraftPack.take(left, n) } + } +} + +function cr_fact(i: int, queued: bool) -> void { + let r = CraftRecipes[i] + let c = new Crafted + c.recipe = i + c.item = r.out + c.n = r.n + c.station = r.station + c.queued = queued + q_push(craft_facts(), c) +} + +# make it: a timed recipe at a station goes into the station (given back if it will not take it), +# anything else is handed over at once. False, and the pack untouched, when it cannot be made. +export function craft_make(i: int) -> bool { + if not craft_can(i) { return false } + let r = CraftRecipes[i] + craft_take(i) + if r.minutes > 0 and r.station != CRAFT_HANDS { + if not CraftStations.start(r.station, i) { + craft_refund(i) + return false + } + cr_fact(i, true) + return true + } + CraftPack.add(r.out, r.n) + cr_fact(i, false) + return true +}