diff --git a/bun.lock b/bun.lock index 0451c12..e6c45a2 100644 --- a/bun.lock +++ b/bun.lock @@ -13,15 +13,17 @@ "name": "battlestation-example", "version": "0.1.0", "dependencies": { - "electrobun": "^1.14.4", + "electrobun": "^1.15.1", "three": "^0.172.0", "webtau": "workspace:*", }, "devDependencies": { "@tauri-apps/api": "^2.0.0", "@tauri-apps/cli": "^2.0.0", + "@types/bun": "^1.3.9", "@types/three": "^0.172.0", "concurrently": "^9.2.1", + "cross-env": "^7.0.3", "typescript": "^5.8.0", "vite": "^6.0.0", "webtau-vite": "workspace:*", @@ -421,7 +423,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "electrobun": ["electrobun@1.14.4", "", { "dependencies": { "@types/bun": "^1.3.8", "archiver": "^7.0.1", "png-to-ico": "^2.1.8", "rcedit": "^4.0.1" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-ul8S2dK7IXAbvHGZTb9sFnG/mq9T4VkRJ1OYX59kGPIoTkM8WKJXtwTENIyBwzjEi8Yq12WQvSGHO2ezV9zjCw=="], + "electrobun": ["electrobun@1.15.1", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-FJ7XaTzbCPuH2ABK615WJyrfacoMdYR33tHJ0j/2JIw2srK4lHN9iSjPpvRVL1UveIQB7zRQ2pmVjeI4evwJ+g=="], "electrobun-counter-example": ["electrobun-counter-example@workspace:examples/electrobun-counter"], @@ -677,7 +679,9 @@ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "electrobun-counter-example/electrobun": ["electrobun@1.15.1", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-FJ7XaTzbCPuH2ABK615WJyrfacoMdYR33tHJ0j/2JIw2srK4lHN9iSjPpvRVL1UveIQB7zRQ2pmVjeI4evwJ+g=="], + "counter-example/electrobun": ["electrobun@1.14.4", "", { "dependencies": { "@types/bun": "^1.3.8", "archiver": "^7.0.1", "png-to-ico": "^2.1.8", "rcedit": "^4.0.1" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-ul8S2dK7IXAbvHGZTb9sFnG/mq9T4VkRJ1OYX59kGPIoTkM8WKJXtwTENIyBwzjEi8Yq12WQvSGHO2ezV9zjCw=="], + + "electrobun/three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -685,14 +689,14 @@ "png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], + "pong-example/electrobun": ["electrobun@1.14.4", "", { "dependencies": { "@types/bun": "^1.3.8", "archiver": "^7.0.1", "png-to-ico": "^2.1.8", "rcedit": "^4.0.1" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-ul8S2dK7IXAbvHGZTb9sFnG/mq9T4VkRJ1OYX59kGPIoTkM8WKJXtwTENIyBwzjEi8Yq12WQvSGHO2ezV9zjCw=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "electrobun-counter-example/electrobun/three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], - "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], diff --git a/examples/battlestation/README.md b/examples/battlestation/README.md new file mode 100644 index 0000000..ce7fcda --- /dev/null +++ b/examples/battlestation/README.md @@ -0,0 +1,43 @@ +# Battlestation + +Battlestation is the heavier Electrobun follow-up after the counter proofs. + +It now supports: + +- web / Tauri with the original DOM HUD + audio path +- Electrobun `BrowserWindow` with the same web-first path +- Electrobun `GpuWindow` with the shared mission loop and native Three WebGPU rendering + +## Run + +BrowserWindow shell: + +```bash +bun run dev:electrobun:browser +``` + +GPUWindow shell: + +```bash +bun run dev:electrobun:gpu +``` + +## Current GPUWindow limitations + +The GPUWindow path is intentionally narrower than the browser shell: + +- no HTML HUD overlay in native mode +- no Web Audio tone playback in native mode yet +- mission status is surfaced through the window title and console alerts +- keyboard + mouse controls are implemented directly through Electrobun window/screen APIs +- native title-bar chrome may slightly skew top-edge mouse targeting until Electrobun exposes content-area coordinates + +That split is deliberate: the mission orchestration is now shared, while rendering/input are runtime-specific adapters. + +## Build + +```bash +bun run build:web +bun run build:electrobun:browser +bun run build:electrobun:gpu +``` diff --git a/examples/battlestation/electrobun.config.ts b/examples/battlestation/electrobun.config.ts index 22c68d9..cab2596 100644 --- a/examples/battlestation/electrobun.config.ts +++ b/examples/battlestation/electrobun.config.ts @@ -1,16 +1,35 @@ +import type { ElectrobunConfig } from "electrobun"; + +const renderMode = process.env.GAMETAU_ELECTROBUN_RENDER_MODE === "gpu" + ? "gpu" + : "browser"; +const isGpu = renderMode === "gpu"; + export default { app: { - name: "Battlestation (Electrobun Showcase)", + name: isGpu ? "Battlestation (GPUWindow)" : "Battlestation (Electrobun Showcase)", identifier: "dev.gametau.battlestation.showcase", version: "0.1.0", }, build: { bun: { - entrypoint: "src/bun/index.js", + entrypoint: isGpu ? "src/bun/gpu.ts" : "src/bun/browser.ts", }, copy: { "dist/index.html": "views/main/index.html", "dist/assets": "views/main/assets", }, + mac: { + bundleCEF: !isGpu, + bundleWGPU: isGpu, + }, + linux: { + bundleCEF: !isGpu, + bundleWGPU: isGpu, + }, + win: { + bundleCEF: !isGpu, + bundleWGPU: isGpu, + }, }, -}; +} satisfies ElectrobunConfig; diff --git a/examples/battlestation/package.json b/examples/battlestation/package.json index 04107b7..6673521 100644 --- a/examples/battlestation/package.json +++ b/examples/battlestation/package.json @@ -5,23 +5,29 @@ "type": "module", "scripts": { "dev": "vite", - "dev:electrobun": "concurrently -k -n WEB,APP \"vite\" \"electrobun dev\"", + "dev:electrobun": "bun run dev:electrobun:browser", + "dev:electrobun:browser": "concurrently -k -n WEB,APP \"vite\" \"node ./node_modules/electrobun/bin/electrobun.cjs dev\"", + "dev:electrobun:gpu": "cross-env GAMETAU_ELECTROBUN_RENDER_MODE=gpu node ./node_modules/electrobun/bin/electrobun.cjs dev", "dev:tauri": "tauri dev", "build:web": "vite build", - "build:electrobun": "bun run build:web && electrobun build", + "build:electrobun": "bun run build:electrobun:browser", + "build:electrobun:browser": "bun run build:web && node ./node_modules/electrobun/bin/electrobun.cjs build", + "build:electrobun:gpu": "bun run build:web && cross-env GAMETAU_ELECTROBUN_RENDER_MODE=gpu node ./node_modules/electrobun/bin/electrobun.cjs build", "build:desktop": "tauri build", "preview": "vite preview" }, "dependencies": { - "electrobun": "^1.14.4", + "electrobun": "^1.15.1", "three": "^0.172.0", "webtau": "workspace:*" }, "devDependencies": { "@tauri-apps/api": "^2.0.0", "@tauri-apps/cli": "^2.0.0", + "@types/bun": "^1.3.9", "@types/three": "^0.172.0", "concurrently": "^9.2.1", + "cross-env": "^7.0.3", "typescript": "^5.8.0", "vite": "^6.0.0", "webtau-vite": "workspace:*" diff --git a/examples/battlestation/src/bun/browser.ts b/examples/battlestation/src/bun/browser.ts new file mode 100644 index 0000000..3349baf --- /dev/null +++ b/examples/battlestation/src/bun/browser.ts @@ -0,0 +1,9 @@ +import { BrowserWindow } from "electrobun/bun"; + +const isProduction = Bun.env.NODE_ENV === "production"; +const url = isProduction ? "views://main/index.html" : "http://localhost:1420"; + +new BrowserWindow({ + title: "gametau battlestation (electrobun showcase)", + url, +}); diff --git a/examples/battlestation/src/bun/gpu.ts b/examples/battlestation/src/bun/gpu.ts new file mode 100644 index 0000000..bb0ae7d --- /dev/null +++ b/examples/battlestation/src/bun/gpu.ts @@ -0,0 +1,141 @@ +import { configure } from "webtau"; +import { GpuWindow, Screen } from "electrobun/bun"; +import { FALLBACK_MISSION, FALLBACK_THEME } from "../game/config"; +import { createDefenseSceneGpu } from "../game/scene-gpu"; +import { startBattlestationRuntime } from "../game/runtime"; + +// Mixed JS/native key codes while Electrobun's Bun-side keyboard surface settles: +// 37/39 = ArrowLeft/ArrowRight, 65/68 = A/D, 123/124 = native left/right on macOS, +// 13/32 = Enter/Space, 77 = M. +const LEFT_KEYS = new Set([37, 65, 123]); +const RIGHT_KEYS = new Set([39, 68, 124]); +const FIRE_KEYS = new Set([13, 32]); +const MUTE_KEYS = new Set([77]); + +function createSilentAudioAdapter() { + return { + setMasterVolume() {}, + setMuted() {}, + async resume() {}, + async playTone() {}, + }; +} + +async function loadRuntime() { + configure({ + loadWasm: async () => { + const wasm = await import("../wasm/battlestation_wasm"); + await wasm.default(); + wasm.init(); + return wasm; + }, + }); +} + +async function main() { + await loadRuntime(); + + const window = new GpuWindow({ + title: "A130 Defense (GPUWindow)", + frame: { x: 120, y: 120, width: 960, height: 960 }, + titleBarStyle: "default", + transparent: false, + }); + + const pressedKeys = new Set(); + let mouseLatch = false; + let lastProfile = "runs 0 / best 0"; + let lastAlert = "Stand by."; + + window.on("keyDown", (event) => { + const data = event as { data?: { keyCode?: number } }; + if (typeof data.data?.keyCode === "number") { + pressedKeys.add(data.data.keyCode); + } + }); + window.on("keyUp", (event) => { + const data = event as { data?: { keyCode?: number } }; + if (typeof data.data?.keyCode === "number") { + pressedKeys.delete(data.data.keyCode); + } + }); + + const scene = createDefenseSceneGpu(window, FALLBACK_THEME.scene); + + const stop = await startBattlestationRuntime({ + mission: FALLBACK_MISSION, + theme: FALLBACK_THEME, + scene, + audio: createSilentAudioAdapter(), + controls: { + getSelectionAxis() { + if ([...LEFT_KEYS].some((key) => pressedKeys.has(key))) return -1; + if ([...RIGHT_KEYS].some((key) => pressedKeys.has(key))) return 1; + return 0; + }, + getFirePressed() { + return [...FIRE_KEYS].some((key) => pressedKeys.has(key)); + }, + getMutePressed() { + return [...MUTE_KEYS].some((key) => pressedKeys.has(key)); + }, + drainPointerTargets() { + const buttons = Screen.getMouseButtons(); + const leftDown = (buttons & 1n) === 1n; + const targets: Array<{ x: number; y: number }> = []; + + if (leftDown && !mouseLatch) { + const cursor = Screen.getCursorScreenPoint(); + const frame = window.getFrame(); + // Electrobun currently exposes the outer window frame here, not an + // explicit content-rect API. That means native chrome can slightly + // skew top-edge click mapping until a content-area coordinate surface + // is available on the Bun side. + const inside = cursor.x >= frame.x + && cursor.x <= frame.x + frame.width + && cursor.y >= frame.y + && cursor.y <= frame.y + frame.height; + if (inside && frame.width > 0 && frame.height > 0) { + targets.push({ + x: ((cursor.x - frame.x) / frame.width) * 640, + y: ((cursor.y - frame.y) / frame.height) * 640, + }); + } + } + + mouseLatch = leftDown; + return targets; + }, + dispose() {}, + }, + hud: { + updateMission(view) { + const target = view.selected_contact_id === null ? "NONE" : `#${view.selected_contact_id}`; + window.setTitle( + `A130 Defense | ${view.mission_state} | score ${view.score} | integrity ${view.integrity} | target ${target}`, + ); + }, + updateProfile(profile) { + lastProfile = `runs ${profile.missionsRun} / best ${profile.bestScore}`; + }, + setAlert(text) { + lastAlert = text; + console.log(text); + }, + setAlertLog(lines) { + if (lines.length > 0) { + console.log(lines[0]); + } + }, + setStatus(text) { + window.setTitle(`${text} | ${lastProfile} | ${lastAlert}`); + }, + }, + }); + + window.on("close", () => { + stop(); + }); +} + +main().catch(console.error); diff --git a/examples/battlestation/src/bun/index.js b/examples/battlestation/src/bun/index.js index 55dc31f..8f66120 100644 --- a/examples/battlestation/src/bun/index.js +++ b/examples/battlestation/src/bun/index.js @@ -1,9 +1 @@ -import { BrowserWindow } from "electrobun/bun"; - -const isProduction = process.env.NODE_ENV === "production"; -const url = isProduction ? "views://main/index.html" : "http://localhost:1420"; - -new BrowserWindow({ - title: "gametau battlestation (electrobun showcase)", - url, -}); +import "./browser"; diff --git a/examples/battlestation/src/game/config.ts b/examples/battlestation/src/game/config.ts new file mode 100644 index 0000000..1c4c8eb --- /dev/null +++ b/examples/battlestation/src/game/config.ts @@ -0,0 +1,59 @@ +export interface MissionStubConfig { + callsign: string; + sector: string; + objective: string; + intel: string; + tacticalProtocol: string[]; +} + +export interface ThemeConfig { + scene: SceneTheme; + audio: { + hitHz: number; + killConfirmHz: number; + missHz: number; + integrityLossHz: number; + criticalAlertHz: number; + }; + ui: { + criticalIntegrityThreshold: number; + }; +} + +export interface SceneTheme { + background: string; + grid: string; + selected: string; + shipColor: string; + friendlyColor: string; + contactPulseSpeed?: number; + contactPulseAmount?: number; +} + +export const FALLBACK_MISSION: MissionStubConfig = { + callsign: "LANCE-130", + sector: "Outer Grid Delta-7", + objective: "Defend friendly cluster from approaching hostiles.", + intel: "No mission config found. Using fallback profile.", + tacticalProtocol: [], +}; + +export const FALLBACK_THEME: ThemeConfig = { + scene: { + background: "#030608", + grid: "#1a3040", + selected: "#ffe680", + shipColor: "#00e5ff", + friendlyColor: "#22cc44", + }, + audio: { + hitHz: 660, + killConfirmHz: 880, + missHz: 220, + integrityLossHz: 160, + criticalAlertHz: 120, + }, + ui: { + criticalIntegrityThreshold: 25, + }, +}; diff --git a/examples/battlestation/src/game/runtime.ts b/examples/battlestation/src/game/runtime.ts new file mode 100644 index 0000000..d274801 --- /dev/null +++ b/examples/battlestation/src/game/runtime.ts @@ -0,0 +1,205 @@ +import { + cycleTarget, + fireAt, + fireShot, + getMissionView, + tickMission, + type MissionView, +} from "../services/backend"; +import { publishAlert, type AlertLevel } from "../services/comms"; +import { + loadOperatorProfile, + recordMissionOutcome, + saveOperatorProfile, + type OperatorProfile, +} from "../services/profile"; +import type { PlayToneOptions } from "webtau/audio"; +import type { ThemeConfig, MissionStubConfig } from "./config"; + +export type ExplosionType = "hit" | "kill" | "breach"; + +export interface DefenseSceneAdapter { + render(view: MissionView): void; + addProjectile(target: { x: number; y: number }): void; + addExplosion(x: number, y: number, type: ExplosionType): void; + dispose(): void; +} + +export interface BattlestationAudioAdapter { + setMasterVolume(value: number): void; + setMuted(muted: boolean): void; + resume(): Promise | void; + playTone( + frequencyHz: number, + durationMs: number, + options: PlayToneOptions, + ): Promise | void; +} + +export interface BattlestationControlSource { + getSelectionAxis(): number; + getFirePressed(): boolean; + getMutePressed(): boolean; + drainPointerTargets(): Array<{ x: number; y: number }>; + dispose(): void; +} + +export interface BattlestationHudAdapter { + updateMission(view: MissionView): void; + updateProfile(profile: OperatorProfile): void; + setAlert(text: string): void; + setAlertLog(lines: string[]): void; + setStatus(text: string): void; +} + +export interface StartBattlestationRuntimeOptions { + mission: MissionStubConfig; + theme: ThemeConfig; + scene: DefenseSceneAdapter; + controls: BattlestationControlSource; + hud: BattlestationHudAdapter; + audio: BattlestationAudioAdapter; +} + +function axisDirection(value: number): number { + if (value <= -0.45) return -1; + if (value >= 0.45) return 1; + return 0; +} + +function alertSummary(level: AlertLevel, message: string): string { + return `[${level.toUpperCase()}] ${message}`; +} + +export async function startBattlestationRuntime( + options: StartBattlestationRuntimeOptions, +): Promise<() => void> { + const { mission, theme, scene, controls, hud, audio } = options; + + let profile = await loadOperatorProfile(); + audio.setMasterVolume(profile.masterVolume); + audio.setMuted(profile.muted); + hud.updateProfile(profile); + + const alertLog: string[] = []; + const pushAlert = async (level: AlertLevel, tick: number, message: string) => { + await publishAlert({ level, tick, message }); + hud.setAlert(alertSummary(level, message)); + alertLog.unshift(`T${tick}: ${message}`); + alertLog.splice(6); + hud.setAlertLog(alertLog); + }; + + let view = await getMissionView(); + let lastIntegrity = view.integrity; + let criticalAlertRaised = false; + let missionRecorded = false; + let selectionLatchTime = 0; + let fireLatch = false; + let muteLatch = false; + let inFlight = false; + + hud.updateMission(view); + scene.render(view); + await pushAlert("info", 0, `${mission.callsign} online in ${mission.sector}.`); + for (const line of mission.tacticalProtocol.slice(0, 2)) { + await pushAlert("info", view.tick, line); + } + + async function handleFireAt(x: number, y: number): Promise { + scene.addProjectile({ x, y }); + const outcome = await fireAt(x, y); + if (outcome.killed) { + await pushAlert("info", view.tick, outcome.summary); + void audio.playTone(theme.audio.killConfirmHz, 120, { type: "triangle", gain: 0.15 }); + scene.addExplosion(x, y, "kill"); + } else if (outcome.hit) { + await pushAlert("info", view.tick, outcome.summary); + void audio.playTone(theme.audio.hitHz, 80, { type: "triangle", gain: 0.12 }); + scene.addExplosion(x, y, "hit"); + } else { + await pushAlert("warning", view.tick, outcome.summary); + void audio.playTone(theme.audio.missHz, 140, { type: "sawtooth", gain: 0.17 }); + } + } + + const step = async () => { + if (inFlight) return; + inFlight = true; + try { + const now = performance.now(); + const selectionDirection = axisDirection(controls.getSelectionAxis()); + if (selectionDirection !== 0 && now - selectionLatchTime > 220) { + view = await cycleTarget(selectionDirection); + selectionLatchTime = now; + } + + const firePressed = controls.getFirePressed(); + if (firePressed && !fireLatch) { + const selected = view.contacts.find((contact) => contact.selected); + if (selected) { + await handleFireAt(selected.x, selected.y); + } else { + const outcome = await fireShot(); + await pushAlert("warning", view.tick, outcome.summary); + } + } + fireLatch = firePressed; + + const mutePressed = controls.getMutePressed(); + if (mutePressed && !muteLatch) { + profile = { ...profile, muted: !profile.muted }; + audio.setMuted(profile.muted); + await saveOperatorProfile(profile); + hud.updateProfile(profile); + await pushAlert("info", view.tick, profile.muted ? "Audio muted." : "Audio restored."); + } + muteLatch = mutePressed; + + for (const target of controls.drainPointerTargets()) { + await handleFireAt(target.x, target.y); + } + + view = await tickMission(0.1); + if (view.integrity < lastIntegrity) { + await pushAlert("warning", view.tick, "Integrity damage - enemy breached defense perimeter."); + void audio.playTone(theme.audio.integrityLossHz, 180, { type: "square", gain: 0.16 }); + scene.addExplosion(320, 320, "breach"); + } + lastIntegrity = view.integrity; + + if (view.integrity <= theme.ui.criticalIntegrityThreshold && !criticalAlertRaised) { + criticalAlertRaised = true; + await pushAlert("critical", view.tick, "Defense integrity entering critical threshold."); + void audio.playTone(theme.audio.criticalAlertHz, 260, { type: "square", gain: 0.2 }); + } else if (view.integrity > theme.ui.criticalIntegrityThreshold) { + criticalAlertRaised = false; + } + + if (view.mission_state === "FAILED" && !missionRecorded) { + missionRecorded = true; + profile = await recordMissionOutcome(profile, view); + hud.updateProfile(profile); + await pushAlert("critical", view.tick, "Mission failed. Reload to run a new operation."); + } + + hud.updateMission(view); + scene.render(view); + hud.setStatus(`${view.mission_state} - score ${view.score} - integrity ${view.integrity}`); + } catch (error) { + hud.setAlert(`Simulation error: ${String(error)}`); + } finally { + inFlight = false; + } + }; + + const timer = setInterval(() => { + void step(); + }, 100); + + return () => { + clearInterval(timer); + controls.dispose(); + scene.dispose(); + }; +} diff --git a/examples/battlestation/src/game/scene-gpu.ts b/examples/battlestation/src/game/scene-gpu.ts new file mode 100644 index 0000000..6a7f689 --- /dev/null +++ b/examples/battlestation/src/game/scene-gpu.ts @@ -0,0 +1,37 @@ +import { GpuWindow, webgpu } from "electrobun/bun"; +import { WebGPURenderer } from "three/webgpu"; +import type { SceneTheme } from "./config"; +import { createDefenseSceneWithRenderer } from "./scene"; + +export function createDefenseSceneGpu( + window: GpuWindow, + theme: Partial = {}, +) { + webgpu.install(); + const canvas = webgpu.utils.createCanvasShim(window); + const renderer = new WebGPURenderer({ + canvas: canvas as unknown as HTMLCanvasElement, + antialias: true, + }); + + return createDefenseSceneWithRenderer( + canvas as unknown as { + width: number; + height: number; + getBoundingClientRect(): { + left?: number; + top?: number; + width: number; + height: number; + }; + }, + renderer as unknown as { + setClearColor: (color: unknown, alpha?: number) => void; + setSize: (width: number, height: number, updateStyle?: boolean) => void; + render: (scene: unknown, camera: unknown) => void; + dispose: () => void; + }, + theme, + () => 1, + ); +} diff --git a/examples/battlestation/src/game/scene.ts b/examples/battlestation/src/game/scene.ts index 3aa63aa..41d113b 100644 --- a/examples/battlestation/src/game/scene.ts +++ b/examples/battlestation/src/game/scene.ts @@ -14,16 +14,25 @@ import { Vector3, WebGLRenderer, } from "three"; +import type { SceneTheme } from "./config"; import type { EnemyType, MissionView } from "../services/backend"; -export interface SceneTheme { - background: string; - grid: string; - selected: string; - shipColor: string; - friendlyColor: string; - contactPulseSpeed?: number; - contactPulseAmount?: number; +interface DefenseViewport { + width: number; + height: number; + getBoundingClientRect(): { + left?: number; + top?: number; + width: number; + height: number; + }; +} + +interface DefenseRenderer { + setClearColor(color: Color, alpha: number): void; + setSize(width: number, height: number, updateStyle?: boolean): void; + render(scene: Scene, camera: OrthographicCamera): void; + dispose(): void; } const DEFAULT_THEME: SceneTheme = { @@ -116,7 +125,12 @@ function makeCircleGeometry(r: number, segments: number): BufferGeometry { return geo; } -export function createDefenseScene(canvas: HTMLCanvasElement, theme: Partial = {}) { +export function createDefenseSceneWithRenderer( + viewport: DefenseViewport, + renderer: DefenseRenderer, + theme: Partial = {}, + getDevicePixelRatio: () => number = () => 1, +) { const colors = { ...DEFAULT_THEME, ...theme }; const pulse = { contactPulseSpeed: theme.contactPulseSpeed ?? PULSE_DEFAULTS.contactPulseSpeed, @@ -128,24 +142,18 @@ export function createDefenseScene(canvas: HTMLCanvasElement, theme: Partial syncRendererSize()); - resizeObserver.observe(canvas); // --- Camera (top-down, Y-flipped to match Canvas2D coords) --- const camera = new OrthographicCamera(-centerX, centerX, -centerY, centerY, 0.1, 200); @@ -320,6 +328,7 @@ export function createDefenseScene(canvas: HTMLCanvasElement, theme: Partial { const o = obj as Mesh | Line; if (o.geometry) o.geometry.dispose(); @@ -503,3 +511,13 @@ export function createDefenseScene(canvas: HTMLCanvasElement, theme: Partial = {}) { + const renderer = new WebGLRenderer({ canvas, antialias: true }); + return createDefenseSceneWithRenderer( + canvas, + renderer, + theme, + () => window.devicePixelRatio || 1, + ); +} diff --git a/examples/battlestation/src/index.ts b/examples/battlestation/src/index.ts index dae39ff..8c76d3c 100644 --- a/examples/battlestation/src/index.ts +++ b/examples/battlestation/src/index.ts @@ -1,76 +1,14 @@ -import { configure, isTauri } from "webtau"; +import { configure, getRuntimeInfo, isTauri } from "webtau"; +import { bootstrapElectrobunFromWindowBridge } from "webtau/adapters/electrobun"; import { getName, getTauriVersion, getVersion, setAppName, setAppVersion } from "webtau/app"; import { createAssetLoader } from "webtau/assets"; import { createAudioController } from "webtau/audio"; import { createInputController, type TouchPoint } from "webtau/input"; -import { createDefenseScene, type SceneTheme } from "./game/scene"; -import { - cycleTarget, - fireAt, - fireShot, - getMissionView, - tickMission, - type MissionView, -} from "./services/backend"; -import { publishAlert, subscribeAlerts, type AlertLevel } from "./services/comms"; -import { - loadOperatorProfile, - recordMissionOutcome, - saveOperatorProfile, - type OperatorProfile, -} from "./services/profile"; +import { FALLBACK_MISSION, FALLBACK_THEME, type MissionStubConfig, type ThemeConfig } from "./game/config"; +import { startBattlestationRuntime } from "./game/runtime"; +import { createDefenseScene } from "./game/scene"; -interface MissionStubConfig { - callsign: string; - sector: string; - objective: string; - intel: string; - tacticalProtocol: string[]; -} - -interface ThemeConfig { - scene: SceneTheme; - audio: { - hitHz: number; - killConfirmHz: number; - missHz: number; - integrityLossHz: number; - criticalAlertHz: number; - }; - ui: { - criticalIntegrityThreshold: number; - }; -} - -const FALLBACK_MISSION: MissionStubConfig = { - callsign: "LANCE-130", - sector: "Outer Grid Delta-7", - objective: "Defend friendly cluster from approaching hostiles.", - intel: "No mission config found. Using fallback profile.", - tacticalProtocol: [], -}; - -const FALLBACK_THEME: ThemeConfig = { - scene: { - background: "#030608", - grid: "#1a3040", - selected: "#ffe680", - shipColor: "#00e5ff", - friendlyColor: "#22cc44", - }, - audio: { - hitHz: 660, - killConfirmHz: 880, - missHz: 220, - integrityLossHz: 160, - criticalAlertHz: 120, - }, - ui: { - criticalIntegrityThreshold: 25, - }, -}; - -function updateHud(view: MissionView): void { +function updateHud(view: import("./services/backend").MissionView): void { document.getElementById("mission")!.textContent = view.mission_state; document.getElementById("integrity")!.textContent = String(view.integrity); document.getElementById("score")!.textContent = String(view.score); @@ -81,7 +19,7 @@ function updateHud(view: MissionView): void { view.selected_contact_id === null ? "NONE" : `#${view.selected_contact_id}`; } -function updateProfileHud(profile: OperatorProfile): void { +function updateProfileHud(profile: import("./services/profile").OperatorProfile): void { document.getElementById("profile")!.textContent = `runs ${profile.missionsRun} / best ${profile.bestScore}`; document.getElementById("audio-state")!.textContent = profile.muted ? "MUTED" : "ACTIVE"; @@ -117,7 +55,10 @@ async function main() { const alertLogEl = document.getElementById("alert-log")!; const canvas = document.getElementById("radar") as HTMLCanvasElement; - if (!isTauri()) { + if (bootstrapElectrobunFromWindowBridge()) { + const runtime = getRuntimeInfo(); + modeEl.textContent = `Electrobun (${runtime.capabilities.renderMode ?? "browser"})`; + } else if (!isTauri()) { modeEl.textContent = "WASM (web)"; configure({ loadWasm: async () => { @@ -143,9 +84,7 @@ async function main() { const assets = createAssetLoader(); const [mission, theme] = await Promise.all([ - assets - .loadJson("assets/mission-stub.json") - .catch(() => FALLBACK_MISSION), + assets.loadJson("assets/mission-stub.json").catch(() => FALLBACK_MISSION), assets.loadJson("assets/battlestation-theme.json").catch(() => FALLBACK_THEME), ]); objectiveEl.textContent = `${mission.callsign} • ${mission.objective}`; @@ -154,42 +93,12 @@ async function main() { const input = createInputController(); const audio = createAudioController(); - let profile = await loadOperatorProfile(); - audio.setMasterVolume(profile.masterVolume); - audio.setMuted(profile.muted); - updateProfileHud(profile); - const unlockAudio = () => { void audio.resume(); }; window.addEventListener("keydown", unlockAudio, { once: true }); window.addEventListener("pointerdown", unlockAudio, { once: true }); - const alertLog: string[] = []; - const renderAlertLog = () => { - alertLogEl.textContent = alertLog.join("\n"); - }; - const unlistenAlerts = await subscribeAlerts((alert) => { - alertEl.textContent = `[${alert.level.toUpperCase()}] ${alert.message}`; - alertLog.unshift(`T${alert.tick}: ${alert.message}`); - alertLog.splice(6); - renderAlertLog(); - }); - - await publishAlert({ - level: "info", - tick: 0, - message: `${mission.callsign} online in ${mission.sector}.`, - }); - - let view = await getMissionView(); - let lastIntegrity = view.integrity; - let criticalAlertRaised = false; - let missionRecorded = false; - updateHud(view); - scene.render(view); - - // --- Screen → logical coordinate mapping (640×640 world) --- function screenToLogical(clientX: number, clientY: number): { x: number; y: number } { const rect = canvas.getBoundingClientRect(); return { @@ -198,173 +107,62 @@ async function main() { }; } - // --- Mouse click-to-fire queue --- - const pendingFires: { x: number; y: number }[] = []; - + const pendingFires: Array<{ x: number; y: number }> = []; canvas.style.cursor = "crosshair"; - canvas.addEventListener("pointerdown", (e) => { - if (e.button === 0) { - pendingFires.push(screenToLogical(e.clientX, e.clientY)); + canvas.addEventListener("pointerdown", (event) => { + if (event.button === 0) { + pendingFires.push(screenToLogical(event.clientX, event.clientY)); } }); - // --- Process a single fire-at-position --- - async function handleFireAt(x: number, y: number): Promise { - scene.addProjectile({ x, y }); - const outcome = await fireAt(x, y); - if (outcome.killed) { - const level: AlertLevel = "info"; - await publishAlert({ level, tick: view.tick, message: outcome.summary }); - void audio.playTone(theme.audio.killConfirmHz, 120, { type: "triangle", gain: 0.15 }); - scene.addExplosion(x, y, "kill"); - } else if (outcome.hit) { - const level: AlertLevel = "info"; - await publishAlert({ level, tick: view.tick, message: outcome.summary }); - void audio.playTone(theme.audio.hitHz, 80, { type: "triangle", gain: 0.12 }); - scene.addExplosion(x, y, "hit"); - } else { - const level: AlertLevel = "warning"; - await publishAlert({ level, tick: view.tick, message: outcome.summary }); - void audio.playTone(theme.audio.missHz, 140, { type: "sawtooth", gain: 0.17 }); - } - } - - const actions: Array<"left" | "right" | "fire"> = []; - let selectionLatchTime = 0; - let fireLatch = false; - let muteLatch = false; - let inFlight = false; - - const step = async () => { - if (inFlight) return; - inFlight = true; - try { - const now = performance.now(); - const bounds = canvas.getBoundingClientRect(); - const touches = input.touches(); - const selectionDirection = resolveSelectionDirection( - input.keyAxis(["ArrowLeft", "a", "A"], ["ArrowRight", "d", "D"]), - input.gamepadAxis(0, { deadzone: 0.35 }), - touchSelectionAxis(touches, bounds), - ); - if (selectionDirection !== 0 && now - selectionLatchTime > 220) { - actions.push(selectionDirection < 0 ? "left" : "right"); - selectionLatchTime = now; - } - - // Keyboard/gamepad fire (touch/click fire goes through pointerdown -> pendingFires) - const firePressed = - input.isPressed(" ") || - input.isPressed("Enter") || - input.gamepadAxis(5, { deadzone: 0.4 }) > 0.5; - if (firePressed && !fireLatch) { - actions.push("fire"); - } - fireLatch = firePressed; - - const mutePressed = input.isPressed("m") || input.isPressed("M"); - if (mutePressed && !muteLatch) { - profile = { ...profile, muted: !profile.muted }; - audio.setMuted(profile.muted); - await saveOperatorProfile(profile); - updateProfileHud(profile); - await publishAlert({ - level: "info", - tick: view.tick, - message: profile.muted ? "Audio muted." : "Audio restored.", - }); - } - muteLatch = mutePressed; - - // --- Process mouse/touch positional fires --- - while (pendingFires.length > 0) { - const target = pendingFires.shift()!; - await handleFireAt(target.x, target.y); - } - - // --- Process keyboard/gamepad actions --- - while (actions.length > 0) { - const action = actions.shift(); - if (!action) continue; - - if (action === "left") { - view = await cycleTarget(-1); - } else if (action === "right") { - view = await cycleTarget(1); - } else { - // Keyboard fire: use selected target position - const selected = view.contacts.find((c) => c.selected); - if (selected) { - await handleFireAt(selected.x, selected.y); - } else { - const outcome = await fireShot(); - await publishAlert({ - level: "warning", - tick: view.tick, - message: outcome.summary, - }); - } - } - } - - view = await tickMission(0.1); - if (view.integrity < lastIntegrity) { - await publishAlert({ - level: "warning", - tick: view.tick, - message: "Integrity damage — enemy breached defense perimeter.", - }); - void audio.playTone(theme.audio.integrityLossHz, 180, { type: "square", gain: 0.16 }); - scene.addExplosion(320, 320, "breach"); - } - lastIntegrity = view.integrity; - - if (view.integrity <= theme.ui.criticalIntegrityThreshold && !criticalAlertRaised) { - criticalAlertRaised = true; - await publishAlert({ - level: "critical", - tick: view.tick, - message: "Defense integrity entering critical threshold.", - }); - void audio.playTone(theme.audio.criticalAlertHz, 260, { type: "square", gain: 0.2 }); - } else if (view.integrity > theme.ui.criticalIntegrityThreshold) { - criticalAlertRaised = false; - } - - if (view.mission_state === "FAILED" && !missionRecorded) { - missionRecorded = true; - profile = await recordMissionOutcome(profile, view); - updateProfileHud(profile); - await publishAlert({ - level: "critical", - tick: view.tick, - message: "Mission failed. Reload to run a new operation.", - }); - } - - updateHud(view); - scene.render(view); - } catch (error) { - alertEl.textContent = `Simulation error: ${String(error)}`; - } finally { - inFlight = false; - } - }; - - // Emit mission protocol guidance once the event pipeline is live. - for (const line of mission.tacticalProtocol.slice(0, 2)) { - await publishAlert({ level: "info", tick: view.tick, message: line }); - } - - const timer = setInterval(() => { - void step(); - }, 100); + const stop = await startBattlestationRuntime({ + mission, + theme, + scene, + audio, + controls: { + getSelectionAxis() { + const bounds = canvas.getBoundingClientRect(); + return resolveSelectionDirection( + input.keyAxis(["ArrowLeft", "a", "A"], ["ArrowRight", "d", "D"]), + input.gamepadAxis(0, { deadzone: 0.35 }), + touchSelectionAxis(input.touches(), bounds), + ); + }, + getFirePressed() { + return ( + input.isPressed(" ") + || input.isPressed("Enter") + || input.gamepadAxis(5, { deadzone: 0.4 }) > 0.5 + ); + }, + getMutePressed() { + return input.isPressed("m") || input.isPressed("M"); + }, + drainPointerTargets() { + return pendingFires.splice(0, pendingFires.length); + }, + dispose() { + input.destroy(); + }, + }, + hud: { + updateMission: updateHud, + updateProfile: updateProfileHud, + setAlert(text) { + alertEl.textContent = text; + }, + setAlertLog(lines) { + alertLogEl.textContent = lines.join("\n"); + }, + setStatus() { + // Browser/Tauri path already exposes a richer HUD overlay. + }, + }, + }); window.addEventListener("beforeunload", () => { - clearInterval(timer); - unlistenAlerts(); - input.destroy(); - scene.dispose(); + stop(); }); } diff --git a/examples/battlestation/src/types/electrobun-bun.d.ts b/examples/battlestation/src/types/electrobun-bun.d.ts new file mode 100644 index 0000000..5310cbf --- /dev/null +++ b/examples/battlestation/src/types/electrobun-bun.d.ts @@ -0,0 +1,44 @@ +export class BrowserWindow { + constructor(options: { title?: string; url: string }); +} + +export class GpuWindow { + constructor(options: { + title?: string; + frame: { x: number; y: number; width: number; height: number }; + titleBarStyle: "hidden" | "hiddenInset" | "default"; + transparent: boolean; + }); + + setTitle(title: string): void; + getFrame(): { x: number; y: number; width: number; height: number }; + on(name: string, handler: (event: unknown) => void): void; +} + +export const Screen: { + getCursorScreenPoint(): { x: number; y: number }; + getMouseButtons(): bigint; +}; + +export const webgpu: { + install(): void; + utils: { + createCanvasShim(window: GpuWindow): { + width: number; + height: number; + clientWidth: number; + clientHeight: number; + style: Record; + getContext(type: string): unknown; + getBoundingClientRect(): { + left: number; + top: number; + width: number; + height: number; + }; + addEventListener: (...args: unknown[]) => void; + removeEventListener: (...args: unknown[]) => void; + setAttribute: (...args: unknown[]) => void; + }; + }; +}; diff --git a/examples/battlestation/src/types/electrobun.d.ts b/examples/battlestation/src/types/electrobun.d.ts new file mode 100644 index 0000000..0e2f355 --- /dev/null +++ b/examples/battlestation/src/types/electrobun.d.ts @@ -0,0 +1,16 @@ +export interface ElectrobunConfig { + app: { + name: string; + identifier: string; + version: string; + }; + build: { + bun: { + entrypoint: string; + }; + copy?: Record; + mac?: { bundleCEF?: boolean; bundleWGPU?: boolean }; + linux?: { bundleCEF?: boolean; bundleWGPU?: boolean }; + win?: { bundleCEF?: boolean; bundleWGPU?: boolean }; + }; +} diff --git a/examples/battlestation/src/types/wasm.d.ts b/examples/battlestation/src/types/wasm.d.ts index 3c07904..fd3f547 100644 --- a/examples/battlestation/src/types/wasm.d.ts +++ b/examples/battlestation/src/types/wasm.d.ts @@ -1,4 +1,4 @@ -declare module "./wasm/battlestation_wasm" { +declare module "*battlestation_wasm" { const bootstrap: () => Promise; export default bootstrap; export function init(): void; diff --git a/examples/battlestation/tsconfig.json b/examples/battlestation/tsconfig.json index 97e6274..3cfc328 100644 --- a/examples/battlestation/tsconfig.json +++ b/examples/battlestation/tsconfig.json @@ -3,6 +3,12 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "baseUrl": ".", + "types": ["bun"], + "paths": { + "electrobun": ["./src/types/electrobun"], + "electrobun/bun": ["./src/types/electrobun-bun"] + }, "strict": true, "esModuleInterop": true, "skipLibCheck": true