feat(packages): ludic.settings - a settings store as data: typed reads and writes by id, clamping, rescue values, per-key persistence, a fact per change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3514d46954
commit
4318adc98c
9 changed files with 338 additions and 0 deletions
38
packages/ludic.settings/README.md
Normal file
38
packages/ludic.settings/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# ludic.settings
|
||||
|
||||
A game's settings as data, in one store with one owner. The game declares them - usually as a
|
||||
registry of `SettingDef` read from a `.lres` - and everything else reads and writes them through
|
||||
the store's verbs. A change is a `SettingChanged` fact the game drains to apply it. Uses
|
||||
[`ludic.base`](../ludic.base/README.md) and nothing else.
|
||||
|
||||
```ludic
|
||||
import "ludic.settings"
|
||||
export registry Settings of SettingDef as SET from "assets/data/settings.lres"
|
||||
for i in 0 .. SET_COUNT { settings_add(Settings[i]) } # SET_* is each setting's id
|
||||
```
|
||||
|
||||
```
|
||||
# settings.lres - the entry's name is the key in the file
|
||||
bloom { kind: SETTING_BOOL, num: 1.0 }
|
||||
shadow_q { kind: SETTING_INT, num: 2.0, lo: 0.0, hi: 3.0, rescue: true }
|
||||
rscale { kind: SETTING_FLOAT, num: 0.78, lo: 0.5, hi: 1.0, rescue: true }
|
||||
lang { kind: SETTING_TEXT, text: "en" }
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `SettingDef { key, kind, num, text, lo, hi, rescue, saved }` | a setting: `SETTING_BOOL` / `_INT` / `_FLOAT` / `_TEXT`, its default (`num` or `text`), its range (`hi < lo` is none), whether a rescue start puts it back, whether it is written |
|
||||
| `settings_add(d) -> int`, `settings_clear()`, `settings_count()`, `settings_def(id)`, `settings_id_of(key) -> int` | the table; an id is the place a setting was added |
|
||||
| `settings_get_bool / _int / _float / _text(id)` | the value |
|
||||
| `settings_set_bool / _int / _float / _text(id, v)`, `settings_set_value(id, Val)` | a change, held to the range (outside it is the default) and reported |
|
||||
| `settings_changes() -> Queue<SettingChanged>` | the facts: `{ id, key }`, one per change |
|
||||
| `settings_reset_all()`, `settings_clamp_all()`, `settings_rescue()`, `settings_is_safe(id)` | the whole store at once, quietly: defaults, ranges, the rescue set |
|
||||
| `settings_to_json() -> Val`, `settings_put(v, id)`, `settings_from_json(v)` | the file, by key: a switch is 0/1, a float thousandths; a key the file lacks keeps the store's value |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
ludic build packages/ludic.settings/tests/settings_test.ludic --headless -o /tmp/settings_test && /tmp/settings_test
|
||||
```
|
||||
23
packages/ludic.settings/defs.ludic
Normal file
23
packages/ludic.settings/defs.ludic
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# ludic.settings/defs.ludic - what a setting is: its key in the file, its kind, its default, the
|
||||
# range it may take, and whether a rescue start puts it back. A game's registry is a list of these.
|
||||
export const SETTING_BOOL: int = 0
|
||||
export const SETTING_INT: int = 1
|
||||
export const SETTING_FLOAT: int = 2
|
||||
export const SETTING_TEXT: int = 3
|
||||
|
||||
export property SettingDef {
|
||||
key: string = "" # its name in the file; a registry fills it with the entry's own name
|
||||
kind: int = 0 # SETTING_*
|
||||
num: float = 0.0 # the default: 0 or 1 for a switch, a whole number, a float
|
||||
text: string = "" # the default of a text setting
|
||||
lo: float = 0.0 # the range, inclusive; a value outside it is the default again
|
||||
hi: float = -1.0 # hi below lo: any value
|
||||
rescue: bool = false # a rescue start puts it back to its default
|
||||
saved: bool = true # false: held in memory only, never written to the file
|
||||
}
|
||||
|
||||
# one setting changed, by its place in the store and its key
|
||||
export property SettingChanged {
|
||||
id: int = 0
|
||||
key: string = ""
|
||||
}
|
||||
54
packages/ludic.settings/file.ludic
Normal file
54
packages/ludic.settings/file.ludic
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# ludic.settings/file.ludic - the store as a JSON object, by key: a switch is 0 or 1, a whole number
|
||||
# is itself, a float is thousandths (sv_put_float), text is a string. A key the file lacks keeps
|
||||
# what the store holds, and a key the store does not know is ignored.
|
||||
export function settings_to_json() -> Val {
|
||||
sg_init()
|
||||
let v = Value.object()
|
||||
for i in 0 .. len(sg_defs) { settings_put(v, i) }
|
||||
return v
|
||||
}
|
||||
|
||||
# one setting into `v` under its key (a game building its own file adds its extras beside them)
|
||||
export function settings_put(v: Val, id: int) -> void {
|
||||
let d = sg_defs[id]
|
||||
if not d.saved { return }
|
||||
if d.kind == SETTING_TEXT {
|
||||
sv_put_str(v, d.key, sg_text[id])
|
||||
} else if d.kind == SETTING_FLOAT {
|
||||
sv_put_float(v, d.key, sg_num[id])
|
||||
} else {
|
||||
sv_put_int(v, d.key, int(sg_num[id]))
|
||||
}
|
||||
}
|
||||
|
||||
# every key `v` names, held to its range; quiet, since a load is applied whole by its caller
|
||||
export function settings_from_json(v: Val) -> void {
|
||||
sg_init()
|
||||
if v == null { return }
|
||||
for i in 0 .. len(sg_defs) { sg_read(v, i) }
|
||||
}
|
||||
|
||||
function sg_read(v: Val, id: int) -> void {
|
||||
let d = sg_defs[id]
|
||||
if not d.saved or Value.kind(v) != 6 or Value.has(v, d.key) == 0 { return }
|
||||
if d.kind == SETTING_TEXT {
|
||||
sg_text[id] = sv_str(v, d.key, sg_text[id])
|
||||
} else if d.kind == SETTING_FLOAT {
|
||||
sg_num[id] = settings_held(id, sv_float(v, d.key, sg_num[id]))
|
||||
} else {
|
||||
sg_num[id] = settings_held(id, float(sv_int(v, d.key, int(sg_num[id]))))
|
||||
}
|
||||
}
|
||||
|
||||
# one setting from a JSON value in the file's own form (a row in a settings screen hands these):
|
||||
# a change is a fact
|
||||
export function settings_set_value(id: int, x: Val) -> void {
|
||||
let d = sg_defs[id]
|
||||
if d.kind == SETTING_TEXT {
|
||||
if Value.kind(x) == 4 { settings_set_text(id, Value.as_str(x)) }
|
||||
} else if d.kind == SETTING_FLOAT {
|
||||
settings_set_float(id, float(Value.as_int(x)) / 1000.0)
|
||||
} else {
|
||||
settings_set_int(id, Value.as_int(x))
|
||||
}
|
||||
}
|
||||
9
packages/ludic.settings/index.ludic
Normal file
9
packages/ludic.settings/index.ludic
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# ludic.settings - the settings a game declares as data (a registry of SettingDef), read and
|
||||
# written only through settings_get_* / settings_set_*, each change a SettingChanged fact
|
||||
module ludic_settings uses ludic_base
|
||||
numbers float
|
||||
import "ludic.base"
|
||||
import "defs.ludic"
|
||||
import "store.ludic"
|
||||
import "rules.ludic"
|
||||
import "file.ludic"
|
||||
5
packages/ludic.settings/package.ludic
Normal file
5
packages/ludic.settings/package.ludic
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# ludic.settings - a game's settings as data: one store, one owner, a fact per change, held to
|
||||
# their ranges, a safe set for a rescue start, and a file by key. Uses ludic.base. See README.md.
|
||||
package "ludic.settings"
|
||||
version "0.1.0"
|
||||
kind source
|
||||
35
packages/ludic.settings/rules.ludic
Normal file
35
packages/ludic.settings/rules.ludic
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# ludic.settings/rules.ludic - the whole store at once: every setting to its default, every one
|
||||
# held to its range, and the rescue set. None of these is news: whoever calls them applies it all
|
||||
export function settings_reset_all() -> void {
|
||||
sg_init()
|
||||
for i in 0 .. len(sg_defs) {
|
||||
sg_num[i] = sg_defs[i].num
|
||||
sg_text[i] = sg_defs[i].text
|
||||
}
|
||||
}
|
||||
|
||||
# a hand-edited file, or one from a build with more choices, never hands the game a value it has
|
||||
# no case for
|
||||
export function settings_clamp_all() -> void {
|
||||
sg_init()
|
||||
for i in 0 .. len(sg_defs) { sg_num[i] = settings_held(i, sg_num[i]) }
|
||||
}
|
||||
|
||||
# the values every machine can run: each setting marked `rescue` back to its default, and the
|
||||
# rest (keys, language, volume) left the player's
|
||||
export function settings_rescue() -> void {
|
||||
sg_init()
|
||||
for i in 0 .. len(sg_defs) {
|
||||
if sg_defs[i].rescue {
|
||||
sg_num[i] = sg_defs[i].num
|
||||
sg_text[i] = sg_defs[i].text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# whether a setting is on the rescue list at its safe value
|
||||
export function settings_is_safe(id: int) -> bool {
|
||||
let d = sg_defs[id]
|
||||
if not d.rescue { return true }
|
||||
return sg_num[id] == d.num and sg_text[id] == d.text
|
||||
}
|
||||
93
packages/ludic.settings/store.ludic
Normal file
93
packages/ludic.settings/store.ludic
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# ludic.settings/store.ludic - the values, by id (a setting's place in the order it was added),
|
||||
# and the verbs: nothing else holds a setting, and every set that changes one is a fact
|
||||
var sg_defs: []SettingDef = null
|
||||
var sg_num: []float = null
|
||||
var sg_text: []string = null
|
||||
var sg_changes: Queue<SettingChanged> = null
|
||||
|
||||
function sg_init() -> void {
|
||||
if sg_defs != null { return }
|
||||
sg_defs = new []SettingDef
|
||||
sg_num = new []float
|
||||
sg_text = new []string
|
||||
}
|
||||
|
||||
export function settings_changes() -> Queue<SettingChanged> {
|
||||
if sg_changes == null { sg_changes = queue_new("settings.changes") }
|
||||
return sg_changes
|
||||
}
|
||||
|
||||
# forget every setting (a test, or a game that declares its table again)
|
||||
export function settings_clear() -> void {
|
||||
sg_defs = new []SettingDef
|
||||
sg_num = new []float
|
||||
sg_text = new []string
|
||||
q_clear(settings_changes())
|
||||
}
|
||||
|
||||
# a setting, at its default; its id is its place in the order added
|
||||
export function settings_add(d: SettingDef) -> int {
|
||||
sg_init()
|
||||
push(sg_defs, d)
|
||||
push(sg_num, d.num)
|
||||
push(sg_text, d.text)
|
||||
return len(sg_defs) - 1
|
||||
}
|
||||
|
||||
export function settings_count() -> int {
|
||||
sg_init()
|
||||
return len(sg_defs)
|
||||
}
|
||||
|
||||
export function settings_def(id: int) -> SettingDef { return sg_defs[id] }
|
||||
|
||||
# the id of `key`, or -1
|
||||
export function settings_id_of(key: string) -> int {
|
||||
sg_init()
|
||||
for i in 0 .. len(sg_defs) { if sg_defs[i].key == key { return i } }
|
||||
return -1
|
||||
}
|
||||
|
||||
export function settings_get_float(id: int) -> float { return sg_num[id] }
|
||||
export function settings_get_int(id: int) -> int { return int(sg_num[id]) }
|
||||
export function settings_get_bool(id: int) -> bool { return sg_num[id] != 0.0 }
|
||||
export function settings_get_text(id: int) -> string { return sg_text[id] }
|
||||
|
||||
function sg_changed(id: int) -> void {
|
||||
let c = new SettingChanged
|
||||
c.id = id
|
||||
c.key = sg_defs[id].key
|
||||
q_push(settings_changes(), c)
|
||||
}
|
||||
|
||||
# a value outside the setting's range is its default: a damaged file never reaches the game
|
||||
export function settings_set_float(id: int, v: float) -> void {
|
||||
let h = settings_held(id, v)
|
||||
if h == sg_num[id] { return }
|
||||
sg_num[id] = h
|
||||
sg_changed(id)
|
||||
}
|
||||
|
||||
export function settings_set_int(id: int, v: int) -> void { settings_set_float(id, float(v)) }
|
||||
|
||||
export function settings_set_bool(id: int, v: bool) -> void {
|
||||
if v { settings_set_float(id, 1.0) } else { settings_set_float(id, 0.0) }
|
||||
}
|
||||
|
||||
export function settings_set_text(id: int, v: string) -> void {
|
||||
if v == sg_text[id] { return }
|
||||
sg_text[id] = v
|
||||
sg_changed(id)
|
||||
}
|
||||
|
||||
# `v` held to the setting's range: outside it, the default
|
||||
export function settings_held(id: int, v: float) -> float {
|
||||
let d = sg_defs[id]
|
||||
var x = v
|
||||
if d.kind == SETTING_BOOL {
|
||||
if x != 0.0 { x = 1.0 }
|
||||
}
|
||||
if d.kind == SETTING_INT { x = float(int(Math.floor(x + 0.5))) }
|
||||
if d.hi >= d.lo and (x < d.lo or x > d.hi) { return d.num }
|
||||
return x
|
||||
}
|
||||
75
packages/ludic.settings/tests/settings_test.ludic
Normal file
75
packages/ludic.settings/tests/settings_test.ludic
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# settings_test.ludic - a registry of settings as data: defaults, typed reads, a fact per change,
|
||||
# ranges, the rescue set, and the file by key
|
||||
import "ludic.settings"
|
||||
import "ludic.base"
|
||||
program SettingsTest {
|
||||
numbers float
|
||||
registry Toy of SettingDef as TOY from "packages/ludic.settings/tests/toy.lres"
|
||||
|
||||
function declare() -> void {
|
||||
settings_clear()
|
||||
for i in 0 .. TOY_COUNT { settings_add(Toy[i]) }
|
||||
}
|
||||
|
||||
function facts() -> []SettingChanged { return q_drain(settings_changes()) }
|
||||
|
||||
test "a registry declares the store, each at its default" {
|
||||
declare()
|
||||
expect_eq(settings_count(), 5)
|
||||
expect(settings_get_bool(TOY_BLOOM))
|
||||
expect_eq(settings_get_int(TOY_SHADOW_Q), 2)
|
||||
expect(settings_get_float(TOY_RSCALE) > 0.77)
|
||||
expect(settings_get_text(TOY_LANG) == "en")
|
||||
expect_eq(settings_id_of("rscale"), TOY_RSCALE)
|
||||
expect_eq(settings_id_of("nothing"), -1)
|
||||
}
|
||||
|
||||
test "a change is a fact, and setting what is there is none" {
|
||||
declare()
|
||||
settings_set_int(TOY_SHADOW_Q, 3)
|
||||
settings_set_int(TOY_SHADOW_Q, 3)
|
||||
settings_set_text(TOY_LANG, "tr")
|
||||
let fs = facts()
|
||||
expect_eq(len(fs), 2)
|
||||
expect(fs[0].key == "shadow_q")
|
||||
expect_eq(fs[1].id, TOY_LANG)
|
||||
}
|
||||
|
||||
test "a value out of range is the default" {
|
||||
declare()
|
||||
settings_set_int(TOY_SHADOW_Q, 9)
|
||||
expect_eq(settings_get_int(TOY_SHADOW_Q), 2)
|
||||
settings_set_float(TOY_RSCALE, 0.2)
|
||||
expect(settings_get_float(TOY_RSCALE) > 0.77)
|
||||
}
|
||||
|
||||
test "a rescue puts back only what it lists" {
|
||||
declare()
|
||||
settings_set_int(TOY_SHADOW_Q, 0)
|
||||
settings_set_bool(TOY_BLOOM, false)
|
||||
expect(not settings_is_safe(TOY_SHADOW_Q))
|
||||
settings_rescue()
|
||||
expect_eq(settings_get_int(TOY_SHADOW_Q), 2)
|
||||
expect(not settings_get_bool(TOY_BLOOM))
|
||||
}
|
||||
|
||||
test "the file is by key, and what it lacks or cannot hold keeps the store's" {
|
||||
declare()
|
||||
settings_set_float(TOY_RSCALE, 0.9)
|
||||
settings_set_text(TOY_LANG, "tr")
|
||||
settings_set_bool(TOY_HELD, true)
|
||||
let text = Json.encode(settings_to_json())
|
||||
declare()
|
||||
settings_set_bool(TOY_BLOOM, false)
|
||||
let v = Json.parse(text)
|
||||
Value.put(v, "shadow_q", Value.int(7))
|
||||
Value.put(v, "stranger", Value.int(1))
|
||||
settings_from_json(v)
|
||||
expect(settings_get_float(TOY_RSCALE) > 0.89)
|
||||
expect(settings_get_text(TOY_LANG) == "tr")
|
||||
expect_eq(settings_get_int(TOY_SHADOW_Q), 2)
|
||||
expect(settings_get_bool(TOY_BLOOM))
|
||||
expect(not settings_get_bool(TOY_HELD))
|
||||
expect(Value.has(settings_to_json(), "held") == 0)
|
||||
}
|
||||
}
|
||||
6
packages/ludic.settings/tests/toy.lres
Normal file
6
packages/ludic.settings/tests/toy.lres
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# toy.lres - a game's settings for settings_test
|
||||
bloom { kind: SETTING_BOOL, num: 1.0 }
|
||||
shadow_q { kind: SETTING_INT, num: 2.0, lo: 0.0, hi: 3.0, rescue: true }
|
||||
rscale { kind: SETTING_FLOAT, num: 0.78, lo: 0.5, hi: 1.0, rescue: true }
|
||||
lang { kind: SETTING_TEXT, text: "en" }
|
||||
held { kind: SETTING_BOOL, saved: false }
|
||||
Loading…
Add table
Add a link
Reference in a new issue