create-gametau 0.2.1 → 0.3.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/README.md CHANGED
@@ -38,6 +38,19 @@ bunx create-gametau my-game --template vanilla
38
38
  - `src-tauri/app` - Tauri desktop shell
39
39
  - `src-tauri/wasm` - WASM entry crate
40
40
  - `src` - frontend app wired to `webtau` and `webtau-vite`
41
+ - `src/services` - production-ready service seams for backend invoke calls, persistence/settings, mission session snapshots, and event-driven comms
42
+
43
+ ## Service Layer Contract
44
+
45
+ The scaffolded base template now ships a small service architecture instead of a single command wrapper:
46
+
47
+ - `src/services/backend.ts` - typed `invoke()` wrappers for gameplay commands
48
+ - `src/services/settings.ts` - runtime settings persistence via `webtau/path` + `webtau/fs`
49
+ - `src/services/session.ts` - mission/session snapshot persistence via `webtau/path` + `webtau/fs`
50
+ - `src/services/comms.ts` - typed alert/comms channel over `webtau/event`
51
+ - `src/services/contracts.ts` - interfaces/types for settings, session snapshots, and alerts
52
+
53
+ This keeps the generated project lightweight while giving contributors clear extension points for production features.
41
54
 
42
55
  For full docs and package details, see the main repository:
43
56
  <https://github.com/devallibus/gametau>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-gametau",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a Tauri game that deploys to web + desktop",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -25,6 +25,10 @@
25
25
  <div id="hud">
26
26
  <div>Score: <span id="score">0</span></div>
27
27
  <div>Tick: <span id="tick">0</span></div>
28
+ <div>Tick Rate: <span id="tick-rate">0</span> Hz</div>
29
+ <div>Autosave: every <span id="autosave">0</span> ticks</div>
30
+ <div>Session: <span id="session">fresh</span></div>
31
+ <div>Alert: <span id="alert">ready</span></div>
28
32
  </div>
29
33
  <div id="app"></div>
30
34
  <script type="module" src="/src/index.ts"></script>
@@ -11,12 +11,12 @@
11
11
  "preview": "vite preview"
12
12
  },
13
13
  "dependencies": {
14
- "webtau": "^0.2.0"
14
+ "webtau": "^0.3.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "typescript": "^5.8.0",
18
18
  "vite": "^6.0.0",
19
- "webtau-vite": "^0.2.0",
19
+ "webtau-vite": "^0.3.0",
20
20
  "@tauri-apps/cli": "^2.0.0",
21
21
  "@tauri-apps/api": "^2.0.0"
22
22
  }
@@ -1,7 +1,27 @@
1
1
  import { configure, isTauri } from "webtau";
2
- import { getWorldView, tickWorld } from "./services/backend";
3
2
  import { startGameLoop } from "./game/loop";
4
3
  import { initScene, updateScene } from "./game/scene";
4
+ import {
5
+ createServiceLayer,
6
+ getWorldView,
7
+ tickWorld,
8
+ type AlertLevel,
9
+ type RuntimeSettings,
10
+ type WorldView,
11
+ } from "./services";
12
+
13
+ function resolveAlertLevel(scoreDelta: number): AlertLevel {
14
+ if (scoreDelta >= 2) return "critical";
15
+ if (scoreDelta > 0) return "info";
16
+ return "warning";
17
+ }
18
+
19
+ function updateHud(view: WorldView, settings: RuntimeSettings): void {
20
+ document.getElementById("score")!.textContent = String(view.score);
21
+ document.getElementById("tick")!.textContent = String(view.tick_count);
22
+ document.getElementById("tick-rate")!.textContent = String(settings.tickRateHz);
23
+ document.getElementById("autosave")!.textContent = String(settings.autoSaveEveryTicks);
24
+ }
5
25
 
