@adatechnology/game-interactables 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anderson Fernandes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,203 @@
1
- # Temporary Holding Version
1
+ # @adatechnology/game-interactables
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Renderer-agnostic runtime for interactive world objects: movable parts (doors, drawers, lids, knobs), containers with pickable items and
4
+ deterministic loot, a logical power grid (meter, circuits, sockets, switches, devices), fuel (gas bottles, charcoal), a logical water network,
5
+ seats and beds, soft volumes that sway instead of blocking, effect emitters, mirrors, colliders and damage.
6
+
7
+ The package draws nothing. Content packs describe objects as `ObjectSpec` data (see `src/types.ts`), the host adds them to a `World`, calls
8
+ `update(dt)` every frame and draws whatever the world reports (part poses, active emitters, mirrors, colliders, sway). `three` is not required.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pnpm add @adatechnology/game-interactables
14
+ ```
15
+
16
+ Browser without a bundler (the global is `GameInteractables`):
17
+
18
+ ```html
19
+ <script src="https://unpkg.com/@adatechnology/game-interactables/dist/index.global.js"></script>
20
+ <script>
21
+ const world = GameInteractables.createWorld({ seed: 'level-1' })
22
+ </script>
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```ts
28
+ import { createWorld, poseOf, type ObjectSpec } from '@adatechnology/game-interactables'
29
+
30
+ const world = createWorld({
31
+ locale: 'en',
32
+ seed: 'level-1',
33
+ items: { can: { id: 'can', category: 'food', size: [0.07, 0.11, 0.07], weight: 0.4, stack: 6 } },
34
+ loot: { pantry: { fill: 0.7, entries: [{ type: 'can', weight: 1, qty: [1, 3] }] } },
35
+ strings: { en: { 'prompt.door.closed': 'Open {name}', 'prompt.door.open': 'Close {name}', 'type.cupboard': 'cupboard' } },
36
+ onEvent: (e) => console.log(e),
37
+ })
38
+
39
+ const cupboard: ObjectSpec = {
40
+ type: 'cupboard',
41
+ parts: [{ id: 'door', pivot: [-0.4, 0, 0.3], motion: { kind: 'hinge', angle: -Math.PI / 2 }, sweep: [-0.4, 0.3, 0.4, 1.1], label: 'prompt.door' }],
42
+ containers: [{ id: 'main', access: 'door', volume: { center: [0, 0.5, 0], half: [0.4, 0.4, 0.25] }, slots: [{ pos: [0, 0.1, 0], max: [0.3, 0.3, 0.3] }], loot: 'pantry' }],
43
+ }
44
+ world.add('kitchen_cupboard', cupboard, { x: 2, y: 0, z: -1, ry: Math.PI })
45
+
46
+ // once, after placing objects next to walls: the door never opens through them
47
+ world.fitSweep('kitchen_cupboard', 'door', (x, z) => isWall(x, z))
48
+
49
+ // every frame
50
+ world.update(dt)
51
+ const look = world.target({ x: cam.x, y: cam.y, z: cam.z, yaw, pitch }) // best interaction or null
52
+ if (look) hud.show(look.label) // "Open cupboard"
53
+ if (pressedUse && look) look.act()
54
+
55
+ // drawing a part: pose is relative to the part's pivot
56
+ for (const o of world.objects()) {
57
+ for (const p of o.spec.parts ?? []) {
58
+ const pose = poseOf(p.motion, world.partT(o.id, p.id)) // { position, rotation: { axis, angle }, scale }
59
+ }
60
+ }
61
+ ```
62
+
63
+ Coordinates in a spec are local to the object: front is `+z`, origin at the center of the footprint on the floor, meters. `Transform.ry` rotates
64
+ around `+y` like `three.js` (`+z` turns toward `+x` for a positive angle). View `yaw` follows the camera convention: yaw 0 looks toward `-z`.
65
+
66
+ ## Behaviour
67
+
68
+ - **Parts** animate toward their target at `motion.speed` (default 2.5 t/s). `requires` keeps a part from opening while another one is not open and closes it
69
+ with that one (a drawer behind a door closes with the door). `fitSweep(object, part, blocked)` samples the swept `sweep` rectangle (rotating the leaf for hinges, translating for slides),
70
+ returns the largest safe `t` (when a part declares `bounds`, the whole closed-pose slab is moved through the motion, so leaf thickness, hinge offset and horizontal hinge axes count) and clamps the part to it for good. A part counts as open from `t >= 0.5` (or 90% of its clamped limit).
71
+ Parts that share a `group` move together: `setPart` or a toggle on any member sets the target of all (blind slats, curtain panels).
72
+ - **Default interactions**: when a spec has no `interactions`, the world derives them. Labelled parts give `toggle_part`; containers without an
73
+ access part give `open_container`; devices, switches, fixtures, burners (and `ignite` for `needsIgnition`), gas valves and seats get the
74
+ matching action. Explicit `interactions` replace the derived ones. Ids are `part.<id>`, `open.<container>`, `device.<id>`, `channel.<id>`,
75
+ `switch.<id>`, `fixture.<id>`, `burner.<id>`, `ignite.<id>`, `fuel.<id>`, `seat.<id>`; world-wide `object:interactionId`.
76
+ - **Labels**: the world looks up `<label>.open|.closed` (parts, valves, containers) or `<label>.on|.off` (devices, fixtures, burners,
77
+ switches), then `<label>`, then an engine default (`prompt.open`, `prompt.turn_on`, ...). `{name}` is replaced by `type.<type>` or a humanized
78
+ type id. Labels never carry a key hint such as "E:"; the host adds its own.
79
+ - **Structured prompts**: every `Interactable` also has `verb` ("Open") and `noun` ("the fridge") so a host can style them separately
80
+ (`object` is the owning object's id). Entries `<key>.verb` / `<key>.object` in the locale win; otherwise the first word of the label is the verb,
81
+ the rest the noun, and a one-word prompt uses the object's type name as the noun.
82
+ - **Availability**: `Interactable.available` is false with a `reason` (a locale key: `requires_open`, `no_power`, `locked` when `canInteract`
83
+ refuses, `already_lit`, `needs_ignition`, `turn_on_first`; `world.t(reason)` is the text) while the interaction cannot be used.
84
+ - **Targeting**: `target(eye, { reach = 1.8, cone = 0.45, filter?, kinds?, includeUnavailable? })` scores by distance and angle to the view
85
+ direction. Unavailable interactions (closed container `take`/`read`, the wrong half of `ignite`, a part gated by `requires`...) are skipped
86
+ unless `includeUnavailable`; `kinds` limits the action kinds and `filter` is any predicate. A hard-wired lamp (device `kind: 'lamp'` with a
87
+ `circuit`, no `plug`, no `part`) gets **no** derived interaction: switches and `setDevice` drive it, so it never steals the interact key.
88
+ - **Driving state from code**: `setDevice(object, device, on)`, `setFixture(object, fixture, on)` and `setBurner(object, burner, on)` do what
89
+ the toggles do (parts, `device`/`fixture`/`burner` events, `no_power` message) and are quiet when the state does not change.
90
+ - **Colliders**: `colliders({ merge: true, gap = 0.02, slack = 0.1 })` unions overlapping or adjacent fixed boxes of the same object (same
91
+ rotation) when the merged box adds at most `slack` extra volume; door colliders stay separate. `mergeColliders(list, opts)` is exported for
92
+ lists you already have.
93
+ - **Containers**: contents from `contents` or deterministic loot seeded with `seed + object id + container id`. An item fits a slot when its size
94
+ is within the slot's `max` (width and depth may swap). `take` needs the container to be accessible and the item pickable and emits `pickup`.
95
+ - **Power**: `grid`, a breaker per circuit (default on), sockets (optionally on a circuit) and devices plugged to a socket (`auto` = nearest free
96
+ socket within 2 m, across the whole world) or hard-wired to a circuit. `powered = grid && breaker && socket found`. A device that is on but
97
+ unpowered stays on, reports `powered: false` and emits the `no_power` message. Switches toggle the listed devices (`object:device` or local ids).
98
+ The `toggle_breaker` action (`{ do: 'toggle_breaker', circuit, part? }`) flips a circuit from a breaker panel, mirrors the optional rocker `part`
99
+ (t = 1 when on; refused while that part is gated by `PartSpec.requires`, e.g. a closed panel door) and uses the prompts `prompt.breaker_on|off` (`{circuit}` is the action's `name` or the circuit id).
100
+ - **Fuel**: burners need fuel and, for a bottle with a valve, the valve open. `fuel: 'auto'` = nearest gas source within 2 m. Gas is consumed
101
+ while lit; running out or closing the valve puts the flame out, and a gas knob left on without gas lights by itself when the valve opens. Charcoal needs `ignite`, burns until empty, and `toggle_burner` puts it out.
102
+ Smoke emitters of solid fuel linger 20 s after the fire is out, fading.
103
+ - **Water**: tanks (`capacity`, `level`, `rainArea`), a default tank, fixtures that flow while on and supplied. With `options.waterSupply`, fixtures
104
+ draw through it (`flow / 60 * dt` liters, a flush draws `volume` once). Dry fixtures stay "on" but report `flowing: false`.
105
+ A device with `fixture: '<fixtureId>'` (same object, e.g. a washing machine inlet) drives that fixture: it flows while the device is on and
106
+ powered, stops when it is switched off or loses power, and gets no interaction of its own.
107
+ - **Gated interactions**: `InteractionSpec.requires = { object, part }` (world-wide object id) makes an interaction available only while that
108
+ part is open (`t >= 0.5`): `target` skips it and `act` refuses it otherwise (a gas valve behind a cabinet door).
109
+ - **Seats**: one actor per seat. `sit` returns the world eye position and yaw, `stand` returns the world exit point.
110
+ - **Soft volumes** never block and are never colliders. `push(pos, radius, vel)` swings them; they settle back by a damped spring. Wind adds a
111
+ small oscillating force. A `panel` with a `part` opens when pushed from the front.
112
+ - **Damage**: `hp` per target. A destroyed gas source with fuel explodes (`radius = 2 + level / 5`) and empties; a damaged tank leaks once and
113
+ drains; glass is just an event.
114
+ - **Save/load**: `serialize()` is plain JSON; `load()` restores it onto a world that has the same objects added. Swept limits (`fitSweep`) are
115
+ not saved, call it again after loading.
116
+
117
+ ## API
118
+
119
+ ```ts
120
+ createWorld(options?: WorldOptions): World
121
+ ```
122
+
123
+ `WorldOptions`: `locale`, `strings`, `items`, `loot`, `seed`, `grid`, `rain`, `wind`, `waterSupply`, `onEvent`, `canInteract`.
124
+
125
+ | Group | Members |
126
+ |---|---|
127
+ | Objects | `add(id, spec, transform)`, `remove(id)`, `get(id)`, `objects()` |
128
+ | Frame | `update(dt)` |
129
+ | Interaction | `interactables()`, `target(eye, TargetOptions)`, `act(id, actor?)` |
130
+ | Parts | `partT`, `setPart(object, part, target, instant?)`, `fitSweep(object, part, blocked)` |
131
+ | Drive | `setDevice(object, device, on)`, `setFixture(object, fixture, on)`, `setBurner(object, burner, on)` |
132
+ | Containers | `contents`, `accessible`, `take`, `put`, `slotPosition` |
133
+ | Power | `power.grid`, `setGrid`, `setBreaker`, `breaker`, `isPowered`, `isOn`, `pluggedInto`, `channel` |
134
+ | Fuel | `fuelLevel(object, fuel)`, `isLit(object, burner)` |
135
+ | Water | `water.setDefaultTank`, `level`, `refill`, `setRain`, `isFlowing` |
136
+ | Seats | `sit`, `stand`, `seatOf` |
137
+ | Soft | `push`, `sway`, `setWind` |
138
+ | Draw | `emitters()`, `mirrors(eye, maxDistance?, count?)`, `colliders({ merge? })` |
139
+ | Damage | `damage(object, target, amount, kind?)`, `hp(object, target)` |
140
+ | Text | `t(key, vars?)`, `setLocale(locale)` |
141
+ | Save | `serialize()`, `load(state)` |
142
+
143
+ Standalone helpers: `rng(seed)`, `generateLoot(tables, tableId, slots, seed, items)`, `poseOf(motion, t)`, `localToWorld(transform, v)`,
144
+ `worldToLocal`, `rotY`, `rotateAxis`, `fitsSize`, `fitsSlot`, `resolveStrings(locale, extra)`, `locales`, `format`, `pickLocale`.
145
+
146
+ `poseOf(motion, t)` returns `{ position, rotation: { axis, angle }, scale }` relative to the part's pivot: hinge, rocker and knob rotate,
147
+ slide translates along `axis * distance * t`, scale scales along `axis`.
148
+
149
+ ## Events
150
+
151
+ `onEvent` receives every change:
152
+
153
+ | `type` | Fields |
154
+ |---|---|
155
+ | `part` | `object`, `part`, `t`, `target` (on a new target and again on arrival) |
156
+ | `pickup` | `object`, `container`, `slot`, `stack`, `actor` |
157
+ | `device` | `object`, `device`, `on`, `powered`, `channel?` |
158
+ | `switch` | `object`, `switch`, `on` |
159
+ | `fixture` | `object`, `fixture`, `on`, `flowing` |
160
+ | `burner` | `object`, `burner`, `on`, `lit` |
161
+ | `seat` | `object`, `seat`, `actor`, `seated` |
162
+ | `damage` | `object`, `target`, `hp`, `destroyed` |
163
+ | `explosion` | `object`, `pos`, `radius` |
164
+ | `leak` | `object`, `target`, `pos` |
165
+ | `message` | `key`, `text` (localized): `no_power`, `no_gas`, `no_water`, `locked`, `empty`, `full`, or an item text key from a `read` |
166
+ | `power` | `grid` |
167
+ | `container` | `object`, `container`, `open` (an open shelf was "opened") |
168
+
169
+ ## Locales
170
+
171
+ Engine-level strings exist in `en` and `pt-BR`: `prompt.open`, `prompt.close`, `prompt.turn_on`, `prompt.turn_off`, `prompt.take`, `prompt.sit`,
172
+ `prompt.lie_down`, `prompt.get_up`, `prompt.light`, `prompt.change_channel`, `prompt.read`, `prompt.open_valve`, `prompt.close_valve` and the
173
+ messages above. Content packs add their keys through `options.strings`; unknown keys fall back to English, then to the key itself.
174
+
175
+ ## Destruction
176
+
177
+ Specs carry tags for the shared destruction SDK, `@adatechnology/game-destruction`; this package does not depend on it and only stores plain ids.
178
+
179
+ - `PartSpec.destruction`: `{ material, joint?, piece?, explodes?, contains? }`. `material` is an SDK material id (`steel`, `mdf`, `glass`...), `joint` an SDK joint kind (`screw`, `bolt`, `nail`, `weld`, `glue`, `hinge`, `clip`, `tie`, `mortar`, `fused`, `stem`), `piece` what the part is (`door`, `drawer`, `lid`...). `explodes` and `contains` have the same shape as in the SDK's `PartSpec`.
180
+ - `ObjectSpec.destruction`: the same tags for the static body (everything that is not a movable part).
181
+
182
+ The host builds SDK parts from the tags and routes hits, fire and blasts to the SDK. `DamageSpec` and `world.damage` are deprecated: they keep working, but get no new features.
183
+
184
+ ## Português
185
+
186
+ Runtime sem renderer para objetos interativos do mundo: portas, gavetas e tampas que se movem, recipientes com itens e loot determinístico por
187
+ semente, rede elétrica lógica (medidor, circuitos, tomadas, interruptores, aparelhos), combustível (botijão de gás, carvão), rede de água lógica,
188
+ assentos e camas, volumes macios (cortinas, folhagem) que balançam sem bloquear, emissores de efeitos, espelhos, colisores e dano.
189
+
190
+ ```ts
191
+ const world = createWorld({ locale: 'pt-BR', seed: 'fase-1' })
192
+ world.add('armario', especificacao, { x: 2, y: 0, z: -1, ry: Math.PI })
193
+ world.update(dt)
194
+ const alvo = world.target({ x, y, z, yaw, pitch }) // ex.: "Abrir armário"
195
+ alvo?.act()
196
+ ```
197
+
198
+ O pacote não desenha nada: você informa o `ObjectSpec` (dados), chama `update(dt)` a cada quadro e desenha o que o mundo reportar. Os textos do
199
+ motor (`Abrir`, `Ligar`, `Sem energia`, `Sem gás`, `Sem água`...) vêm em `pt-BR`; os pacotes de conteúdo acrescentam as suas chaves em `strings`.
200
+
201
+ ## Destruction delegate
202
+
203
+ Pass `destruction: { hitPart }` (the shape of `DestructionWorld.hitPart` from `@adatechnology/game-destruction`, no dependency on it) to `createWorld` and `world.damage` forwards to it as a `blunt` (`explosive` for `gas`, `slash` for `fabric`) hit with `energy: amount`; the world stops its own hp bookkeeping and `world.hp` returns `null`. Without it the deprecated `DamageSpec` behaviour is unchanged.