@idosgames/mcp 0.1.15 → 0.1.17
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/package.json +1 -1
- package/registry/host.json +8 -4
- package/registry/index.json +28 -28
- package/registry/modules/board-game.json +7 -7
- package/registry/modules/game-hud.json +4 -4
- package/registry/modules/idle-rpg.json +7 -7
- package/registry/modules/voxelcraft.json +35 -23
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
|
@@ -45,17 +45,17 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@idosgames/core": "0.
|
|
49
|
-
"@idosgames/module-sdk": "0.
|
|
50
|
-
"@idosgames/react": "0.2.
|
|
51
|
-
"@idosgames/wallet": "0.
|
|
48
|
+
"@idosgames/core": "0.18.0",
|
|
49
|
+
"@idosgames/module-sdk": "0.4.1",
|
|
50
|
+
"@idosgames/react": "0.2.11",
|
|
51
|
+
"@idosgames/wallet": "0.5.0",
|
|
52
52
|
"@tanstack/react-query": "5.101.2",
|
|
53
53
|
"react": "19.2.7",
|
|
54
54
|
"three": "0.185.1",
|
|
55
55
|
"viem": "2.55.2",
|
|
56
56
|
"wagmi": "3.7.2"
|
|
57
57
|
},
|
|
58
|
-
"contentHash": "
|
|
58
|
+
"contentHash": "a7a360f0ecb5106bb5c5565d06b13845a8f1838bdb475d626f33ca89c73cec29",
|
|
59
59
|
"files": [
|
|
60
60
|
{
|
|
61
61
|
"path": "chrome.ts",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
"path": "game/BoardController.ts",
|
|
130
|
-
"content": "import * as THREE from \"three\";\nimport type { TileInfo } from \"../data/boardConfig\";\n\nconst TILE_COLORS: Record<string, number> = {\n Chance: 0xf0a020,\n Reward: 0x4caf50,\n Special: 0x9c5cff,\n RandomAction: 0xe8534e,\n Shield: 0x4a90e2,\n};\nconst DEFAULT_TILE_COLOR = 0x6b6b7a;\n\n// Glyphs drawn on top of non-reward tiles so the board reads at a glance.\nconst TILE_SYMBOLS: Record<string, string> = {\n Chance: \"?\",\n Special: \"★\",\n RandomAction: \"✖\",\n Shield: \"✚\",\n};\n\nconst BUILDING_COLORS = [\n 0x6c5ce7, 0x4caf50, 0xe8534e, 0xf0a020, 0x4a90e2, 0x42c9c2,\n];\n\nconst TOKEN_Y = 0.85;\nconst TILE_Y = 0.13;\nconst STEP_MS = 240;\nconst HIGHLIGHT_MS = 850;\nconst BODY_H = 1.0;\nconst DICE_MS = 950;\nconst DICE_BASE_Y = 3.2;\nconst DICE_DROP = 3.4;\n\n// Standard bounce-out easing (0 → 1 with decaying bounces) for the dice toss.\nfunction bounceOut(t: number): number {\n const n1 = 7.5625;\n const d1 = 2.75;\n if (t < 1 / d1) return n1 * t * t;\n if (t < 2 / d1) {\n const u = t - 1.5 / d1;\n return n1 * u * u + 0.75;\n }\n if (t < 2.5 / d1) {\n const u = t - 2.25 / d1;\n return n1 * u * u + 0.9375;\n }\n const u = t - 2.625 / d1;\n return n1 * u * u + 0.984375;\n}\n\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z. Assign pip values so opposite faces sum to 7.\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\n// Resting rotation that brings each value's face to the top (+Y).\nconst REST_ROTATION: Record<number, [number, number, number]> = {\n 1: [-Math.PI / 2, 0, 0],\n 2: [0, 0, Math.PI / 2],\n 3: [0, 0, 0],\n 4: [Math.PI, 0, 0],\n 5: [0, 0, -Math.PI / 2],\n 6: [Math.PI / 2, 0, 0],\n};\n\nconst PIP_LAYOUTS: Record<number, string[]> = {\n 1: [\"C\"],\n 2: [\"TL\", \"BR\"],\n 3: [\"TL\", \"C\", \"BR\"],\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\n};\nconst PIP_COORDS: Record<string, [number, number]> = {\n TL: [0.28, 0.28],\n TR: [0.72, 0.28],\n ML: [0.28, 0.5],\n C: [0.5, 0.5],\n MR: [0.72, 0.5],\n BL: [0.28, 0.72],\n BR: [0.72, 0.72],\n};\n\ninterface Tween {\n from: THREE.Vector3;\n to: THREE.Vector3;\n start: number;\n resolve: () => void;\n}\n\ninterface DiceTween {\n entries: {\n mesh: THREE.Mesh;\n from: THREE.Euler;\n to: THREE.Euler;\n rest: THREE.Euler;\n }[];\n start: number;\n resolve: () => void;\n}\n\ninterface Building {\n body: THREE.Mesh;\n roof: THREE.Mesh;\n}\n\n/**\n * Three.js renderer for the board: a square loop of tiles (colored + glyph-marked by type), the\n * player's city as towers that grow with their level, an animated token, and a pair of pip dice that\n * tumble to the rolled total. Engine-only — it knows nothing about the SDK.\n */\nexport class BoardController {\n private readonly renderer: THREE.WebGLRenderer;\n private readonly scene: THREE.Scene;\n private readonly camera: THREE.PerspectiveCamera;\n private readonly token: THREE.Mesh;\n private readonly tileGroup = new THREE.Group();\n private readonly cityGroup = new THREE.Group();\n private readonly lookAt = new THREE.Vector3(0, 0, 0);\n private readonly diceTextures: THREE.CanvasTexture[] = [];\n private readonly diceMaterials: THREE.MeshStandardMaterial[] = [];\n private readonly dice: THREE.Mesh[] = [];\n private readonly tileDecalTextures = new Map<string, THREE.CanvasTexture>();\n\n private tilePositions: THREE.Vector3[] = [];\n private tileMeshes: THREE.Mesh[] = [];\n private tileColors: THREE.Color[] = [];\n private buildings: Building[] = [];\n private tween: Tween | null = null;\n private diceTween: DiceTween | null = null;\n private highlight: { index: number; start: number } | null = null;\n private frame = 0;\n private disposed = false;\n private running = true;\n private resizeObserver?: ResizeObserver;\n\n private readonly loop = (): void => {\n if (this.disposed) return;\n this.frame = requestAnimationFrame(this.loop);\n\n if (this.tween) {\n const t = Math.min(1, (performance.now() - this.tween.start) / STEP_MS);\n const e = t * t * (3 - 2 * t); // smoothstep\n const { from, to } = this.tween;\n this.token.position.x = from.x + (to.x - from.x) * e;\n this.token.position.z = from.z + (to.z - from.z) * e;\n this.token.position.y = TOKEN_Y + Math.sin(t * Math.PI) * 0.6;\n if (t >= 1) {\n this.token.position.set(to.x, TOKEN_Y, to.z);\n const done = this.tween.resolve;\n this.tween = null;\n done();\n }\n }\n\n this.updateDice();\n this.updateHighlight();\n this.token.rotation.y += 0.02;\n\n // Gentle camera lean: drift the look-at point a fraction toward the token.\n this.lookAt.lerp(\n new THREE.Vector3(\n this.token.position.x * 0.35,\n 0,\n this.token.position.z * 0.35,\n ),\n 0.05,\n );\n this.camera.lookAt(this.lookAt);\n\n this.renderer.render(this.scene, this.camera);\n };\n\n constructor(private readonly host: HTMLElement) {\n this.renderer = new THREE.WebGLRenderer({ antialias: true });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.domElement.style.display = \"block\";\n host.appendChild(this.renderer.domElement);\n\n this.scene = new THREE.Scene();\n this.scene.background = new THREE.Color(0x063d99);\n\n this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);\n this.camera.position.set(0, 24, 19);\n this.camera.lookAt(0, 0, 0);\n\n const ambient = new THREE.AmbientLight(0xffffff, 0.75);\n const dir = new THREE.DirectionalLight(0xffffff, 0.85);\n dir.position.set(12, 22, 8);\n this.scene.add(ambient, dir, this.tileGroup, this.cityGroup);\n\n const tokenMat = new THREE.MeshStandardMaterial({\n color: 0xffd479,\n emissive: 0x6b5a1f,\n });\n this.token = new THREE.Mesh(\n new THREE.SphereGeometry(0.5, 24, 16),\n tokenMat,\n );\n this.token.position.set(0, TOKEN_Y, 0);\n this.scene.add(this.token);\n\n this.buildDice();\n\n this.resize();\n this.resizeObserver = new ResizeObserver(() => {\n this.resize();\n });\n this.resizeObserver.observe(host);\n\n this.loop();\n }\n\n // ---- dice ----\n\n private buildDice(): void {\n const textureByValue: Record<number, THREE.CanvasTexture> = {};\n for (let v = 1; v <= 6; v++) {\n const texture = new THREE.CanvasTexture(this.makePipCanvas(v));\n this.diceTextures.push(texture);\n textureByValue[v] = texture;\n }\n for (const value of FACE_VALUES) {\n this.diceMaterials.push(\n new THREE.MeshStandardMaterial({\n map: textureByValue[value],\n color: 0xffffff,\n }),\n );\n }\n\n const geo = new THREE.BoxGeometry(1.2, 1.2, 1.2);\n for (let i = 0; i < 2; i++) {\n const die = new THREE.Mesh(geo, this.diceMaterials);\n die.position.set(i === 0 ? -1.0 : 1.0, 3.2, 0);\n const rest = REST_ROTATION[3] ?? [0, 0, 0];\n die.rotation.set(rest[0], 0, rest[2]);\n this.scene.add(die);\n this.dice.push(die);\n }\n }\n\n private makePipCanvas(value: number): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = 128;\n canvas.height = 128;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return canvas;\n const s = canvas.width;\n ctx.fillStyle = \"#f6f3ff\";\n ctx.fillRect(0, 0, s, s);\n ctx.strokeStyle = \"#d9d2f0\";\n ctx.lineWidth = 4;\n ctx.strokeRect(4, 4, s - 8, s - 8);\n ctx.fillStyle = \"#052e7e\";\n for (const slot of PIP_LAYOUTS[value] ?? []) {\n const coord = PIP_COORDS[slot];\n if (!coord) continue;\n ctx.beginPath();\n ctx.arc(coord[0] * s, coord[1] * s, s * 0.1, 0, Math.PI * 2);\n ctx.fill();\n }\n return canvas;\n }\n\n private splitTotal(total: number): [number, number] {\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\n const min1 = Math.max(1, clamped - 6);\n const max1 = Math.min(6, clamped - 1);\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\n return [v1, clamped - v1];\n }\n\n /** Tumble both dice and land them showing two faces that sum to the rolled total. */\n rollDice(total: number): Promise<void> {\n const values = this.splitTotal(total);\n const entries = this.dice.map((mesh, i) => {\n const r = REST_ROTATION[values[i] ?? 3] ?? [0, 0, 0];\n const rest = new THREE.Euler(r[0], 0, r[2]);\n const turns = (n: number): number => Math.PI * 2 * n;\n const to = new THREE.Euler(\n rest.x + turns(2 + i),\n rest.y + turns(2),\n rest.z + turns(2 + i),\n );\n return { mesh, from: mesh.rotation.clone(), to, rest };\n });\n return new Promise<void>((resolve) => {\n this.diceTween = { entries, start: performance.now(), resolve };\n });\n }\n\n private updateDice(): void {\n if (!this.diceTween) return;\n const t = Math.min(1, (performance.now() - this.diceTween.start) / DICE_MS);\n const e = 1 - Math.pow(1 - t, 3); // easeOutCubic for the tumble\n const y = DICE_BASE_Y + DICE_DROP * (1 - bounceOut(t)); // drop + bounce for the toss\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.set(\n entry.from.x + (entry.to.x - entry.from.x) * e,\n entry.from.y + (entry.to.y - entry.from.y) * e,\n entry.from.z + (entry.to.z - entry.from.z) * e,\n );\n entry.mesh.position.y = y;\n }\n if (t >= 1) {\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.copy(entry.rest);\n entry.mesh.position.y = DICE_BASE_Y;\n }\n const done = this.diceTween.resolve;\n this.diceTween = null;\n done();\n }\n }\n\n // ---- board ----\n\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.tilePositions = [];\n this.tileMeshes = [];\n this.tileColors = [];\n this.buildings = [];\n\n const count = tiles.length;\n const perSide = Math.max(1, Math.round(count / 4));\n const spacing = 1.4;\n const extent = perSide * spacing;\n const half = extent / 2;\n\n for (const tile of tiles) {\n const pos = this.ringPosition(tile.index, perSide, extent, half);\n const colorHex = TILE_COLORS[tile.type] ?? DEFAULT_TILE_COLOR;\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.1, 0.25, 1.1),\n new THREE.MeshStandardMaterial({ color: colorHex }),\n );\n mesh.position.set(pos.x, TILE_Y, pos.z);\n\n const symbol = TILE_SYMBOLS[tile.type];\n if (symbol) {\n const decal = new THREE.Mesh(\n new THREE.PlaneGeometry(0.7, 0.7),\n new THREE.MeshBasicMaterial({\n map: this.getDecalTexture(tile.type, symbol),\n transparent: true,\n }),\n );\n decal.rotation.x = -Math.PI / 2;\n decal.position.set(0, 0.14, 0);\n mesh.add(decal);\n }\n\n this.tileGroup.add(mesh);\n this.tilePositions[tile.index] = new THREE.Vector3(pos.x, TOKEN_Y, pos.z);\n this.tileMeshes[tile.index] = mesh;\n this.tileColors[tile.index] = new THREE.Color(colorHex);\n }\n\n const startX = -((buildingCount - 1) * 1.7) / 2;\n for (let i = 0; i < buildingCount; i++) {\n const color = BUILDING_COLORS[i % BUILDING_COLORS.length] ?? 0x6c5ce7;\n const body = new THREE.Mesh(\n new THREE.BoxGeometry(0.9, BODY_H, 0.9),\n new THREE.MeshStandardMaterial({ color }),\n );\n const roof = new THREE.Mesh(\n new THREE.BoxGeometry(1.05, 0.22, 1.05),\n new THREE.MeshStandardMaterial({\n color: new THREE.Color(color).multiplyScalar(0.6),\n }),\n );\n body.position.set(startX + i * 1.7, BODY_H / 2, 0);\n roof.position.set(startX + i * 1.7, BODY_H + 0.11, 0);\n this.cityGroup.add(body, roof);\n this.buildings[i] = { body, roof };\n }\n }\n\n private getDecalTexture(type: string, symbol: string): THREE.CanvasTexture {\n const cached = this.tileDecalTextures.get(type);\n if (cached) return cached;\n const canvas = document.createElement(\"canvas\");\n canvas.width = 96;\n canvas.height = 96;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.clearRect(0, 0, 96, 96);\n ctx.fillStyle = \"rgba(20,16,40,0.85)\";\n ctx.font = \"bold 64px system-ui, sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(symbol, 48, 52);\n }\n const texture = new THREE.CanvasTexture(canvas);\n this.tileDecalTextures.set(type, texture);\n return texture;\n }\n\n private ringPosition(\n index: number,\n perSide: number,\n extent: number,\n half: number,\n ): THREE.Vector3 {\n const side = Math.floor(index / perSide) % 4;\n const f = (index % perSide) / perSide;\n let x = 0;\n let z = 0;\n switch (side) {\n case 0:\n x = -half + f * extent;\n z = half;\n break;\n case 1:\n x = half;\n z = half - f * extent;\n break;\n case 2:\n x = half - f * extent;\n z = -half;\n break;\n default:\n x = -half;\n z = -half + f * extent;\n break;\n }\n return new THREE.Vector3(x, 0, z);\n }\n\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs its RAF). */\n setRunning(running: boolean): void {\n const shouldRun = running && !this.disposed;\n if (shouldRun === this.running) return;\n this.running = shouldRun;\n if (shouldRun) this.frame = requestAnimationFrame(this.loop);\n else cancelAnimationFrame(this.frame);\n }\n\n /** Snap the token to a tile with no animation. */\n setPosition(index: number): void {\n const p = this.tilePositions[index];\n if (p) this.token.position.set(p.x, TOKEN_Y, p.z);\n }\n\n /**\n * What the scene currently shows. Read by the module's agent surface (`agent.ts`): a rendered\n * board is a single canvas, so this is the only way the AI Coder's agent can tell where the\n * token stands and whether the board was loaded at all.\n */\n describeScene(): {\n running: boolean;\n tileCount: number;\n buildingCount: number;\n token: { x: number; z: number };\n } {\n return {\n running: this.running,\n tileCount: this.tilePositions.length,\n buildingCount: this.buildings.length,\n token: {\n x: Math.round(this.token.position.x * 100) / 100,\n z: Math.round(this.token.position.z * 100) / 100,\n },\n };\n }\n\n /** Animate the token through the given tile indices, one hop at a time. */\n async moveToken(path: number[]): Promise<void> {\n for (const idx of path) {\n const to = this.tilePositions[idx];\n if (!to) continue;\n await this.tweenTo(to);\n }\n }\n\n /** Pulse a tile (emissive + lift) to mark where the token landed. */\n highlightTile(index: number): void {\n if (this.tileMeshes[index])\n this.highlight = { index, start: performance.now() };\n }\n\n /** Grow the city towers to reflect each building's level. */\n setBuildingLevels(levels: number[]): void {\n for (let i = 0; i < this.buildings.length; i++) {\n const building = this.buildings[i];\n if (!building) continue;\n const level = levels[i] ?? 0;\n const scaleY = 0.5 + level * 0.9;\n const height = BODY_H * scaleY;\n building.body.scale.y = scaleY;\n building.body.position.y = height / 2;\n building.roof.position.y = height + 0.11;\n }\n }\n\n private updateHighlight(): void {\n if (!this.highlight) return;\n const mesh = this.tileMeshes[this.highlight.index];\n const baseColor = this.tileColors[this.highlight.index];\n if (!mesh || !baseColor) {\n this.highlight = null;\n return;\n }\n const mat = mesh.material as THREE.MeshStandardMaterial;\n const t = (performance.now() - this.highlight.start) / HIGHLIGHT_MS;\n if (t >= 1) {\n mat.emissive.setHex(0x000000);\n mat.emissiveIntensity = 1;\n mesh.position.y = TILE_Y;\n this.highlight = null;\n return;\n }\n const pulse = Math.sin(Math.min(1, t) * Math.PI); // 0 → 1 → 0\n mat.emissive.copy(baseColor);\n mat.emissiveIntensity = pulse * 0.9;\n mesh.position.y = TILE_Y + pulse * 0.35;\n }\n\n private tweenTo(to: THREE.Vector3): Promise<void> {\n return new Promise<void>((resolve) => {\n this.tween = {\n from: this.token.position.clone(),\n to: to.clone(),\n start: performance.now(),\n resolve,\n };\n });\n }\n\n private resize(): void {\n const w = this.host.clientWidth || 640;\n const h = this.host.clientHeight || 640;\n this.renderer.setSize(w, h, false);\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n }\n\n private disposeGroup(group: THREE.Group): void {\n group.traverse((obj) => {\n if (obj instanceof THREE.Mesh) {\n obj.geometry.dispose();\n const material = obj.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material.dispose();\n }\n });\n group.clear();\n }\n\n destroy(): void {\n this.disposed = true;\n cancelAnimationFrame(this.frame);\n this.resizeObserver?.disconnect();\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.token.geometry.dispose();\n (this.token.material as THREE.Material).dispose();\n if (this.dice[0]) this.dice[0].geometry.dispose();\n this.diceMaterials.forEach((m) => m.dispose());\n this.diceTextures.forEach((t) => t.dispose());\n this.tileDecalTextures.forEach((t) => t.dispose());\n this.renderer.dispose();\n this.renderer.domElement.remove();\n }\n}\n"
|
|
130
|
+
"content": "import * as THREE from \"three\";\nimport type { TileInfo } from \"../data/boardConfig\";\n\nconst TILE_COLORS: Record<string, number> = {\n Chance: 0xf0a020,\n Reward: 0x4caf50,\n Special: 0x9c5cff,\n RandomAction: 0xe8534e,\n Shield: 0x4a90e2,\n};\nconst DEFAULT_TILE_COLOR = 0x6b6b7a;\n\n// Glyphs drawn on top of non-reward tiles so the board reads at a glance.\nconst TILE_SYMBOLS: Record<string, string> = {\n Chance: \"?\",\n Special: \"★\",\n RandomAction: \"✖\",\n Shield: \"✚\",\n};\n\nconst BUILDING_COLORS = [\n 0x6c5ce7, 0x4caf50, 0xe8534e, 0xf0a020, 0x4a90e2, 0x42c9c2,\n];\n\nconst TOKEN_Y = 0.85;\nconst TILE_Y = 0.13;\nconst STEP_MS = 240;\nconst HIGHLIGHT_MS = 850;\nconst BODY_H = 1.0;\nconst DICE_MS = 950;\nconst DICE_BASE_Y = 3.2;\nconst DICE_DROP = 3.4;\n\n// Standard bounce-out easing (0 → 1 with decaying bounces) for the dice toss.\nfunction bounceOut(t: number): number {\n const n1 = 7.5625;\n const d1 = 2.75;\n if (t < 1 / d1) return n1 * t * t;\n if (t < 2 / d1) {\n const u = t - 1.5 / d1;\n return n1 * u * u + 0.75;\n }\n if (t < 2.5 / d1) {\n const u = t - 2.25 / d1;\n return n1 * u * u + 0.9375;\n }\n const u = t - 2.625 / d1;\n return n1 * u * u + 0.984375;\n}\n\n// BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z. Assign pip values so opposite faces sum to 7.\nconst FACE_VALUES = [2, 5, 3, 4, 1, 6];\n// Resting rotation that brings each value's face to the top (+Y).\nconst REST_ROTATION: Record<number, [number, number, number]> = {\n 1: [-Math.PI / 2, 0, 0],\n 2: [0, 0, Math.PI / 2],\n 3: [0, 0, 0],\n 4: [Math.PI, 0, 0],\n 5: [0, 0, -Math.PI / 2],\n 6: [Math.PI / 2, 0, 0],\n};\n\nconst PIP_LAYOUTS: Record<number, string[]> = {\n 1: [\"C\"],\n 2: [\"TL\", \"BR\"],\n 3: [\"TL\", \"C\", \"BR\"],\n 4: [\"TL\", \"TR\", \"BL\", \"BR\"],\n 5: [\"TL\", \"TR\", \"C\", \"BL\", \"BR\"],\n 6: [\"TL\", \"TR\", \"ML\", \"MR\", \"BL\", \"BR\"],\n};\nconst PIP_COORDS: Record<string, [number, number]> = {\n TL: [0.28, 0.28],\n TR: [0.72, 0.28],\n ML: [0.28, 0.5],\n C: [0.5, 0.5],\n MR: [0.72, 0.5],\n BL: [0.28, 0.72],\n BR: [0.72, 0.72],\n};\n\ninterface Tween {\n from: THREE.Vector3;\n to: THREE.Vector3;\n start: number;\n resolve: () => void;\n}\n\ninterface DiceTween {\n entries: {\n mesh: THREE.Mesh;\n from: THREE.Euler;\n to: THREE.Euler;\n rest: THREE.Euler;\n }[];\n start: number;\n resolve: () => void;\n}\n\ninterface Building {\n body: THREE.Mesh;\n roof: THREE.Mesh;\n}\n\n/**\n * Three.js renderer for the board: a square loop of tiles (colored + glyph-marked by type), the\n * player's city as towers that grow with their level, an animated token, and a pair of pip dice that\n * tumble to the rolled total. Engine-only — it knows nothing about the SDK.\n */\nexport class BoardController {\n private readonly renderer: THREE.WebGLRenderer;\n private readonly scene: THREE.Scene;\n private readonly camera: THREE.PerspectiveCamera;\n private readonly token: THREE.Mesh;\n private readonly tileGroup = new THREE.Group();\n private readonly cityGroup = new THREE.Group();\n private readonly lookAt = new THREE.Vector3(0, 0, 0);\n private readonly diceTextures: THREE.CanvasTexture[] = [];\n private readonly diceMaterials: THREE.MeshStandardMaterial[] = [];\n private readonly dice: THREE.Mesh[] = [];\n private readonly tileDecalTextures = new Map<string, THREE.CanvasTexture>();\n\n private tilePositions: THREE.Vector3[] = [];\n private tileMeshes: THREE.Mesh[] = [];\n private tileColors: THREE.Color[] = [];\n private buildings: Building[] = [];\n private tween: Tween | null = null;\n private diceTween: DiceTween | null = null;\n private highlight: { index: number; start: number } | null = null;\n private frame = 0;\n private disposed = false;\n private running = true;\n private resizeObserver?: ResizeObserver;\n\n private readonly loop = (): void => {\n if (this.disposed) return;\n this.frame = requestAnimationFrame(this.loop);\n\n if (this.tween) {\n const t = Math.min(1, (performance.now() - this.tween.start) / STEP_MS);\n const e = t * t * (3 - 2 * t); // smoothstep\n const { from, to } = this.tween;\n this.token.position.x = from.x + (to.x - from.x) * e;\n this.token.position.z = from.z + (to.z - from.z) * e;\n this.token.position.y = TOKEN_Y + Math.sin(t * Math.PI) * 0.6;\n if (t >= 1) {\n this.token.position.set(to.x, TOKEN_Y, to.z);\n const done = this.tween.resolve;\n this.tween = null;\n done();\n }\n }\n\n this.updateDice();\n this.updateHighlight();\n this.token.rotation.y += 0.02;\n\n // Gentle camera lean: drift the look-at point a fraction toward the token.\n this.lookAt.lerp(\n new THREE.Vector3(\n this.token.position.x * 0.35,\n 0,\n this.token.position.z * 0.35,\n ),\n 0.05,\n );\n this.camera.lookAt(this.lookAt);\n\n this.renderer.render(this.scene, this.camera);\n };\n\n constructor(private readonly host: HTMLElement) {\n this.renderer = new THREE.WebGLRenderer({ antialias: true });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.domElement.style.display = \"block\";\n host.appendChild(this.renderer.domElement);\n\n this.scene = new THREE.Scene();\n this.scene.background = new THREE.Color(0x063d99);\n\n this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);\n this.camera.position.set(0, 24, 19);\n this.camera.lookAt(0, 0, 0);\n\n const ambient = new THREE.AmbientLight(0xffffff, 0.75);\n const dir = new THREE.DirectionalLight(0xffffff, 0.85);\n dir.position.set(12, 22, 8);\n this.scene.add(ambient, dir, this.tileGroup, this.cityGroup);\n\n const tokenMat = new THREE.MeshStandardMaterial({\n color: 0xffd479,\n emissive: 0x6b5a1f,\n });\n this.token = new THREE.Mesh(\n new THREE.SphereGeometry(0.5, 24, 16),\n tokenMat,\n );\n this.token.position.set(0, TOKEN_Y, 0);\n this.scene.add(this.token);\n\n this.buildDice();\n\n this.resize();\n this.resizeObserver = new ResizeObserver(() => {\n this.resize();\n });\n this.resizeObserver.observe(host);\n\n this.loop();\n }\n\n // ---- dice ----\n\n private buildDice(): void {\n const textureByValue: Record<number, THREE.CanvasTexture> = {};\n for (let v = 1; v <= 6; v++) {\n const texture = new THREE.CanvasTexture(this.makePipCanvas(v));\n this.diceTextures.push(texture);\n textureByValue[v] = texture;\n }\n for (const value of FACE_VALUES) {\n this.diceMaterials.push(\n new THREE.MeshStandardMaterial({\n map: textureByValue[value],\n color: 0xffffff,\n }),\n );\n }\n\n const geo = new THREE.BoxGeometry(1.2, 1.2, 1.2);\n for (let i = 0; i < 2; i++) {\n const die = new THREE.Mesh(geo, this.diceMaterials);\n die.position.set(i === 0 ? -1.0 : 1.0, 3.2, 0);\n const rest = REST_ROTATION[3] ?? [0, 0, 0];\n die.rotation.set(rest[0], 0, rest[2]);\n this.scene.add(die);\n this.dice.push(die);\n }\n }\n\n private makePipCanvas(value: number): HTMLCanvasElement {\n const canvas = document.createElement(\"canvas\");\n canvas.width = 128;\n canvas.height = 128;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return canvas;\n const s = canvas.width;\n ctx.fillStyle = \"#f6f3ff\";\n ctx.fillRect(0, 0, s, s);\n ctx.strokeStyle = \"#d9d2f0\";\n ctx.lineWidth = 4;\n ctx.strokeRect(4, 4, s - 8, s - 8);\n ctx.fillStyle = \"#052e7e\";\n for (const slot of PIP_LAYOUTS[value] ?? []) {\n const coord = PIP_COORDS[slot];\n if (!coord) continue;\n ctx.beginPath();\n ctx.arc(coord[0] * s, coord[1] * s, s * 0.1, 0, Math.PI * 2);\n ctx.fill();\n }\n return canvas;\n }\n\n private splitTotal(total: number): [number, number] {\n const clamped = Math.max(2, Math.min(12, Math.round(total)));\n const min1 = Math.max(1, clamped - 6);\n const max1 = Math.min(6, clamped - 1);\n const v1 = min1 + Math.floor(Math.random() * (max1 - min1 + 1));\n return [v1, clamped - v1];\n }\n\n /** Tumble both dice and land them showing two faces that sum to the rolled total. */\n rollDice(total: number): Promise<void> {\n const values = this.splitTotal(total);\n const entries = this.dice.map((mesh, i) => {\n const r = REST_ROTATION[values[i] ?? 3] ?? [0, 0, 0];\n const rest = new THREE.Euler(r[0], 0, r[2]);\n const turns = (n: number): number => Math.PI * 2 * n;\n const to = new THREE.Euler(\n rest.x + turns(2 + i),\n rest.y + turns(2),\n rest.z + turns(2 + i),\n );\n return { mesh, from: mesh.rotation.clone(), to, rest };\n });\n return new Promise<void>((resolve) => {\n this.diceTween = { entries, start: performance.now(), resolve };\n });\n }\n\n private updateDice(): void {\n if (!this.diceTween) return;\n const t = Math.min(1, (performance.now() - this.diceTween.start) / DICE_MS);\n const e = 1 - Math.pow(1 - t, 3); // easeOutCubic for the tumble\n const y = DICE_BASE_Y + DICE_DROP * (1 - bounceOut(t)); // drop + bounce for the toss\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.set(\n entry.from.x + (entry.to.x - entry.from.x) * e,\n entry.from.y + (entry.to.y - entry.from.y) * e,\n entry.from.z + (entry.to.z - entry.from.z) * e,\n );\n entry.mesh.position.y = y;\n }\n if (t >= 1) {\n for (const entry of this.diceTween.entries) {\n entry.mesh.rotation.copy(entry.rest);\n entry.mesh.position.y = DICE_BASE_Y;\n }\n const done = this.diceTween.resolve;\n this.diceTween = null;\n done();\n }\n }\n\n // ---- board ----\n\n loadBoard(tiles: TileInfo[], buildingCount: number): void {\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.tilePositions = [];\n this.tileMeshes = [];\n this.tileColors = [];\n this.buildings = [];\n\n const count = tiles.length;\n const perSide = Math.max(1, Math.round(count / 4));\n const spacing = 1.4;\n const extent = perSide * spacing;\n const half = extent / 2;\n\n for (const tile of tiles) {\n const pos = this.ringPosition(tile.index, perSide, extent, half);\n const colorHex = TILE_COLORS[tile.type] ?? DEFAULT_TILE_COLOR;\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.1, 0.25, 1.1),\n new THREE.MeshStandardMaterial({ color: colorHex }),\n );\n mesh.position.set(pos.x, TILE_Y, pos.z);\n\n const symbol = TILE_SYMBOLS[tile.type];\n if (symbol) {\n const decal = new THREE.Mesh(\n new THREE.PlaneGeometry(0.7, 0.7),\n new THREE.MeshBasicMaterial({\n map: this.getDecalTexture(tile.type, symbol),\n transparent: true,\n }),\n );\n decal.rotation.x = -Math.PI / 2;\n decal.position.set(0, 0.14, 0);\n mesh.add(decal);\n }\n\n this.tileGroup.add(mesh);\n this.tilePositions[tile.index] = new THREE.Vector3(pos.x, TOKEN_Y, pos.z);\n this.tileMeshes[tile.index] = mesh;\n this.tileColors[tile.index] = new THREE.Color(colorHex);\n }\n\n const startX = -((buildingCount - 1) * 1.7) / 2;\n for (let i = 0; i < buildingCount; i++) {\n const color = BUILDING_COLORS[i % BUILDING_COLORS.length] ?? 0x6c5ce7;\n const body = new THREE.Mesh(\n new THREE.BoxGeometry(0.9, BODY_H, 0.9),\n new THREE.MeshStandardMaterial({ color }),\n );\n const roof = new THREE.Mesh(\n new THREE.BoxGeometry(1.05, 0.22, 1.05),\n new THREE.MeshStandardMaterial({\n color: new THREE.Color(color).multiplyScalar(0.6),\n }),\n );\n body.position.set(startX + i * 1.7, BODY_H / 2, 0);\n roof.position.set(startX + i * 1.7, BODY_H + 0.11, 0);\n this.cityGroup.add(body, roof);\n this.buildings[i] = { body, roof };\n }\n }\n\n private getDecalTexture(type: string, symbol: string): THREE.CanvasTexture {\n const cached = this.tileDecalTextures.get(type);\n if (cached) return cached;\n const canvas = document.createElement(\"canvas\");\n canvas.width = 96;\n canvas.height = 96;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) {\n ctx.clearRect(0, 0, 96, 96);\n ctx.fillStyle = \"rgba(20,16,40,0.85)\";\n ctx.font = \"bold 64px system-ui, sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(symbol, 48, 52);\n }\n const texture = new THREE.CanvasTexture(canvas);\n this.tileDecalTextures.set(type, texture);\n return texture;\n }\n\n private ringPosition(\n index: number,\n perSide: number,\n extent: number,\n half: number,\n ): THREE.Vector3 {\n const side = Math.floor(index / perSide) % 4;\n const f = (index % perSide) / perSide;\n let x = 0;\n let z = 0;\n switch (side) {\n case 0:\n x = -half + f * extent;\n z = half;\n break;\n case 1:\n x = half;\n z = half - f * extent;\n break;\n case 2:\n x = half - f * extent;\n z = -half;\n break;\n default:\n x = -half;\n z = -half + f * extent;\n break;\n }\n return new THREE.Vector3(x, 0, z);\n }\n\n /** Start/stop the render loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs its RAF). */\n setRunning(running: boolean): void {\n const shouldRun = running && !this.disposed;\n if (shouldRun === this.running) return;\n this.running = shouldRun;\n if (shouldRun) this.frame = requestAnimationFrame(this.loop);\n else cancelAnimationFrame(this.frame);\n }\n\n /** Snap the token to a tile with no animation. */\n setPosition(index: number): void {\n const p = this.tilePositions[index];\n if (p) this.token.position.set(p.x, TOKEN_Y, p.z);\n }\n\n /**\n * What the scene currently shows. Read by the module's agent surface (`agent.ts`): a rendered\n * board is a single canvas, so this is the only way the AI Coder's agent can tell where the\n * token stands and whether the board was loaded at all.\n */\n describeScene(): {\n running: boolean;\n tileCount: number;\n buildingCount: number;\n token: { x: number; z: number };\n } {\n return {\n running: this.running,\n tileCount: this.tilePositions.length,\n buildingCount: this.buildings.length,\n token: {\n x: Math.round(this.token.position.x * 100) / 100,\n z: Math.round(this.token.position.z * 100) / 100,\n },\n };\n }\n\n /** Animate the token through the given tile indices, one hop at a time. */\n async moveToken(path: number[]): Promise<void> {\n for (const idx of path) {\n const to = this.tilePositions[idx];\n if (!to) continue;\n await this.tweenTo(to);\n }\n }\n\n /** Pulse a tile (emissive + lift) to mark where the token landed. */\n highlightTile(index: number): void {\n if (this.tileMeshes[index])\n this.highlight = { index, start: performance.now() };\n }\n\n /** Grow the city towers to reflect each building's level. */\n setBuildingLevels(levels: number[]): void {\n for (let i = 0; i < this.buildings.length; i++) {\n const building = this.buildings[i];\n if (!building) continue;\n const level = levels[i] ?? 0;\n const scaleY = 0.5 + level * 0.9;\n const height = BODY_H * scaleY;\n building.body.scale.y = scaleY;\n building.body.position.y = height / 2;\n building.roof.position.y = height + 0.11;\n }\n }\n\n private updateHighlight(): void {\n if (!this.highlight) return;\n const mesh = this.tileMeshes[this.highlight.index];\n const baseColor = this.tileColors[this.highlight.index];\n if (!mesh || !baseColor) {\n this.highlight = null;\n return;\n }\n const mat = mesh.material as THREE.MeshStandardMaterial;\n const t = (performance.now() - this.highlight.start) / HIGHLIGHT_MS;\n if (t >= 1) {\n mat.emissive.setHex(0x000000);\n mat.emissiveIntensity = 1;\n mesh.position.y = TILE_Y;\n this.highlight = null;\n return;\n }\n const pulse = Math.sin(Math.min(1, t) * Math.PI); // 0 → 1 → 0\n mat.emissive.copy(baseColor);\n mat.emissiveIntensity = pulse * 0.9;\n mesh.position.y = TILE_Y + pulse * 0.35;\n }\n\n private tweenTo(to: THREE.Vector3): Promise<void> {\n return new Promise<void>((resolve) => {\n this.tween = {\n from: this.token.position.clone(),\n to: to.clone(),\n start: performance.now(),\n resolve,\n };\n });\n }\n\n private resize(): void {\n const w = this.host.clientWidth || 640;\n const h = this.host.clientHeight || 640;\n this.renderer.setSize(w, h, false);\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n }\n\n private disposeGroup(group: THREE.Group): void {\n group.traverse((obj) => {\n if (obj instanceof THREE.Mesh) {\n obj.geometry.dispose();\n const material = obj.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material.dispose();\n }\n });\n group.clear();\n }\n\n /**\n * One still frame of the board, for the platform's share-a-shot button.\n *\n * ⚠ Renders and reads in the SAME turn. The renderer is created without\n * `preserveDrawingBuffer`, so by the time anyone else touches this canvas its drawing buffer is\n * already cleared and `toBlob` hands back a black rectangle without failing.\n */\n async capture(): Promise<Blob | null> {\n if (this.disposed) return null;\n this.renderer.render(this.scene, this.camera);\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n destroy(): void {\n this.disposed = true;\n cancelAnimationFrame(this.frame);\n this.resizeObserver?.disconnect();\n this.disposeGroup(this.tileGroup);\n this.disposeGroup(this.cityGroup);\n this.token.geometry.dispose();\n (this.token.material as THREE.Material).dispose();\n if (this.dice[0]) this.dice[0].geometry.dispose();\n this.diceMaterials.forEach((m) => m.dispose());\n this.diceTextures.forEach((t) => t.dispose());\n this.tileDecalTextures.forEach((t) => t.dispose());\n this.renderer.dispose();\n this.renderer.domElement.remove();\n }\n}\n"
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"path": "index.ts",
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
},
|
|
160
160
|
{
|
|
161
161
|
"path": "scene.ts",
|
|
162
|
-
"content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Three.js board renderer as a host-driven EngineScene. Created on mount (it appends its\n// own <canvas> to the host element and starts a RAF loop), published into the shared box for the\n// React overlay, and paused/resumed by the Mode Router via activate/suspend.\nexport function createBoardScene(\n box: ControllerBox<BoardController>,\n): EngineScene {\n let controller: BoardController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new BoardController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
|
|
162
|
+
"content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { BoardController } from \"./game/BoardController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Three.js board renderer as a host-driven EngineScene. Created on mount (it appends its\n// own <canvas> to the host element and starts a RAF loop), published into the shared box for the\n// React overlay, and paused/resumed by the Mode Router via activate/suspend.\nexport function createBoardScene(\n box: ControllerBox<BoardController>,\n): EngineScene {\n let controller: BoardController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new BoardController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return controller?.capture() ?? Promise.resolve(null);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
|
|
163
163
|
},
|
|
164
164
|
{
|
|
165
165
|
"path": "module.meta.json",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@idosgames/core": "0.
|
|
52
|
-
"@idosgames/module-sdk": "0.
|
|
53
|
-
"@idosgames/react": "0.2.
|
|
54
|
-
"@idosgames/wallet": "0.
|
|
51
|
+
"@idosgames/core": "0.18.0",
|
|
52
|
+
"@idosgames/module-sdk": "0.4.1",
|
|
53
|
+
"@idosgames/react": "0.2.11",
|
|
54
|
+
"@idosgames/wallet": "0.5.0",
|
|
55
55
|
"@tanstack/react-query": "5.101.2",
|
|
56
56
|
"react": "19.2.7",
|
|
57
57
|
"viem": "2.55.2",
|
|
@@ -49,17 +49,17 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@idosgames/core": "0.
|
|
53
|
-
"@idosgames/module-sdk": "0.
|
|
54
|
-
"@idosgames/react": "0.2.
|
|
55
|
-
"@idosgames/wallet": "0.
|
|
52
|
+
"@idosgames/core": "0.18.0",
|
|
53
|
+
"@idosgames/module-sdk": "0.4.1",
|
|
54
|
+
"@idosgames/react": "0.2.11",
|
|
55
|
+
"@idosgames/wallet": "0.5.0",
|
|
56
56
|
"@tanstack/react-query": "5.101.2",
|
|
57
57
|
"phaser": "4.2.1",
|
|
58
58
|
"react": "19.2.7",
|
|
59
59
|
"viem": "2.55.2",
|
|
60
60
|
"wagmi": "3.7.2"
|
|
61
61
|
},
|
|
62
|
-
"contentHash": "
|
|
62
|
+
"contentHash": "143c4595784974ca4d58d5032e77b26e93d1d96c2455974c796c7d5c1d710ba6",
|
|
63
63
|
"files": [
|
|
64
64
|
{
|
|
65
65
|
"path": "chrome.ts",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"path": "game/IdleRpgController.ts",
|
|
110
|
-
"content": "import Phaser from \"phaser\";\nimport { IdleRpgScene, type CharacterDisplay } from \"../phaser/IdleRpgScene\";\n\n/** Owns the Phaser game instance and bridges React → scene (selecting a character). */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly scene: IdleRpgScene;\n\n constructor(parent: HTMLElement) {\n this.scene = new IdleRpgScene();\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: 480,\n height: 640,\n backgroundColor: \"#063d99\",\n scene: this.scene,\n scale: {\n // Hug the left of the full-bleed host so the host's UI panel can dock on the right without\n // covering the character card.\n mode: Phaser.Scale.FIT,\n autoCenter: Phaser.Scale.CENTER_VERTICALLY,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n }\n\n selectCharacter(display: CharacterDisplay): void {\n this.scene.setSelected(display);\n this.selected = display;\n }\n\n /** Последний выбранный персонаж — единственное, что сцена показывает и чего нет в DOM. */\n private selected: CharacterDisplay | null = null;\n\n /**\n * Что сейчас на канвасе. Читается поверхностью для агента (`module.ts`): у отрисованной сцены\n * нет DOM, и без этого агент не может сказать, дошёл ли выбор персонажа до Phaser.\n */\n describeScene(): { running: boolean; selected: CharacterDisplay | null } {\n return { running: this.game.loop.running, selected: this.selected };\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs). */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n destroy(): void {\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
|
|
110
|
+
"content": "import Phaser from \"phaser\";\nimport { IdleRpgScene, type CharacterDisplay } from \"../phaser/IdleRpgScene\";\n\n/** Owns the Phaser game instance and bridges React → scene (selecting a character). */\nexport class IdleRpgController {\n private readonly game: Phaser.Game;\n private readonly scene: IdleRpgScene;\n\n constructor(parent: HTMLElement) {\n this.scene = new IdleRpgScene();\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: 480,\n height: 640,\n backgroundColor: \"#063d99\",\n scene: this.scene,\n scale: {\n // Hug the left of the full-bleed host so the host's UI panel can dock on the right without\n // covering the character card.\n mode: Phaser.Scale.FIT,\n autoCenter: Phaser.Scale.CENTER_VERTICALLY,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n }\n\n selectCharacter(display: CharacterDisplay): void {\n this.scene.setSelected(display);\n this.selected = display;\n }\n\n /** Последний выбранный персонаж — единственное, что сцена показывает и чего нет в DOM. */\n private selected: CharacterDisplay | null = null;\n\n /**\n * Что сейчас на канвасе. Читается поверхностью для агента (`module.ts`): у отрисованной сцены\n * нет DOM, и без этого агент не может сказать, дошёл ли выбор персонажа до Phaser.\n */\n describeScene(): { running: boolean; selected: CharacterDisplay | null } {\n return { running: this.game.loop.running, selected: this.selected };\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs). */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"path": "index.ts",
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"path": "scene.ts",
|
|
134
|
-
"content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Phaser controller as a host-driven EngineScene. Created lazily on mount (Phaser needs a\n// parent element), published into the shared box for the React panel, and paused/resumed by the\n// Mode Router via activate/suspend so a hidden mode stops ticking.\nexport function createIdleRpgScene(\n box: ControllerBox<IdleRpgController>,\n): EngineScene {\n let controller: IdleRpgController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new IdleRpgController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
|
|
134
|
+
"content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport type { ControllerBox } from \"./controller-box\";\n\n// Wraps the Phaser controller as a host-driven EngineScene. Created lazily on mount (Phaser needs a\n// parent element), published into the shared box for the React panel, and paused/resumed by the\n// Mode Router via activate/suspend so a hidden mode stops ticking.\nexport function createIdleRpgScene(\n box: ControllerBox<IdleRpgController>,\n): EngineScene {\n let controller: IdleRpgController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new IdleRpgController(ctx.host);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n },\n suspend(): void {\n controller?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return controller?.capture() ?? Promise.resolve(null);\n },\n destroy(): void {\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
|
|
135
135
|
},
|
|
136
136
|
{
|
|
137
137
|
"path": "module.meta.json",
|