6
26
  async function main() {
7
27
  // Configure webtau for web mode (no-op in Tauri)
@@ -20,27 +40,56 @@ async function main() {
20
40
  const app = document.getElementById("app")!;
21
41
  await initScene(app);
22
42
 
23
- // Get initial state
43
+ // Build service seams up front: backend invoke wrappers + fs/path + event orchestration.
44
+ const services = await createServiceLayer();
45
+ const settings = await services.settings.load();
46
+ const previousSession = await services.session.loadLastSnapshot();
47
+ document.getElementById("session")!.textContent = previousSession
48
+ ? `restored (${previousSession.savedAt})`
49
+ : "fresh";
50
+ document.getElementById("alert")!.textContent = "ready";
51
+
52
+ const unlistenAlerts = await services.comms.subscribe((message) => {
53
+ document.getElementById("alert")!.textContent = `[${message.level}] ${message.message}`;
54
+ });
55
+ window.addEventListener("beforeunload", () => {
56
+ unlistenAlerts();
57
+ });
58
+
59
+ // Persist settings on first run so users have an explicit config seam to extend.
60
+ await services.settings.save(settings);
61
+
62
+ // Get and display initial state.
24
63
  const view = await getWorldView();
25
- document.getElementById("score")!.textContent = String(view.score);
26
- document.getElementById("tick")!.textContent = String(view.tick_count);
64
+ updateHud(view, settings);
65
+ await services.session.saveSnapshot(view);
27
66
 
28
67
  // Start game loop
29
68
  let tickAccumulator = 0;
30
69
  let tickInFlight = false;
31
- const TICK_RATE = 1 / 10; // 10 ticks per second
70
+ const tickRate = 1 / settings.tickRateHz;
32
71
 
33
72
  startGameLoop(
34
73
  (dt) => {
35
74
  tickAccumulator += dt;
36
- if (!tickInFlight && tickAccumulator >= TICK_RATE) {
37
- tickAccumulator -= TICK_RATE;
75
+ if (!tickInFlight && tickAccumulator >= tickRate) {
76
+ tickAccumulator -= tickRate;
38
77
  tickInFlight = true;
39
78
  tickWorld()
40
- .then(() => getWorldView())
41
- .then((view) => {
42
- document.getElementById("score")!.textContent = String(view.score);
43
- document.getElementById("tick")!.textContent = String(view.tick_count);
79
+ .then(async (tickResult) => {
80
+ const nextView = await getWorldView();
81
+ updateHud(nextView, settings);
82
+
83
+ if (nextView.tick_count % settings.autoSaveEveryTicks === 0) {
84
+ await services.session.saveSnapshot(nextView);
85
+ document.getElementById("session")!.textContent = `saved at tick ${nextView.tick_count}`;
86
+ }
87
+
88
+ await services.comms.publish({
89
+ level: resolveAlertLevel(tickResult.score_delta),
90
+ source: "engine",
91
+ message: `score delta ${tickResult.score_delta}`,
92
+ });
44
93
  })
45
94
  .catch(console.error)
46
95
  .finally(() => { tickInFlight = false; });
@@ -0,0 +1,32 @@
1
+ import { emit, listen } from "webtau/event";
2
+ import type { AlertMessage, CommsService } from "./contracts";
3
+
4
+ const COMMS_NAMESPACE = "gametau:comms";
5
+
6
+ function eventNameFor(channel: string): string {
7
+ return `${COMMS_NAMESPACE}:${channel}`;
8
+ }
9
+
10
+ export function createCommsService(channel: string): CommsService {
11
+ const eventName = eventNameFor(channel);
12
+
13
+ return {
14
+ channel,
15
+
16
+ async publish(input): Promise<void> {
17
+ const payload: AlertMessage = {
18
+ level: input.level,
19
+ source: input.source,
20
+ message: input.message,
21
+ timestamp: input.timestamp ?? new Date().toISOString(),
22
+ };
23
+ await emit(eventName, payload);
24
+ },
25
+
26
+ async subscribe(handler): Promise<() => void> {
27
+ return listen<AlertMessage>(eventName, ({ payload }) => {
28
+ handler(payload);
29
+ });
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,48 @@
1
+ import type { WorldView } from "./backend";
2
+
3
+ export type AlertLevel = "info" | "warning" | "critical";
4
+
5
+ export interface AlertMessage {
6
+ level: AlertLevel;
7
+ source: string;
8
+ message: string;
9
+ timestamp: string;
10
+ }
11
+
12
+ export interface RuntimeSettings {
13
+ tickRateHz: number;
14
+ autoSaveEveryTicks: number;
15
+ commsChannel: string;
16
+ }
17
+
18
+ export const DEFAULT_RUNTIME_SETTINGS: RuntimeSettings = {
19
+ tickRateHz: 10,
20
+ autoSaveEveryTicks: 10,
21
+ commsChannel: "ops",
22
+ };
23
+
24
+ export interface SessionSnapshot extends WorldView {
25
+ savedAt: string;
26
+ }
27
+
28
+ export interface SettingsService {
29
+ load(): Promise<RuntimeSettings>;
30
+ save(next: Partial<RuntimeSettings>): Promise<RuntimeSettings>;
31
+ }
32
+
33
+ export interface SessionService {
34
+ loadLastSnapshot(): Promise<SessionSnapshot | null>;
35
+ saveSnapshot(view: WorldView): Promise<void>;
36
+ }
37
+
38
+ export interface CommsService {
39
+ readonly channel: string;
40
+ publish(input: Omit<AlertMessage, "timestamp"> & { timestamp?: string }): Promise<void>;
41
+ subscribe(handler: (message: AlertMessage) => void): Promise<() => void>;
42
+ }
43
+
44
+ export interface ServiceLayer {
45
+ settings: SettingsService;
46
+ session: SessionService;
47
+ comms: CommsService;
48
+ }
@@ -0,0 +1,28 @@
1
+ import { createCommsService } from "./comms";
2
+ import {
3
+ DEFAULT_RUNTIME_SETTINGS,
4
+ type AlertLevel,
5
+ type AlertMessage,
6
+ type RuntimeSettings,
7
+ type SessionSnapshot,
8
+ type ServiceLayer,
9
+ } from "./contracts";
10
+ import { createSessionService } from "./session";
11
+ import { createSettingsService } from "./settings";
12
+
13
+ export * from "./backend";
14
+ export {
15
+ DEFAULT_RUNTIME_SETTINGS,
16
+ type RuntimeSettings,
17
+ type AlertLevel,
18
+ type AlertMessage,
19
+ type SessionSnapshot,
20
+ };
21
+
22
+ export async function createServiceLayer(): Promise<ServiceLayer> {
23
+ const settings = createSettingsService();
24
+ const resolvedSettings = await settings.load();
25
+ const comms = createCommsService(resolvedSettings.commsChannel);
26
+ const session = createSessionService();
27
+ return { settings, comms, session };
28
+ }
@@ -0,0 +1,59 @@
1
+ import { createDir, exists, readTextFile, writeTextFile } from "webtau/fs";
2
+ import { appDataDir, join } from "webtau/path";
3
+ import type { SessionService, SessionSnapshot } from "./contracts";
4
+ import type { WorldView } from "./backend";
5
+
6
+ const SESSION_FILENAME = "mission-session.json";
7
+
8
+ function toSnapshot(view: WorldView): SessionSnapshot {
9
+ return {
10
+ ...view,
11
+ savedAt: new Date().toISOString(),
12
+ };
13
+ }
14
+
15
+ function parseSnapshot(raw: unknown): SessionSnapshot | null {
16
+ if (!raw || typeof raw !== "object") return null;
17
+ const candidate = raw as Partial<SessionSnapshot>;
18
+ if (
19
+ typeof candidate.score !== "number" ||
20
+ typeof candidate.tick_count !== "number" ||
21
+ typeof candidate.savedAt !== "string"
22
+ ) {
23
+ return null;
24
+ }
25
+ return {
26
+ score: candidate.score,
27
+ tick_count: candidate.tick_count,
28
+ savedAt: candidate.savedAt,
29
+ };
30
+ }
31
+
32
+ async function getSessionPath(): Promise<string> {
33
+ const dataDir = await appDataDir();
34
+ await createDir(dataDir, { recursive: true });
35
+ return join(dataDir, SESSION_FILENAME);
36
+ }
37
+
38
+ export function createSessionService(): SessionService {
39
+ return {
40
+ async loadLastSnapshot(): Promise<SessionSnapshot | null> {
41
+ const sessionPath = await getSessionPath();
42
+ if (!(await exists(sessionPath))) {
43
+ return null;
44
+ }
45
+ try {
46
+ const raw = await readTextFile(sessionPath);
47
+ return parseSnapshot(JSON.parse(raw) as unknown);
48
+ } catch {
49
+ return null;
50
+ }
51
+ },
52
+
53
+ async saveSnapshot(view: WorldView): Promise<void> {
54
+ const sessionPath = await getSessionPath();
55
+ const snapshot = toSnapshot(view);
56
+ await writeTextFile(sessionPath, JSON.stringify(snapshot, null, 2));
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,69 @@
1
+ import { createDir, exists, readTextFile, writeTextFile } from "webtau/fs";
2
+ import { appConfigDir, join } from "webtau/path";
3
+ import {
4
+ DEFAULT_RUNTIME_SETTINGS,
5
+ type RuntimeSettings,
6
+ type SettingsService,
7
+ } from "./contracts";
8
+
9
+ const SETTINGS_FILENAME = "runtime-settings.json";
10
+
11
+ function normalizeSettings(raw: unknown): RuntimeSettings {
12
+ if (!raw || typeof raw !== "object") {
13
+ return DEFAULT_RUNTIME_SETTINGS;
14
+ }
15
+ const candidate = raw as Partial<RuntimeSettings>;
16
+ const tickRateHz =
17
+ typeof candidate.tickRateHz === "number" && Number.isFinite(candidate.tickRateHz)
18
+ ? Math.max(1, Math.floor(candidate.tickRateHz))
19
+ : DEFAULT_RUNTIME_SETTINGS.tickRateHz;
20
+ const autoSaveEveryTicks =
21
+ typeof candidate.autoSaveEveryTicks === "number" && Number.isFinite(candidate.autoSaveEveryTicks)
22
+ ? Math.max(1, Math.floor(candidate.autoSaveEveryTicks))
23
+ : DEFAULT_RUNTIME_SETTINGS.autoSaveEveryTicks;
24
+ const commsChannel =
25
+ typeof candidate.commsChannel === "string" && candidate.commsChannel.trim().length > 0
26
+ ? candidate.commsChannel.trim()
27
+ : DEFAULT_RUNTIME_SETTINGS.commsChannel;
28
+
29
+ return { tickRateHz, autoSaveEveryTicks, commsChannel };
30
+ }
31
+
32
+ async function getSettingsPath(): Promise<string> {
33
+ const configDir = await appConfigDir();
34
+ await createDir(configDir, { recursive: true });
35
+ return join(configDir, SETTINGS_FILENAME);
36
+ }
37
+
38
+ export function createSettingsService(): SettingsService {
39
+ let cache: RuntimeSettings | null = null;
40
+
41
+ return {
42
+ async load(): Promise<RuntimeSettings> {
43
+ if (cache) return cache;
44
+
45
+ const settingsPath = await getSettingsPath();
46
+ if (!(await exists(settingsPath))) {
47
+ cache = DEFAULT_RUNTIME_SETTINGS;
48
+ return cache;
49
+ }
50
+
51
+ try {
52
+ const raw = await readTextFile(settingsPath);
53
+ cache = normalizeSettings(JSON.parse(raw) as unknown);
54
+ } catch {
55
+ cache = DEFAULT_RUNTIME_SETTINGS;
56
+ }
57
+ return cache;
58
+ },
59
+
60
+ async save(next: Partial<RuntimeSettings>): Promise<RuntimeSettings> {
61
+ const current = await this.load();
62
+ const merged = normalizeSettings({ ...current, ...next });
63
+ const settingsPath = await getSettingsPath();
64
+ await writeTextFile(settingsPath, JSON.stringify(merged, null, 2));
65
+ cache = merged;
66
+ return merged;
67
+ },
68
+ };
69
+ }
@@ -5,7 +5,7 @@ edition.workspace = true
5
5
 
6
6
  [dependencies]
7
7
  {{PROJECT_NAME}}-core = { path = "../core" }
8
- webtau = "0.2"
8
+ webtau = "0.3.0"
9
9
 
10
10
  [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
11
11
  tauri = { version = "2", features = [] }
@@ -11,7 +11,7 @@ wasm-bindgen = "0.2"
11
11
  serde = { version = "1", features = ["derive"] }
12
12
  serde-wasm-bindgen = "0.6"
13
13
  getrandom = { version = "0.2", features = ["js"] }
14
- webtau = "0.2"
14
+ webtau = "0.3.0"
15
15
  {{PROJECT_NAME}}-core = { path = "../core" }
16
16
  {{PROJECT_NAME}}-commands = { path = "../commands" }
17
17
 
@@ -12,12 +12,12 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "pixi.js": "^8.0.0",
15
- "webtau": "^0.2.0"
15
+ "webtau": "^0.3.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "typescript": "^5.8.0",
19
19
  "vite": "^6.0.0",
20
- "webtau-vite": "^0.2.0",
20
+ "webtau-vite": "^0.3.0",
21
21
  "@tauri-apps/cli": "^2.0.0",
22
22
  "@tauri-apps/api": "^2.0.0"
23
23
  }
@@ -12,13 +12,13 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "three": "^0.172.0",
15
- "webtau": "^0.2.0"
15
+ "webtau": "^0.3.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/three": "^0.172.0",
19
19
  "typescript": "^5.8.0",
20
20
  "vite": "^6.0.0",
21
- "webtau-vite": "^0.2.0",
21
+ "webtau-vite": "^0.3.0",
22
22
  "@tauri-apps/cli": "^2.0.0",
23
23
  "@tauri-apps/api": "^2.0.0"
24
24
  }