@momo2555/koppeliajs 0.0.163 â 0.0.165
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/README.md +419 -61
- package/dist/scripts/device.d.ts +11 -4
- package/dist/scripts/device.js +37 -4
- package/dist/scripts/koppelia.d.ts +5 -0
- package/dist/scripts/koppelia.js +25 -2
- package/dist/scripts/stage.d.ts +2 -0
- package/dist/scripts/stage.js +8 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,110 +1,468 @@
|
|
|
1
|
-
#
|
|
1
|
+
# KoppeliaJS SDK đŽ
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**The official SvelteKit SDK for the Koppelia Console: Building accessible, real-time, zero-overstimulation games for seniors.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
KoppeliaJS is a specialized framework designed to bridge the gap between game development and cognitive accessibility. It allows developers to create synchronized, asymmetric, multi-screen experiences tailored for retirement homes and senior care facilities. By leveraging SvelteKit, WebSockets, and our Filarmonic Master Server, KoppeliaJS synchronizes UI, shared state, and physical IoT hardware seamlessly.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
---
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## đ The Koppelia Ecosystem (Context)
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
# create a new project in the current directory
|
|
13
|
-
npx sv create
|
|
11
|
+
Koppelia is an asymmetric gaming console designed with a strict focus on cognitive accessibility and ergonomic physical interactions.
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
### The Screens
|
|
14
|
+
* đē **The Monitor (Players' Screen):** Plugged via HDMI to a TV. It displays **only essential information**. It is intentionally stripped of complex UI elements (no cursors, no complicated menus, no over-stimulation) to prevent cognitive overload for senior players.
|
|
15
|
+
* đą **The Controller (Animator's Tablet):** Connected via Wi-Fi. This is the control center for the animator. It features the game settings, scores, real-time difficulty adjustments, and hidden mechanics. It acts as the "Dungeon Master" interface.
|
|
16
|
+
|
|
17
|
+
### The Hardware & Backend
|
|
18
|
+
* đšī¸ **The Devices:** Accessible controllers connected via BLE to the console. They feature a single luminous button, an IMU (Inertial Measurement Unit to detect orientation/motion), and a modular design (e.g., they can be clipped to an exercise bike).
|
|
19
|
+
* đ§ **Filarmonic (Master Server):** The core console server orchestrating Docker containers to spin up a dedicated web server for every game launch.
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
---
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
## đ Getting Started
|
|
24
|
+
|
|
25
|
+
### 1. Initialize a New Game Project
|
|
26
|
+
We recommend using Node.js version 20 to manage dependencies.
|
|
22
27
|
|
|
23
28
|
```bash
|
|
29
|
+
npx sv create my-game
|
|
30
|
+
# Prompts:
|
|
31
|
+
# - Add to project: prettier, eslint
|
|
32
|
+
# - SvelteKit adapter: auto
|
|
33
|
+
# - Package manager: npm
|
|
34
|
+
|
|
35
|
+
cd my-game
|
|
36
|
+
npm install
|
|
24
37
|
npm run dev
|
|
25
38
|
|
|
26
|
-
#
|
|
27
|
-
npm
|
|
39
|
+
# Install the KoppeliaJS SDK
|
|
40
|
+
npm install @momo2555/koppeliajs
|
|
28
41
|
```
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
## Building
|
|
33
|
-
|
|
34
|
-
To build your library:
|
|
43
|
+
### 2. Configure the Environment
|
|
44
|
+
Contact the administration to get your unique Game ID. Create an `.env` file at the root of your project:
|
|
35
45
|
|
|
36
|
-
```
|
|
37
|
-
|
|
46
|
+
```env
|
|
47
|
+
PUBLIC_GAME_ID=your-unique-game-id
|
|
48
|
+
PUBLIC_MOCE_ENV=dev
|
|
38
49
|
```
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
*Note: In your `package.json`, update the preview script to expose your host: `"preview": "vite preview --host 0.0.0.0"`.*
|
|
52
|
+
|
|
53
|
+
### 3. Basic Directory Structure
|
|
54
|
+
Koppelia relies on SvelteKit dynamic routing to serve both the Monitor and the Controller from the same codebase. Create the necessary files:
|
|
41
55
|
|
|
42
56
|
```bash
|
|
43
|
-
|
|
57
|
+
mkdir -p "src/routes/game/[type]/home"
|
|
58
|
+
touch "src/routes/game/[type]/home/+page.svelte"
|
|
59
|
+
touch "src/routes/+layout.svelte"
|
|
44
60
|
```
|
|
45
61
|
|
|
46
|
-
|
|
62
|
+
---
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
## đ Core Concepts & APIs
|
|
49
65
|
|
|
50
|
-
|
|
66
|
+
The KoppeliaJS SDK abstracts complex WebSocket networking and IoT Bluetooth management into simple Svelte stores and TypeScript classes. Below is a comprehensive breakdown of every system, how it works, its full capabilities, and an example.
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
### 1. Smart Routing (`routeType`)
|
|
53
69
|
|
|
54
|
-
|
|
70
|
+
#### How it works & Capabilities
|
|
71
|
+
Because your codebase serves both the TV (Monitor) and the Tablet (Controller), the SDK provides a reactive Svelte store (`routeType`) to identify the current environment context. When the application loads, `updateRoute()` parses the URL to determine if the device is at `/game/monitor/...` or `/game/controller/...`.
|
|
72
|
+
This is the cornerstone of **isomorphic game design**: you write one Svelte component, and you use `if ($routeType === 'monitor')` to strip away buttons and complex UI, ensuring the TV remains accessible and visually clean for seniors.
|
|
55
73
|
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
#### Example
|
|
75
|
+
**In `src/routes/+layout.svelte`:**
|
|
76
|
+
```svelte
|
|
77
|
+
<script>
|
|
78
|
+
import { updateRoute } from "@momo2555/koppeliajs";
|
|
79
|
+
// Parses the URL and sets the store globally
|
|
80
|
+
updateRoute();
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<slot />
|
|
58
84
|
```
|
|
59
85
|
|
|
60
|
-
|
|
61
|
-
|
|
86
|
+
**In any component:**
|
|
87
|
+
```svelte
|
|
88
|
+
<script>
|
|
89
|
+
import { routeType } from "@momo2555/koppeliajs";
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
{#if $routeType === 'monitor'}
|
|
93
|
+
<h1>Welcome Players!</h1>
|
|
94
|
+
{:else if $routeType === 'controller'}
|
|
95
|
+
<h1>Animator Dashboard</h1>
|
|
96
|
+
<button>Force Next Level</button>
|
|
97
|
+
{/if}
|
|
62
98
|
```
|
|
63
99
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
### 2. Global State Synchronization (`koppelia.state`)
|
|
67
103
|
|
|
68
|
-
|
|
104
|
+
#### How it works & Capabilities
|
|
105
|
+
The `State` is a synchronized, real-time Svelte `Writable` store shared across the entire network. It is designed for **persistent game data** (e.g., scores, current question index, timers, player names).
|
|
106
|
+
Under the hood, when you mutate the state, KoppeliaJS performs a diffing operation and broadcasts only the changes via WebSocket to all other connected screens. Because it extends a Svelte store, any UI bound to `$state` will automatically re-render when the server or another device alters the data.
|
|
69
107
|
|
|
70
|
-
|
|
108
|
+
**Available Methods:**
|
|
109
|
+
* `koppelia.updateState(partialObject)`: Merges the provided keys/values into the existing state (Recommended for performance).
|
|
110
|
+
* `koppelia.setState(fullObject)`: Completely overwrites the global state tree.
|
|
71
111
|
|
|
72
|
-
|
|
73
|
-
|
|
112
|
+
#### Example
|
|
113
|
+
```svelte
|
|
114
|
+
<script lang="ts">
|
|
115
|
+
import { Koppelia } from "@momo2555/koppeliajs";
|
|
116
|
+
|
|
117
|
+
let koppelia = Koppelia.instance;
|
|
118
|
+
let state = koppelia.state;
|
|
74
119
|
|
|
75
|
-
|
|
76
|
-
|
|
120
|
+
function awardPoints() {
|
|
121
|
+
// Broadcasts the updated score to all screens instantly
|
|
122
|
+
koppelia.updateState({
|
|
123
|
+
score: $state.score + 10,
|
|
124
|
+
lastAction: "Points Awarded!"
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
</script>
|
|
77
128
|
|
|
78
|
-
|
|
79
|
-
Manage the devices connected to the console.
|
|
129
|
+
<p>Current Score: {$state.score}</p>
|
|
80
130
|
|
|
81
|
-
|
|
82
|
-
|
|
131
|
+
{#if $routeType === 'controller'}
|
|
132
|
+
<button on:click={awardPoints}>Award 10 Points</button>
|
|
133
|
+
{/if}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
### 3. IoT Hardware Control (`Device`)
|
|
139
|
+
|
|
140
|
+
#### How it works & Capabilities
|
|
141
|
+
The `Device` class is your bridge to the physical world. Instead of dealing with BLE (Bluetooth Low Energy) protocols, the Master Server handles the connection and exposes an abstracted API through KoppeliaJS. You can control the visual feedback of the controllers and listen to their onboard IMU sensors.
|
|
142
|
+
|
|
143
|
+
**Available Output Methods (Feedback):**
|
|
144
|
+
* `setColor({ r, g, b, lon?, loff? })`: Sets a solid color. `lon` and `loff` can be used to create an automatic hardware blinking effect.
|
|
145
|
+
* `setColorSequence(["RED", "BLUE", "GREEN"], reset)`: Sends a predefined color loop to the device.
|
|
146
|
+
* `vibrate(time, blink?, blinkOff?, blinkCount?)`: Triggers the haptic motor. Can be set to a single long buzz, or a pulsing pattern (e.g., heartbeat effect).
|
|
147
|
+
|
|
148
|
+
**Available Input Listeners:**
|
|
149
|
+
* `onEvent(eventName, callback)`: Listens for physical button presses (e.g., "buzz").
|
|
150
|
+
* `onVerticalDetector(callback(isVertical))`: Uses the IMU to detect if the controller is held upright or flat.
|
|
151
|
+
* `onCursor(callback(x, y))`: Translates IMU spatial movement into 2D coordinates.
|
|
152
|
+
* `onBiking(callback(speed))`: Reads data if the modular controller is clipped to a physical exercise bike pedal.
|
|
153
|
+
|
|
154
|
+
#### Example
|
|
155
|
+
```typescript
|
|
156
|
+
import { Koppelia, Device } from "@momo2555/koppeliajs";
|
|
157
|
+
let koppelia = Koppelia.instance;
|
|
158
|
+
|
|
159
|
+
koppelia.onReady(async () => {
|
|
160
|
+
let devices: Device[] = await koppelia.getDevices();
|
|
161
|
+
|
|
162
|
+
for (let device of devices) {
|
|
163
|
+
// Setup visual feedback
|
|
164
|
+
device.setColor({ r: 0, g: 255, b: 0 }); // Green
|
|
165
|
+
|
|
166
|
+
// Listen for physical button press
|
|
167
|
+
device.onEvent("buzz", () => {
|
|
168
|
+
console.log(`${device.name} pressed the button!`);
|
|
169
|
+
// Trigger 500ms haptic feedback
|
|
170
|
+
device.vibrate(500);
|
|
171
|
+
// Update the game state to lock out other players
|
|
172
|
+
koppelia.updateState({ winner: device.name });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Listen for orientation (e.g., raising a hand/controller)
|
|
176
|
+
device.onVerticalDetector((isVertical) => {
|
|
177
|
+
if (isVertical) console.log(`${device.name} is holding the device up!`);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
### 4. Remote Procedure Calls (`CustomCallbacks`)
|
|
186
|
+
|
|
187
|
+
#### How it works & Capabilities
|
|
188
|
+
While `State` is used for persistent data, **Custom Callbacks** are designed for **transient events**. If you need to trigger a one-off action (like playing a sound effect, triggering a CSS explosion animation, or forcing a hardware reset), using state is inefficient. Custom Callbacks act as a Remote Procedure Call (RPC) system to broadcast functions across the network.
|
|
189
|
+
|
|
190
|
+
**Available Methods:**
|
|
191
|
+
* `koppelia.on(name, callback(args))`: Registers a listener on the current device.
|
|
192
|
+
* `koppelia.run(name, args)`: Broadcasts an execution request to all devices.
|
|
193
|
+
* `koppelia.unsub(name)`: Removes a listener. **Crucial:** Always call this in Svelte's `onDestroy` lifecycle to prevent memory leaks when changing stages.
|
|
194
|
+
|
|
195
|
+
#### Example
|
|
196
|
+
```svelte
|
|
197
|
+
<script lang="ts">
|
|
198
|
+
import { onMount, onDestroy } from "svelte";
|
|
199
|
+
import { Koppelia, routeType } from "@momo2555/koppeliajs";
|
|
200
|
+
let koppelia = Koppelia.instance;
|
|
201
|
+
|
|
202
|
+
if ($routeType === 'monitor') {
|
|
203
|
+
// The Monitor listens for the trigger
|
|
204
|
+
onMount(() => {
|
|
205
|
+
koppelia.on("playExplosion", (args) => {
|
|
206
|
+
showVisualExplosionAt(args.x, args.y);
|
|
207
|
+
audioManager.play("boom.mp3");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
onDestroy(() => {
|
|
212
|
+
koppelia.unsub("playExplosion"); // Prevent memory leaks!
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function triggerEventFromTablet() {
|
|
217
|
+
// The Animator clicks a button, sending the command to the Monitor
|
|
218
|
+
koppelia.run("playExplosion", { x: 50, y: 50 });
|
|
219
|
+
}
|
|
220
|
+
</script>
|
|
221
|
+
|
|
222
|
+
{#if $routeType === 'controller'}
|
|
223
|
+
<button on:click={triggerEventFromTablet}>Trigger Explosion</button>
|
|
224
|
+
{/if}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
83
228
|
|
|
84
|
-
|
|
85
|
-
Change the difficulty of the game in real time. The DifficultyCurser option should be activated on KoppeliaJS.
|
|
229
|
+
### 5. Game Options (Live Settings)
|
|
86
230
|
|
|
87
|
-
|
|
88
|
-
|
|
231
|
+
#### How it works & Capabilities
|
|
232
|
+
Game Options allow the animator to adjust variables dynamically from their tablet menu without altering the core game state tree. The Master server processes these UI definitions and generates Native UI components on the Flutter Controller App. The Svelte application receives real-time updates when the animator moves a slider or toggles a switch.
|
|
89
233
|
|
|
90
|
-
|
|
91
|
-
|
|
234
|
+
**Available Generators (Called by Controller):**
|
|
235
|
+
* `createSliderOption(name, label, value, min, max, step)`: Creates a numeric slider.
|
|
236
|
+
* `createSwitchOption(name, label, value)`: Creates a boolean toggle.
|
|
237
|
+
* `createChoicesOption(name, label, value, choicesArray)`: Creates a dropdown/segmented control.
|
|
92
238
|
|
|
93
|
-
|
|
239
|
+
**Available Listeners (Used by both screens):**
|
|
240
|
+
* `onOptionChanged(name, callback(data))`: Fires whenever the animator adjusts the generated UI.
|
|
94
241
|
|
|
95
|
-
|
|
242
|
+
#### Example
|
|
243
|
+
```typescript
|
|
244
|
+
import { Koppelia, routeType } from "@momo2555/koppeliajs";
|
|
245
|
+
import { get } from "svelte/store";
|
|
246
|
+
let koppelia = Koppelia.instance;
|
|
96
247
|
|
|
97
|
-
|
|
248
|
+
// Only the controller asks the Master Server to generate the UI
|
|
249
|
+
if (get(routeType) === 'controller') {
|
|
250
|
+
koppelia.createSliderOption("gameSpeed", "Game Speed Modifier", 1.0, 0.5, 2.0, 0.1);
|
|
251
|
+
koppelia.createSwitchOption("hardMode", "Enable Hard Mode", false);
|
|
252
|
+
}
|
|
98
253
|
|
|
99
|
-
|
|
254
|
+
// Both screens listen to the changes to adapt their logic
|
|
255
|
+
koppelia.onOptionChanged("hardMode", (data) => {
|
|
256
|
+
let isHardModeEnabled = data.value;
|
|
257
|
+
if (isHardModeEnabled) {
|
|
258
|
+
enableStrictTimer();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
### 6. CMS & Content (`Play`, `Resident`, `Song`)
|
|
266
|
+
|
|
267
|
+
#### How it works & Capabilities
|
|
268
|
+
Koppelia games are "engines" populated by content stored in the Filarmonic database. The SDK provides classes to securely fetch this data, automatically resolving complex media paths into usable URLs.
|
|
269
|
+
* **`Resident`**: Profiles of the seniors. Contains `.name` and `.imageUrl` (automatically resolved) to display their faces on the TV.
|
|
270
|
+
* **`Play`**: The content payload. E.g., a specific quiz deck. You must call `updatePlayContent()` to download the JSON payload into memory.
|
|
271
|
+
* **`Song`**: Structured audio data featuring separate URLs for backing tracks, vocals, lyrics, and album covers.
|
|
272
|
+
|
|
273
|
+
#### Example
|
|
274
|
+
```typescript
|
|
275
|
+
import { Koppelia, Resident, Play } from "@momo2555/koppeliajs";
|
|
276
|
+
let koppelia = Koppelia.instance;
|
|
277
|
+
|
|
278
|
+
koppelia.onReady(async () => {
|
|
279
|
+
// 1. Load Residents (Avatars)
|
|
280
|
+
let residents: Resident[] = await koppelia.getResidents();
|
|
281
|
+
console.log("Player 1 is:", residents[0].name);
|
|
282
|
+
|
|
283
|
+
// 2. Load the specific Game Content
|
|
284
|
+
let currentPlay: Play = await koppelia.getCurrentPlay();
|
|
285
|
+
await currentPlay.updatePlayContent(); // Triggers the actual download
|
|
286
|
+
|
|
287
|
+
// Access the JSON payload created by the animator
|
|
288
|
+
let quizQuestions = currentPlay.playContent.questions;
|
|
289
|
+
});
|
|
290
|
+
```
|
|
100
291
|
|
|
101
|
-
|
|
292
|
+
---
|
|
102
293
|
|
|
103
|
-
|
|
294
|
+
### 7. Stage Management (Server-Driven Navigation)
|
|
104
295
|
|
|
105
|
-
|
|
296
|
+
#### How it works & Capabilities
|
|
297
|
+
A "Stage" correlates directly to a SvelteKit route (e.g., `home`, `game`, `explanation`). Navigation in Koppelia is driven by the server. You do not use standard `<a>` tags or Svelte's `goto` manually. Instead, you ask the SDK to change the stage, which ensures that **both the Monitor and Controller transition to the new screen simultaneously**.
|
|
106
298
|
|
|
107
|
-
|
|
299
|
+
**Available Methods:**
|
|
300
|
+
* `init(defaultState, stagesArray)`: Registers the permitted routes with the server.
|
|
301
|
+
* `goto(stageName)`: Broadcasts a navigation command. The SDK will automatically clean up internal event listeners and force the browser to change URLs.
|
|
108
302
|
|
|
109
|
-
|
|
303
|
+
#### Example
|
|
304
|
+
```typescript
|
|
305
|
+
import { Koppelia } from "@momo2555/koppeliajs";
|
|
306
|
+
let koppelia = Koppelia.instance;
|
|
307
|
+
|
|
308
|
+
// Called on the boot screen
|
|
309
|
+
koppelia.init(
|
|
310
|
+
{ score: 0 },
|
|
311
|
+
['home', 'game', 'results']
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
function startGame() {
|
|
315
|
+
// Both TV and Tablet will navigate to /game/[type]/game instantly
|
|
316
|
+
koppelia.goto('game');
|
|
317
|
+
}
|
|
318
|
+
```
|
|
110
319
|
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## đ ī¸ Global Practical Example
|
|
323
|
+
|
|
324
|
+
Here is a comprehensive example demonstrating how Routing, State, Hardware, and Custom Callbacks come together in a single isomorphic `+page.svelte` file to create a fully functioning Quiz Game environment.
|
|
325
|
+
|
|
326
|
+
```svelte
|
|
327
|
+
<script lang="ts">
|
|
328
|
+
import { onDestroy, onMount } from "svelte";
|
|
329
|
+
import { get } from "svelte/store";
|
|
330
|
+
import { routeType, Koppelia, Device } from "@momo2555/koppeliajs";
|
|
331
|
+
|
|
332
|
+
// Abstracted UI Components (Assumed to exist in your project)
|
|
333
|
+
import QuestionDisplay from "$lib/components/QuestionDisplay.svelte";
|
|
334
|
+
import Button from "$lib/components/Button.svelte";
|
|
335
|
+
|
|
336
|
+
let koppelia = Koppelia.instance;
|
|
337
|
+
let state = koppelia.state;
|
|
338
|
+
let devices: Device[] = [];
|
|
339
|
+
|
|
340
|
+
// ==========================================
|
|
341
|
+
// đē MONITOR LOGIC (Game Rules & Hardware Hub)
|
|
342
|
+
// ==========================================
|
|
343
|
+
if ($routeType === "monitor") {
|
|
344
|
+
|
|
345
|
+
onMount(() => {
|
|
346
|
+
// Track persistent data in the global state
|
|
347
|
+
koppelia.updateState({ questionStartTime: Date.now() });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
onDestroy(() => {
|
|
351
|
+
// Prevent memory leaks when changing stages!
|
|
352
|
+
koppelia.unsub("qpc-unbuzz");
|
|
353
|
+
koppelia.unsub("resetDevices");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
koppelia.onReady(async () => {
|
|
357
|
+
devices = await koppelia.getDevices();
|
|
358
|
+
let players = get(koppelia.state).players || {};
|
|
359
|
+
|
|
360
|
+
// 1. RPC Callback: Listen for Animator forcing a hardware reset
|
|
361
|
+
koppelia.on("resetDevices", () => {
|
|
362
|
+
for (let device of devices) device.setColorSequence([], true);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// 2. Hardware Initialization
|
|
366
|
+
for (let device of devices) {
|
|
367
|
+
// Initialize player data in the state
|
|
368
|
+
if (!Object.hasOwn(players, device.name)) {
|
|
369
|
+
players[device.name] = { score: 0, name: device.name };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Listen for physical buzzer presses
|
|
373
|
+
device.onEvent("buzz", () => {
|
|
374
|
+
// Only accept buzz if nobody has buzzed yet
|
|
375
|
+
if ($state.buzz === null) {
|
|
376
|
+
device.vibrate(700); // Haptic feedback
|
|
377
|
+
|
|
378
|
+
// Update global state! The tablet will instantly see who buzzed.
|
|
379
|
+
koppelia.updateState({ buzz: $state.players[device.name] });
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// Push the registered players to the network
|
|
384
|
+
koppelia.updateState({ players });
|
|
385
|
+
|
|
386
|
+
// 3. RPC Callback: Reset the UI state if the Animator rejects an answer
|
|
387
|
+
koppelia.on("qpc-unbuzz", () => {
|
|
388
|
+
koppelia.updateState({ buzz: null });
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ==========================================
|
|
394
|
+
// đą CONTROLLER LOGIC (Animator Actions)
|
|
395
|
+
// ==========================================
|
|
396
|
+
function onSkip() {
|
|
397
|
+
koppelia.updateState({ buzz: null });
|
|
398
|
+
// Forces both screens to navigate to the explanation stage
|
|
399
|
+
koppelia.goto("explanation");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function onCorrect() {
|
|
403
|
+
// Mutate persistent state
|
|
404
|
+
let players = $state.players;
|
|
405
|
+
players[$state.buzz.name].score += 1;
|
|
406
|
+
koppelia.updateState({ players, answerState: "right" });
|
|
407
|
+
|
|
408
|
+
// Wait 2 seconds, then change stage
|
|
409
|
+
setTimeout(() => {
|
|
410
|
+
koppelia.goto("explanation");
|
|
411
|
+
koppelia.updateState({ buzz: null });
|
|
412
|
+
}, 2000);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function onWrong() {
|
|
416
|
+
koppelia.updateState({ answerState: "bad" });
|
|
417
|
+
// RPC: Tell the Monitor to reset hardware/buzzer lockout
|
|
418
|
+
koppelia.run("qpc-unbuzz", {});
|
|
419
|
+
}
|
|
420
|
+
</script>
|
|
421
|
+
|
|
422
|
+
<div class="game-content">
|
|
423
|
+
|
|
424
|
+
{#if $routeType === "controller"}
|
|
425
|
+
<div class="top-dashboard">
|
|
426
|
+
<p>Game Difficulty: {$state.difficulty}</p>
|
|
427
|
+
</div>
|
|
428
|
+
{/if}
|
|
429
|
+
|
|
430
|
+
<QuestionDisplay question={$state.question}></QuestionDisplay>
|
|
431
|
+
|
|
432
|
+
{#if $state.buzz !== null}
|
|
433
|
+
<h2>{$state.buzz.name} Buzzed!</h2>
|
|
434
|
+
{/if}
|
|
435
|
+
|
|
436
|
+
{#if $routeType === "controller"}
|
|
437
|
+
<div class="actions">
|
|
438
|
+
{#if $state.buzz !== null}
|
|
439
|
+
<Button text="Correct" color="green" callback={onCorrect} />
|
|
440
|
+
<Button text="Wrong" color="red" callback={onWrong} />
|
|
441
|
+
{:else}
|
|
442
|
+
<Button text="Skip Question" callback={onSkip} />
|
|
443
|
+
{/if}
|
|
444
|
+
</div>
|
|
445
|
+
{/if}
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<style>
|
|
449
|
+
/* Strict layout control to prevent scrolling issues on the TV */
|
|
450
|
+
:global(body, html) {
|
|
451
|
+
margin: 0;
|
|
452
|
+
padding: 0;
|
|
453
|
+
height: 100vh;
|
|
454
|
+
overflow: hidden;
|
|
455
|
+
}
|
|
456
|
+
.game-content {
|
|
457
|
+
background-color: #f8f9fa;
|
|
458
|
+
display: flex;
|
|
459
|
+
flex-direction: column;
|
|
460
|
+
height: 100vh;
|
|
461
|
+
text-align: center;
|
|
462
|
+
}
|
|
463
|
+
.actions {
|
|
464
|
+
margin-top: auto;
|
|
465
|
+
padding: 20px;
|
|
466
|
+
}
|
|
467
|
+
</style>
|
|
468
|
+
```
|
package/dist/scripts/device.d.ts
CHANGED
|
@@ -18,31 +18,34 @@ export declare class Device {
|
|
|
18
18
|
private _console;
|
|
19
19
|
private _attachedEvents;
|
|
20
20
|
private _resident?;
|
|
21
|
+
private _callbackIds;
|
|
22
|
+
private _eventsIds;
|
|
21
23
|
private isAttachedToResident;
|
|
22
24
|
constructor(console: Console, address?: string);
|
|
23
25
|
get color(): Color;
|
|
24
26
|
get name(): string;
|
|
27
|
+
get address(): string;
|
|
25
28
|
/**
|
|
26
29
|
* Subscribes to a specific hardware event for this device.
|
|
27
30
|
* @param eventName The name of the event to listen to.
|
|
28
31
|
* @param callback Function to execute when the event is triggered.
|
|
29
32
|
*/
|
|
30
|
-
onEvent(eventName: string, callback: () => void):
|
|
33
|
+
onEvent(eventName: string, callback: () => void): string;
|
|
31
34
|
/**
|
|
32
35
|
* Enables the cursor module and listens for coordinate updates.
|
|
33
36
|
* @param callback Function to execute with the incoming (x, y) coordinates.
|
|
34
37
|
*/
|
|
35
|
-
onCursor(callback: (x: number, y: number) => void):
|
|
38
|
+
onCursor(callback: (x: number, y: number) => void): string;
|
|
36
39
|
/**
|
|
37
40
|
* Enables the biking module and listens for speed updates.
|
|
38
41
|
* @param callback Function to execute with the incoming speed data.
|
|
39
42
|
*/
|
|
40
|
-
onBiking(callback: (speed: number) => void):
|
|
43
|
+
onBiking(callback: (speed: number) => void): string;
|
|
41
44
|
/**
|
|
42
45
|
* Enables the vertical detector module and listens for orientation updates.
|
|
43
46
|
* @param callback Function to execute with the boolean vertical state.
|
|
44
47
|
*/
|
|
45
|
-
onVerticalDetector(callback: (vertical: boolean) => void):
|
|
48
|
+
onVerticalDetector(callback: (vertical: boolean) => void): string;
|
|
46
49
|
/**
|
|
47
50
|
* Sends a request to the device to attach a specific event listener on the hardware side.
|
|
48
51
|
* @param event The event name to attach.
|
|
@@ -72,6 +75,10 @@ export declare class Device {
|
|
|
72
75
|
* @param blinkCount The number of pulsing cycles to execute.
|
|
73
76
|
*/
|
|
74
77
|
vibrate(time: number, blink?: boolean, blinkOff?: number, blinkCount?: number): void;
|
|
78
|
+
clearCallback(callbackId: string): void;
|
|
79
|
+
clearAllCallbacks(): void;
|
|
80
|
+
clearEvent(eventId: string): void;
|
|
81
|
+
clearAllEvents(): void;
|
|
75
82
|
/**
|
|
76
83
|
* Serializes the Device object into a generic dictionary.
|
|
77
84
|
* @returns A plain object representing the device's current state.
|
package/dist/scripts/device.js
CHANGED
|
@@ -12,6 +12,8 @@ export class Device {
|
|
|
12
12
|
_console;
|
|
13
13
|
_attachedEvents;
|
|
14
14
|
_resident;
|
|
15
|
+
_callbackIds;
|
|
16
|
+
_eventsIds;
|
|
15
17
|
isAttachedToResident;
|
|
16
18
|
constructor(console, address = "") {
|
|
17
19
|
this._address = address;
|
|
@@ -19,6 +21,8 @@ export class Device {
|
|
|
19
21
|
this._name = "";
|
|
20
22
|
this._console = console;
|
|
21
23
|
this._attachedEvents = [];
|
|
24
|
+
this._callbackIds = [];
|
|
25
|
+
this._eventsIds = [];
|
|
22
26
|
this.isAttachedToResident = false;
|
|
23
27
|
}
|
|
24
28
|
get color() {
|
|
@@ -27,6 +31,9 @@ export class Device {
|
|
|
27
31
|
get name() {
|
|
28
32
|
return this._name;
|
|
29
33
|
}
|
|
34
|
+
get address() {
|
|
35
|
+
return this._address;
|
|
36
|
+
}
|
|
30
37
|
/**
|
|
31
38
|
* Subscribes to a specific hardware event for this device.
|
|
32
39
|
* @param eventName The name of the event to listen to.
|
|
@@ -39,7 +46,7 @@ export class Device {
|
|
|
39
46
|
callback();
|
|
40
47
|
}
|
|
41
48
|
};
|
|
42
|
-
this._console.onDeviceEvent(consoleEvent);
|
|
49
|
+
return this._console.onDeviceEvent(consoleEvent);
|
|
43
50
|
}
|
|
44
51
|
/**
|
|
45
52
|
* Enables the cursor module and listens for coordinate updates.
|
|
@@ -48,11 +55,13 @@ export class Device {
|
|
|
48
55
|
onCursor(callback) {
|
|
49
56
|
this._enableModule("cursor");
|
|
50
57
|
this._attachEvent("cursor");
|
|
51
|
-
this._console.onRequest((request, params, form, address) => {
|
|
58
|
+
let callbackId = this._console.onRequest((request, params, form, address) => {
|
|
52
59
|
if (request == "cursor" && address == this._address) {
|
|
53
60
|
callback(params.x, params.y);
|
|
54
61
|
}
|
|
55
62
|
});
|
|
63
|
+
this._callbackIds.push(callbackId);
|
|
64
|
+
return callbackId;
|
|
56
65
|
}
|
|
57
66
|
/**
|
|
58
67
|
* Enables the biking module and listens for speed updates.
|
|
@@ -61,11 +70,13 @@ export class Device {
|
|
|
61
70
|
onBiking(callback) {
|
|
62
71
|
this._enableModule("biking");
|
|
63
72
|
this._attachEvent("biking");
|
|
64
|
-
this._console.onRequest((request, params, form, address) => {
|
|
73
|
+
let callbackId = this._console.onRequest((request, params, form, address) => {
|
|
65
74
|
if (request == "biking" && address == this._address) {
|
|
66
75
|
callback(params.speed);
|
|
67
76
|
}
|
|
68
77
|
});
|
|
78
|
+
this._callbackIds.push(callbackId);
|
|
79
|
+
return callbackId;
|
|
69
80
|
}
|
|
70
81
|
/**
|
|
71
82
|
* Enables the vertical detector module and listens for orientation updates.
|
|
@@ -74,11 +85,13 @@ export class Device {
|
|
|
74
85
|
onVerticalDetector(callback) {
|
|
75
86
|
this._enableModule("vDetct");
|
|
76
87
|
this._attachEvent("verticalDetector");
|
|
77
|
-
this._console.onRequest((request, params, form, address) => {
|
|
88
|
+
let callbackId = this._console.onRequest((request, params, form, address) => {
|
|
78
89
|
if (request == "verticalDetector" && address == this._address) {
|
|
79
90
|
callback(params.value);
|
|
80
91
|
}
|
|
81
92
|
});
|
|
93
|
+
this._callbackIds.push(callbackId);
|
|
94
|
+
return callbackId;
|
|
82
95
|
}
|
|
83
96
|
/**
|
|
84
97
|
* Sends a request to the device to attach a specific event listener on the hardware side.
|
|
@@ -153,6 +166,26 @@ export class Device {
|
|
|
153
166
|
request.setRequest("vibrate");
|
|
154
167
|
this._console.sendMessage(request);
|
|
155
168
|
}
|
|
169
|
+
clearCallback(callbackId) {
|
|
170
|
+
if (this._callbackIds.includes(callbackId)) {
|
|
171
|
+
this._console.unsubscribeCallback(callbackId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
clearAllCallbacks() {
|
|
175
|
+
for (let callbackId of this._callbackIds) {
|
|
176
|
+
this.clearCallback(callbackId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
clearEvent(eventId) {
|
|
180
|
+
if (this._eventsIds.includes(eventId)) {
|
|
181
|
+
this._console.unsubscribeCallback(eventId);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
clearAllEvents() {
|
|
185
|
+
for (let eventId of this._eventsIds) {
|
|
186
|
+
this.clearCallback(eventId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
156
189
|
/**
|
|
157
190
|
* Serializes the Device object into a generic dictionary.
|
|
158
191
|
* @returns A plain object representing the device's current state.
|
|
@@ -64,6 +64,7 @@ export declare class Koppelia {
|
|
|
64
64
|
* @param stageName The target stage to navigate to.
|
|
65
65
|
*/
|
|
66
66
|
goto(stageName: string): void;
|
|
67
|
+
getCurrentStage(): string;
|
|
67
68
|
/**
|
|
68
69
|
* Normalizes a media URL to ensure cross-client compatibility.
|
|
69
70
|
* @param mediaUrl The raw media URL.
|
|
@@ -81,6 +82,10 @@ export declare class Koppelia {
|
|
|
81
82
|
* @returns A promise resolving to an array of instantiated Device objects.
|
|
82
83
|
*/
|
|
83
84
|
getDevices(): Promise<Device[]>;
|
|
85
|
+
private _onDeviceConnNotification;
|
|
86
|
+
onDeviceConnectedNotification(callback: (device: Device) => void): string;
|
|
87
|
+
onDeviceDisconnectedNotification(callback: (device: Device) => void): string;
|
|
88
|
+
unsubDeviceConnectionNotification(callbackId: string): void;
|
|
84
89
|
/**
|
|
85
90
|
* Retrieves the unique identifier of the currently loaded game.
|
|
86
91
|
* @returns The game ID string provided by public environment variables.
|
package/dist/scripts/koppelia.js
CHANGED
|
@@ -118,6 +118,9 @@ export class Koppelia {
|
|
|
118
118
|
goto(stageName) {
|
|
119
119
|
this._stage.goto(stageName);
|
|
120
120
|
}
|
|
121
|
+
getCurrentStage() {
|
|
122
|
+
return this._stage.currentStage;
|
|
123
|
+
}
|
|
121
124
|
/**
|
|
122
125
|
* Normalizes a media URL to ensure cross-client compatibility.
|
|
123
126
|
* @param mediaUrl The raw media URL.
|
|
@@ -144,9 +147,9 @@ export class Koppelia {
|
|
|
144
147
|
getDevicesRequest.setRequest("getDevices");
|
|
145
148
|
getDevicesRequest.setDestination(PeerType.MASTER, "");
|
|
146
149
|
this._console.sendMessage(getDevicesRequest, (response) => {
|
|
147
|
-
let
|
|
150
|
+
let devicesRaw = response.getParam("devices", []);
|
|
148
151
|
let devices = [];
|
|
149
|
-
for (let device_raw of
|
|
152
|
+
for (let device_raw of devicesRaw) {
|
|
150
153
|
let device = new Device(this._console);
|
|
151
154
|
device.fromObject(device_raw);
|
|
152
155
|
devices.push(device);
|
|
@@ -155,6 +158,26 @@ export class Koppelia {
|
|
|
155
158
|
});
|
|
156
159
|
});
|
|
157
160
|
}
|
|
161
|
+
_onDeviceConnNotification(callback, notifName) {
|
|
162
|
+
return this._console.onRequest((request, params, from, address) => {
|
|
163
|
+
if (request == notifName) {
|
|
164
|
+
if (params.device !== undefined) {
|
|
165
|
+
let device = new Device(this._console);
|
|
166
|
+
device.fromObject(params.device);
|
|
167
|
+
callback(device);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
onDeviceConnectedNotification(callback) {
|
|
173
|
+
return this._onDeviceConnNotification(callback, "deviceConnectionNotification");
|
|
174
|
+
}
|
|
175
|
+
onDeviceDisconnectedNotification(callback) {
|
|
176
|
+
return this._onDeviceConnNotification(callback, "deviceDisconnectionNotification");
|
|
177
|
+
}
|
|
178
|
+
unsubDeviceConnectionNotification(callbackId) {
|
|
179
|
+
this._console.unsubscribeCallback(callbackId);
|
|
180
|
+
}
|
|
158
181
|
/**
|
|
159
182
|
* Retrieves the unique identifier of the currently loaded game.
|
|
160
183
|
* @returns The game ID string provided by public environment variables.
|
package/dist/scripts/stage.d.ts
CHANGED
package/dist/scripts/stage.js
CHANGED
|
@@ -34,6 +34,7 @@ export class Stage {
|
|
|
34
34
|
req.setRequest("initStages");
|
|
35
35
|
req.addParam("stages", stages);
|
|
36
36
|
this._console.sendMessage(req);
|
|
37
|
+
this._stages = stages;
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
39
40
|
* Requests a stage transition via the server.
|
|
@@ -60,8 +61,14 @@ export class Stage {
|
|
|
60
61
|
* @param receivedStage The name of the stage to load.
|
|
61
62
|
*/
|
|
62
63
|
_onReceiveStage(from, receivedStage) {
|
|
63
|
-
this.
|
|
64
|
+
this._currentStage = receivedStage;
|
|
64
65
|
let path = "/game/" + get(routeType) + "/" + receivedStage;
|
|
65
66
|
goto(path);
|
|
66
67
|
}
|
|
68
|
+
get currentStage() {
|
|
69
|
+
return this._currentStage;
|
|
70
|
+
}
|
|
71
|
+
get stages() {
|
|
72
|
+
return this._stages;
|
|
73
|
+
}
|
|
67
74
|
}
|