171 lines
11 KiB
Markdown
171 lines
11 KiB
Markdown
# Game architecture — findings
|
|||
|
|
|
||
|
|
Target: *Sulphur Memories: Alchemist* 0.4.2 "sanctified", LÖVE 11.5, Lua 5.1/LuaJIT.
|
||
|
|
|
||
|
|
## 1. Top-level layout
|
||
|
|
|
||
|
|
| Path | Role |
|
||
|
|
|---|---|
|
||
|
|
| `main.lua` | `love.load`, requires everything, runs `preprocess*()` for all templates, switches to `main_menu`. |
|
||
|
|
| `states.lua` | Tiny state machine. **The single most important hook point.** |
|
||
|
|
| `states/*.lua` (28 files) | One file per screen. Each returns a table of LÖVE callbacks. |
|
||
|
|
| `modules/` | Reusable objects: `console`, `level`, `tile`, `item`, popups. |
|
||
|
|
| `models/` | Game data + singletons: `world`, `player`, `keybindings`, `config`, `journal`, `actor`. |
|
||
|
|
| `views/` | Pure drawing: `levelWindow`, `inventoryWindow`, `*CursorPopup`, `hotbar`, `hpbar`. |
|
||
|
|
| `actions/`, `animations/`, `generators/`, `shaders/` | Gameplay verbs, tweens, map gen, GLSL. |
|
||
|
|
|
||
|
|
Global variables (declared in `.luacheckrc`): `width`, `height`, `tileSize`, `UIScale`, `TEsound`, and all the font objects (`mediumfont`, `smallfont`, …). No module system beyond `require`, so **every module is a singleton in `package.loaded`** — this is what makes monkey-patching viable.
|
||
|
|
|
||
|
|
## 2. `states.lua` — the master hook
|
||
|
|
|
||
|
|
```lua
|
||
|
|
function states.setup()
|
||
|
|
for _,callback in pairs(love2dCallbacksList) do
|
||
|
|
states[callback] = function(...) stateFiles[currentState][callback](unpack({...})) end
|
||
|
|
love[callback] = love[callback] or states[callback]
|
||
|
|
end
|
||
|
|
end
|
||
|
|
function states.switch(newState, params) ... end
|
||
|
|
function states.getCurrent() ... end
|
||
|
|
```
|
||
|
|
|
||
|
|
Every LÖVE callback (`keypressed`, `draw`, `update`, `mousepressed`, …) funnels through `states[callback]`. State files are lazily `require`d into `stateFiles[stateName]` on first switch.
|
||
|
|
|
||
|
|
Consequences:
|
||
|
|
- Wrapping `states.switch` gives us a **screen-change event** for free (announce the new screen).
|
||
|
|
- Wrapping `states.keypressed` gives us a **global hotkey layer** that can consume a key before the game sees it, in any screen.
|
||
|
|
- Because state tables are plain Lua tables held in `stateFiles`, we can wrap individual state callbacks *after* they are loaded.
|
||
|
|
|
||
|
|
## 3. Console — already a single chokepoint
|
||
|
|
|
||
|
|
`modules/console.lua` exposes exactly:
|
||
|
|
|
||
|
|
```lua
|
||
|
|
console.print(message, colorTemplate) -- colorTemplate is "flavor" or nil (combat/positive/... are commented out)
|
||
|
|
console.clear()
|
||
|
|
console.draw() / console.update(dt) / console.prepare() / console.refreshOutput()
|
||
|
|
```
|
||
|
|
|
||
|
|
`buffer` is a file-local list of `{message, times, textHeight, lifetime, colorTemplate}`. Repeated identical messages **coalesce** into one entry with `times` incremented — the drawn text becomes `"msg (x3)"`.
|
||
|
|
|
||
|
|
Lines expire (`lifetime = 3` s) and scroll away, so the visual buffer is not a usable history. **We need our own unbounded history list**, appended from a wrapper around `console.print`.
|
||
|
|
|
||
|
|
Grepping shows `console.print` is called from `models/actor.lua`, `modules/tile/templates.lua`, `actions/*`, etc. — all of them go through this one function. Nothing else writes game text to the log.
|
||
|
|
|
||
|
|
## 4. The level model
|
||
|
|
|
||
|
|
`modules/level.lua` (1807 lines) — `newLevel(params)` returns a closure table `self`. Relevant public surface:
|
||
|
|
|
||
|
|
**Data**
|
||
|
|
- `self.grid[x][y]` → array (stack) of tile objects, bottom-to-top. Top-most non-hidden tile is what the player "sees".
|
||
|
|
- `self.actors` → flat array of actor objects.
|
||
|
|
- `self.FoV[x][y]`, `self.rememberedFoV[x][y]` → booleans (currently visible / previously seen).
|
||
|
|
- `self.xMax`, `self.yMax`, `self.cursorX`, `self.cursorY`, `self.playerActor`, `self.name`.
|
||
|
|
|
||
|
|
**Queries we will use**
|
||
|
|
| Function | Use for accessibility |
|
||
|
|
|---|---|
|
||
|
|
| `getCursorMessage()` | Returns the description string for the tile under the cursor (actor first, then top visible tile). **Already the exact text we want to speak.** |
|
||
|
|
| `getLevelDescriptionMessage()` | Level name + in-game datetime. |
|
||
|
|
| `getActorOnCoordinates(x,y,cond)` | Actor lookup. |
|
||
|
|
| `getItemOnCoordinates(x,y)` | Item lookup. |
|
||
|
|
| `getTile(x,y,templateId,fromBottom)` | Tile lookup. |
|
||
|
|
| `getListByTag(tag)` | **Returns `{ {x,y}, … }` of all *seen* tiles matching a tag, already sorted by distance to player.** This is the seed of the object tracker. |
|
||
|
|
| `getClosestActorOfType(x,y,templateId)`, `countActorsById`, `getAnyActorById` | Actor tracking. |
|
||
|
|
| `isVisible(x,y)`, `isNearVisible(x,y)`, `isPassable(x,y,…)`, `isWaterTile(x,y)` | Tile probing for the surroundings explorer. |
|
||
|
|
| `calculatePathFromPlayer(x,y)` | Returns a `{dx,dy}` step list — used for "how do I get there". |
|
||
|
|
| `calculateStepFromPlayer(x,y)` | Single next step. |
|
||
|
|
| `setCursor(x,y)` / `moveCursor(dx,dy,…)` / `resetCursor()` / `getCursorPosition()` | Drive the examine cursor programmatically. |
|
||
|
|
| `playerInput(cmd,x,y,state)` / `bufferInput(cmd,array,state)` | Issue player actions. |
|
||
|
|
|
||
|
|
**Important caveat:** `getListByTag` reads `templatesByTag`, and `modules/tile/templatesByTag.lua` currently defines **only one tag** (`exit`, containing `way_out`, `building_door`, `horse`, `ladder_up`, `ladder_down`). The object tracker will need its own, much richer category table — see `03-implementation-plan.md`.
|
||
|
|
|
||
|
|
## 5. Actors, tiles, items
|
||
|
|
|
||
|
|
`models/actor.lua` — actor objects carry `templateId`, `x`, `y`, `name`, `nameObserved`, `nameAsSubject`, `nameAsOwner`, `description`, `hp`, `maxhp`, `visibilityAlpha`, `isPlayerControlled`, `parent` (the level).
|
||
|
|
|
||
|
|
Template tables are large data files: `modules/tile/templates.lua` (6745 lines), `models/actor/templates.lua` (2887), `modules/item/templates.lua` (7152). They are preprocessed at load. Tiles carry flags we can categorise on: `blocksMovement`, `blocksVision`, `tryInteract`, `loot`, `unlootable`, `isHidden`, `description`, plus `templateId`.
|
||
|
|
|
||
|
|
`modules/item/categories.lua` already defines 7 player-facing item categories (All / Potions / Ingredients / Raw materials / Consumables / Tools / Texts). Reuse these names verbatim for consistency.
|
||
|
|
|
||
|
|
## 6. Keyboard vs. mouse — better than expected
|
||
|
|
|
||
|
|
**Every one of the 28 states already implements `keypressed`.** The game is a keyboard-first roguelike with mouse as an alternative, not a mouse-only game. Verified by reading `inventory`, `craft`, `ingame`, `ingame_observe`, `ingame_dialogue`, `main_menu`.
|
||
|
|
|
||
|
|
`models/keybindings.lua` is a full rebindable-keys system with **contexts** (`ingame`, `aiming`, `menu`, `menuGlobal`, `menuHotkeys`, `hotbar`, with a `parents` inheritance chain), persisted to `keybindings.json`. Public API:
|
||
|
|
|
||
|
|
```lua
|
||
|
|
keybindings.getCommand(scancode, filter) -- scancode -> command name
|
||
|
|
keybindings.scancodeToDirection(scancode, filter) -- -> dx, dy
|
||
|
|
keybindings.isCommandDown(command, filter)
|
||
|
|
keybindings.getKeyForCommand(command, filter)
|
||
|
|
keybindings.checkForConflicts(filter, newScancode)
|
||
|
|
```
|
||
|
|
|
||
|
|
So the real accessibility gap is **not** "can't reach it with the keyboard" — it is **"nothing tells you what is currently focused."** That reframes the whole job from *reimplementing UIs* to *narrating existing focus state*. Much smaller, much more mod-friendly.
|
||
|
|
|
||
|
|
### The narration chokepoint
|
||
|
|
|
||
|
|
Menu-ish states converge on a `updateCursorPopup()`-style helper that ends in:
|
||
|
|
|
||
|
|
```lua
|
||
|
|
cursorPopup.set(inventoryWindow.getCursorPopupStats()) -- craft, inventory, refine, trade
|
||
|
|
cursorPopup.set(message, x, y, alignX, alignY) -- ingame_observe (levelCursorPopup)
|
||
|
|
```
|
||
|
|
|
||
|
|
- `views/inventoryCursorPopup.lua` → `set(lines, x, y, alignX, alignY)` where `lines` is `{ {text=…, icon=…}, … }`.
|
||
|
|
- `views/levelCursorPopup.lua` → same signature, `lines` from `levelWindow.getCursorPopupStats()` which just forwards `levelModel.getCursorMessage()`.
|
||
|
|
|
||
|
|
Patching `set` on these two view modules narrates the focused item in **`inventory`, `craft`, `refine`, `trade`, `ingame_observe`** at once. Five screens, two wrappers, zero game edits.
|
||
|
|
|
||
|
|
### The states that *don't* use cursorPopup
|
||
|
|
|
||
|
|
`main_menu`, `journal`, `character`, `research`, `worldmap`, `settings_menu`, `config_menu`, `keybindings_menu`, `load_game`, `new_game`, `actions_menu`, `alchemy_table`, `ingame_dialogue`, `menu_text`, `narration`, `ingame_message`.
|
||
|
|
|
||
|
|
These hold focus in **file-local variables** — typically `selectedOption`, plus `optionsList` / `options` / `activeTabIndex` / `selectedPage`. Example, `states/main_menu.lua`:
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local selectedOption
|
||
|
|
local optionsList = { {id=1, title="Continue", isEnabledFunction=…, callback=…}, … }
|
||
|
|
```
|
||
|
|
|
||
|
|
File-locals are not reachable through the returned state table — **but they are upvalues of the state's own functions**, and `debug.getupvalue` reaches them. Verified working on this Lua:
|
||
|
|
|
||
|
|
```
|
||
|
|
1 selectedOption 3
|
||
|
|
2 optionsList <table>
|
||
|
|
```
|
||
|
|
|
||
|
|
So we can read (and even `debug.setupvalue`) menu focus **without editing a single game file**. See `02-hooking-strategy.md` for how far to push this.
|
||
|
|
|
||
|
|
## 7. Popups and other text surfaces
|
||
|
|
|
||
|
|
- `views/popupStack.lua` + `modules/{loot,recipe,lore,map,journal,tutorial,bestiary}Popup.lua` — transient popups pushed via `popupStack.add{…}`. `states/ingame.lua` exposes `ingame.lootPopup`, `ingame.recipePopup`, `ingame.lorePopup`, `ingame.mapPopup`, `ingame.journalPopup`, `ingame.tutorialPopup`, `ingame.displayMessage` — all good wrap points.
|
||
|
|
- `views/descriptionWindow.lua` — the persistent level/status panel.
|
||
|
|
- `views/hpbar.lua`, `views/hotbar.lua`, `views/statusGrid.lua` — player status, needs an explicit "read my status" key.
|
||
|
|
- `states/ingame_message.lua`, `states/menu_message.lua`, `states/menu_text.lua`, `states/narration.lua` — full-screen text.
|
||
|
|
|
||
|
|
## 8. TTS backend — `d:\code\love2talk`
|
||
|
|
|
||
|
|
```lua
|
||
|
|
tts = require "Love2talk"
|
||
|
|
tts.say(text, interrupt) -- interrupt defaults to false
|
||
|
|
tts.isSpeaking() -- unreliable under screen readers
|
||
|
|
```
|
||
|
|
|
||
|
|
Windows path uses `tolk.lua` → `ffi.load("Tolk")` → Tolk.dll, with `trySAPI(true)` so it falls back to SAPI 5 when no screen reader is running. `encoding.lua` converts to UTF-16.
|
||
|
|
|
||
|
|
**Deployment constraint:** `ffi.load("Tolk")` resolves through the OS loader, so `Tolk.dll` and its screen-reader client DLLs (`nvdaControllerClient64.dll`, `SAAPI64.dll`, …) must sit **next to `love.exe`** (or in a directory on `PATH`) — *not* inside the `.love` archive, and not inside the game source folder unless the game is launched with that folder as CWD. Copies of these DLLs already exist elsewhere on this machine (`D:\code\cafe`, `D:\code\dragonrpg-mapmaker`, `D:\code\DragonWarriorAccess`). This is an open question — see `04-open-questions.md` Q1.
|
||
|
|
|
||
|
|
Also note `Love2talk.lua` assigns `backend` as a **global**, and `tolk.lua` calls `Tolk_Load()` at require time with no error handling. We should wrap the require in `pcall` so a missing DLL degrades to silence rather than crashing the game.
|
||
|
|
|
||
|
|
## 9. Existing accessibility assets
|
||
|
|
|
||
|
|
`accessibility/assets/walltones/` — `walltone_{north,south,east,west}.wav`. Directional wall tones, presumably for the surroundings explorer. LÖVE + the game's `TEsound` wrapper (`tesound.lua`) can play these; `love.audio.setDistanceModel("inverse")` is already set in `main.lua` and `TEsound.playPositioned` exists (used for positional SFX), so spatialised cues are available if we want them.
|
||
|
|
|
||
|
|
## 10. Build / distribution
|
||
|
|
|
||
|
|
`build.lua` is a `love-build` config: name `sulphur-memories-alchemist`, version `0.4.2`, LÖVE `11.5`, output `dist`, with an `ignore` list. Adding `accessibility/` to the game is compatible with this build path. LÖVE 11.5 is installed locally at `C:\Program Files\LOVE\love.exe` (and `lovec.exe`, which gives a console window — useful for debugging).
|
||
|
|
|
||
|
|
`.luacheckrc` uses `std = "min+alch"` with a custom globals list; new accessibility globals would need adding there if we want lint to stay clean.
|