├── environments ├── openmonsters │ ├── .gitignore │ ├── src │ │ ├── camera │ │ │ ├── follow.ts │ │ │ └── pan-zoom.ts │ │ ├── collectibles │ │ │ ├── gold.ts │ │ │ ├── bomb.ts │ │ │ └── key.ts │ │ ├── tiles │ │ │ ├── teleport.ts │ │ │ ├── action-manager.ts │ │ │ ├── action.ts │ │ │ ├── dialogue.ts │ │ │ └── tilemap.ts │ │ ├── enemies │ │ │ ├── monster.ts │ │ │ └── movement.ts │ │ ├── player │ │ │ ├── inventory.ts │ │ │ └── metrics.ts │ │ ├── mechanics │ │ │ ├── placed-bomb.ts │ │ │ ├── door-handler.ts │ │ │ ├── color-action.ts │ │ │ ├── portal-placement.ts │ │ │ ├── pushable-block-manager.ts │ │ │ └── portal-manager.ts │ │ ├── ui │ │ │ ├── dialogue-text.tsx │ │ │ ├── level-navigation.tsx │ │ │ └── game-selection.tsx │ │ └── effects │ │ │ └── light-overlay.ts │ ├── deno.json │ ├── lib │ │ └── colors.ts │ └── scene-description.md └── portalbench │ ├── .gitignore │ ├── src │ ├── camera │ │ ├── follow.ts │ │ └── pan-zoom.ts │ ├── collectibles │ │ ├── gold.ts │ │ ├── bomb.ts │ │ └── key.ts │ ├── tiles │ │ ├── teleport.ts │ │ ├── action-manager.ts │ │ ├── action.ts │ │ ├── dialogue.ts │ │ └── tilemap.ts │ ├── player │ │ ├── inventory.ts │ │ └── metrics.ts │ ├── utils │ │ └── coordinates.ts │ ├── mechanics │ │ ├── placed-bomb.ts │ │ ├── door-handler.ts │ │ ├── color-action.ts │ │ ├── portal-placement.ts │ │ ├── pushable-block-manager.ts │ │ └── portal-manager.ts │ ├── ui │ │ ├── dialogue-text.tsx │ │ ├── level-navigation.tsx │ │ └── game-selection.tsx │ └── effects │ │ ├── light-overlay.ts │ │ └── particle.ts │ ├── deno.json │ ├── lib │ └── colors.ts │ └── scene-description.md ├── .gitignore ├── harness ├── deno.json ├── prompts │ ├── push-maze.txt │ ├── key-doors.txt │ ├── bombs.txt │ ├── portals.txt │ └── sokoban.txt ├── src │ ├── tools │ │ ├── player-spawn.ts │ │ ├── player-see.ts │ │ ├── restart-level.ts │ │ ├── http-client.ts │ │ ├── place-bomb.ts │ │ ├── delete-player.ts │ │ ├── get-portals.ts │ │ ├── level-select.ts │ │ ├── place-portal.ts │ │ ├── player-move.ts │ │ └── registry.ts │ ├── llm │ │ ├── types.ts │ │ ├── wrapper.ts │ │ └── openai-wrapper.ts │ ├── context.ts │ ├── cli.ts │ └── interactive-prompts.ts ├── deno.lock └── main.ts └── README.md /environments/openmonsters/.gitignore: -------------------------------------------------------------------------------- 1 | _dist/ 2 | _dist_*/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dreamlab-engine 3 | 4 | .env.local 5 | -------------------------------------------------------------------------------- /environments/portalbench/.gitignore: -------------------------------------------------------------------------------- 1 | _dist/ 2 | _dist_*/ 3 | .dreamlab-engine -------------------------------------------------------------------------------- /environments/openmonsters/src/camera/follow.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Camera } from "@dreamlab/engine"; 2 | 3 | export default class CameraFollow extends Behavior { 4 | onPostTick(): void { 5 | if (!this.game.isClient()) return; 6 | if (!this.hasAuthority()) return; 7 | 8 | const target = this.entity; 9 | const camera = Camera.getActive(this.game); 10 | if (camera) camera.pos.assign(target.pos); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /environments/portalbench/src/camera/follow.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Camera } from "@dreamlab/engine"; 2 | 3 | export default class CameraFollow extends Behavior { 4 | onPostTick(): void { 5 | if (!this.game.isClient()) return; 6 | if (!this.hasAuthority()) return; 7 | 8 | const target = this.entity; 9 | const camera = Camera.getActive(this.game); 10 | if (camera) camera.pos.assign(target.pos); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /harness/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "dev": "deno run --allow-net --allow-read --allow-env --watch main.ts", 4 | "start": "deno run --allow-net --allow-read --allow-env main.ts" 5 | }, 6 | "imports": { 7 | "openai": "npm:openai@^6.10.0", 8 | "@std/dotenv": "jsr:@std/dotenv@^0.225.0", 9 | "@std/cli": "jsr:@std/cli@^1.0.0", 10 | "@clack/prompts": "npm:@clack/prompts@^0.11.0" 11 | }, 12 | "compilerOptions": { 13 | "strict": true, 14 | "lib": ["esnext", "dom", "deno.ns"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /environments/openmonsters/src/collectibles/gold.ts: -------------------------------------------------------------------------------- 1 | import { value } from "@dreamlab/engine"; 2 | import InventoryBehavior from "../player/inventory.ts"; 3 | import { PlayerMoved } from "../player/movement.ts"; 4 | import TileAction from "../tiles/action.ts"; 5 | 6 | export default class DroppedGold extends TileAction { 7 | @value() 8 | gold = 1; 9 | 10 | public onTileEnter(ev: PlayerMoved): void { 11 | const inventory = ev.player.entity.getBehavior(InventoryBehavior); 12 | if (!inventory) return; 13 | 14 | inventory.gold += this.gold; 15 | this.gold = 0; 16 | 17 | this.entity.destroy(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /environments/openmonsters/src/tiles/teleport.ts: -------------------------------------------------------------------------------- 1 | import { Entity, EntityRef, value, Vector2, Vector2Adapter } from "@dreamlab/engine"; 2 | import { PlayerMoved } from "../player/movement.ts"; 3 | import TileAction from "./action.ts"; 4 | 5 | export default class TeleportTile extends TileAction { 6 | @value({ type: Vector2Adapter }) 7 | destination: Vector2 = Vector2.ZERO; 8 | 9 | @value({ type: EntityRef }) 10 | target: Entity | undefined; 11 | 12 | public onTileEnter(ev: PlayerMoved): void { 13 | const destination = this.target?.pos.floor() ?? this.destination; 14 | ev.teleport = destination; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /environments/portalbench/src/collectibles/gold.ts: -------------------------------------------------------------------------------- 1 | import { value } from "@dreamlab/engine"; 2 | import InventoryBehavior from "../player/inventory.ts"; 3 | import { PlayerMoved } from "../player/movement.ts"; 4 | import TileAction from "../tiles/action.ts"; 5 | 6 | export default class DroppedGold extends TileAction { 7 | @value() 8 | gold = 1; 9 | 10 | public onTileEnter(ev: PlayerMoved): void { 11 | const inventory = ev.player.entity.getBehavior(InventoryBehavior); 12 | if (!inventory) return; 13 | 14 | inventory.gold += this.gold; 15 | this.gold = 0; 16 | 17 | this.entity.destroy(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /environments/portalbench/src/tiles/teleport.ts: -------------------------------------------------------------------------------- 1 | import { Entity, EntityRef, value, Vector2, Vector2Adapter } from "@dreamlab/engine"; 2 | import { PlayerMoved } from "../player/movement.ts"; 3 | import TileAction from "./action.ts"; 4 | 5 | export default class TeleportTile extends TileAction { 6 | @value({ type: Vector2Adapter }) 7 | destination: Vector2 = Vector2.ZERO; 8 | 9 | @value({ type: EntityRef }) 10 | target: Entity | undefined; 11 | 12 | public onTileEnter(ev: PlayerMoved): void { 13 | const destination = this.target?.pos.floor() ?? this.destination; 14 | ev.teleport = destination; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /environments/openmonsters/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "@dreamlab/engine": "./.dreamlab-engine/engine/mod.ts", 4 | "@dreamlab/engine/internal": "./.dreamlab-engine/engine/internal.ts", 5 | "@dreamlab/vendor/": "./.dreamlab-engine/engine/_deps/", 6 | "@dreamlab/ui": "./.dreamlab-engine/ui/mod.ts", 7 | "@dreamlab/ui/jsx-runtime": "./.dreamlab-engine/ui/jsx.ts", 8 | "@dreamlab/util/": "./.dreamlab-engine/util/" 9 | }, 10 | "compilerOptions": { 11 | "lib": [ 12 | "deno.window", 13 | "dom" 14 | ], 15 | "noImplicitOverride": false, 16 | "jsxImportSource": "@dreamlab/ui" 17 | } 18 | } -------------------------------------------------------------------------------- /environments/openmonsters/src/enemies/monster.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Vector2 } from "@dreamlab/engine"; 2 | import { PlayerMoved } from "../player/movement.ts"; 3 | 4 | export default class MonsterBehavior extends Behavior { 5 | get tilePos(): Vector2 { 6 | return this.entity.pos.floor(); 7 | } 8 | 9 | onInitialize(): void { 10 | this.game.on(PlayerMoved, ev => { 11 | if (!this.tilePos.eq(ev.position)) return; 12 | 13 | // TODO: damage self based on player potency (if sword, *3 damage?) 14 | ev.cancelled = true; 15 | ev.actions.push({ id: "damage-entity", data: {} }); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /environments/portalbench/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "@dreamlab/engine": "./.dreamlab-engine/engine/mod.ts", 4 | "@dreamlab/engine/internal": "./.dreamlab-engine/engine/internal.ts", 5 | "@dreamlab/vendor/": "./.dreamlab-engine/engine/_deps/", 6 | "@dreamlab/ui": "./.dreamlab-engine/ui/mod.ts", 7 | "@dreamlab/ui/jsx-runtime": "./.dreamlab-engine/ui/jsx.ts", 8 | "@dreamlab/util/": "./.dreamlab-engine/util/" 9 | }, 10 | "compilerOptions": { 11 | "lib": [ 12 | "deno.window", 13 | "dom" 14 | ], 15 | "noImplicitOverride": false, 16 | "jsxImportSource": "@dreamlab/ui" 17 | } 18 | } -------------------------------------------------------------------------------- /harness/prompts/push-maze.txt: -------------------------------------------------------------------------------- 1 | Escape the room by reaching the ! 2 | 3 | When you observe, you will get a grid representing the level: 4 | @ represents your player 5 | W represents walls 6 | F represents floor 7 | P represents pushable blocks (movable) 8 | 9 | You can move in any direction but cannot move through walls. 10 | 11 | Pushable blocks will move in the opposite direction you hit them from. They will move as long as there is floor to move to. 12 | 13 | Other characters represent mystery things you will have to discover. Move around, use your tools, solve the puzzle! 14 | 15 | Think step-by-step about the level and the path you have to take. 16 | 17 | The world progresses one time tick every time you move. 18 | -------------------------------------------------------------------------------- /harness/prompts/key-doors.txt: -------------------------------------------------------------------------------- 1 | Escape the room by reaching the ! 2 | 3 | When you observe, you will get a grid representing the level: 4 | @ represents your player 5 | W represents walls 6 | F represents floor 7 | E represents enemies (they can move) 8 | P represents pushable blocks (movable) 9 | B is a blue tile 10 | G is a green tile 11 | 12 | You can move in any direction but cannot move through walls. 13 | 14 | Other characters represent mystery things you will have to discover. Move around, use your tools, solve the puzzle! 15 | % looks like a green key. # looks like a blue key. 16 | 17 | Think step-by-step about the level and the path you have to take. 18 | 19 | The world progresses one time tick every time you move. 20 | -------------------------------------------------------------------------------- /harness/prompts/bombs.txt: -------------------------------------------------------------------------------- 1 | Escape the room by reaching the ! 2 | 3 | When you observe, you will get a grid representing the level: 4 | @ represents your player 5 | W represents walls 6 | F represents floor 7 | E represents enemies (they can move) 8 | b represents collectible bombs (pickup items) 9 | * represents bombs that were placed 10 | X represents bombable walls that can be destroyed 11 | P represents pushable blocks (movable) 12 | 13 | You can move in any direction but cannot move through walls. 14 | 15 | Other characters represent mystery things you will have to discover. Move around, use your tools, solve the puzzle! 16 | 17 | Think step-by-step about the level and the path you have to take. 18 | 19 | The world progresses one time tick every time you move. 20 | -------------------------------------------------------------------------------- /environments/openmonsters/lib/colors.ts: -------------------------------------------------------------------------------- 1 | export const enum Colors { 2 | Wall = 0x3a3a3a, // Dark stone wall 3 | Sand = 0x8b8680, // Stone floor (light) 4 | Water = 0x1a1a1a, // Dark abyss/pit 5 | Grass = 0x5a5550, // Floor tiles (medium) 6 | GreenDoor = 0x22cc22, // Green door (locked) 7 | BlueDoor = 0x2255cc, // Blue door (locked) 8 | Slowdown = 0x6b4423, // Mud/tar slowdown tile 9 | BombWall = 0x4d4d4d, // Bombable wall 10 | GlassWall = 0x8f8e8e, // Glass wall - blocks movement but not line of sight 11 | PushableBlock = 0xe3288f, 12 | BlockGoal = 0xffaa00, // Goal position for pushable blocks 13 | BlockOnGoal = 0x88ff00, // Block successfully placed on goal 14 | BluePortal = 0x00aaff, // Blue portal 15 | OrangePortal = 0xff7700, // Orange portal 16 | } 17 | -------------------------------------------------------------------------------- /environments/portalbench/lib/colors.ts: -------------------------------------------------------------------------------- 1 | export const enum Colors { 2 | Wall = 0x3a3a3a, // Dark stone wall 3 | Sand = 0x8b8680, // Stone floor (light) 4 | Water = 0x1a1a1a, // Dark abyss/pit 5 | Grass = 0x5a5550, // Floor tiles (medium) 6 | GreenDoor = 0x22cc22, // Green door (locked) 7 | BlueDoor = 0x2255cc, // Blue door (locked) 8 | Slowdown = 0x6b4423, // Mud/tar slowdown tile 9 | BombWall = 0x4d4d4d, // Bombable wall 10 | GlassWall = 0x8f8e8e, // Glass wall - blocks movement but not line of sight 11 | PushableBlock = 0xe3288f, 12 | BlockGoal = 0xffaa00, // Goal position for pushable blocks 13 | BlockOnGoal = 0x88ff00, // Block successfully placed on goal 14 | BluePortal = 0x00aaff, // Blue portal 15 | OrangePortal = 0xff7700, // Orange portal 16 | } 17 | -------------------------------------------------------------------------------- /harness/src/tools/player-spawn.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | import { getModelName } from "../context.ts"; 4 | 5 | export const toolDefinition: Tool = { 6 | name: "SpawnPlayer", 7 | description: "Spawns a player. Returns a ref to future use.", 8 | input_schema: { 9 | type: "object", 10 | properties: {}, 11 | required: [], 12 | }, 13 | }; 14 | 15 | export const toolHandler: ToolHandler = async (_input) => { 16 | const modelName = getModelName(); 17 | const data = await callGameAPI("spawn-player", [modelName]); 18 | 19 | if (data.result && typeof data.result === "object" && "ref" in data.result) { 20 | return `Player ref: ${(data.result as { ref: string }).ref}`; 21 | } 22 | 23 | return `Error: ${data.error || "Unknown error"}`; 24 | }; 25 | -------------------------------------------------------------------------------- /environments/portalbench/src/player/inventory.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, LocalRoot, sync, value } from "@dreamlab/engine"; 2 | 3 | const enum Item { 4 | Sword = 1, 5 | Potion, 6 | } 7 | 8 | export default class InventoryBehavior extends Behavior { 9 | @value() 10 | gold = 0; 11 | @sync() 12 | items: Item[] = []; 13 | @value() 14 | equippedItem: number = 0; 15 | @value() 16 | greenKeys = 0; 17 | @value() 18 | blueKeys = 0; 19 | @value() 20 | bombs = 0; 21 | 22 | onInitialize(): void { 23 | const isLocal = this.game.isClient() && this.entity.root instanceof LocalRoot; 24 | if (!(isLocal || this.hasAuthority())) return; 25 | 26 | this.values.get("currentItem")?.onChanged(() => { 27 | const item = this.items.at(this.equippedItem); 28 | // TODO: clone prefab display item to player. clean up existing before 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /environments/openmonsters/src/player/inventory.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, LocalRoot, sync, value } from "@dreamlab/engine"; 2 | 3 | const enum Item { 4 | Sword = 1, 5 | Potion, 6 | } 7 | 8 | export default class InventoryBehavior extends Behavior { 9 | @value() 10 | gold = 0; 11 | @sync() 12 | items: Item[] = []; 13 | @value() 14 | equippedItem: number = 0; 15 | @value() 16 | greenKeys = 0; 17 | @value() 18 | blueKeys = 0; 19 | @value() 20 | bombs = 0; 21 | 22 | onInitialize(): void { 23 | const isLocal = this.game.isClient() && this.entity.root instanceof LocalRoot; 24 | if (!(isLocal || this.hasAuthority())) return; 25 | 26 | this.values.get("currentItem")?.onChanged(() => { 27 | const item = this.items.at(this.equippedItem); 28 | // TODO: clone prefab display item to player. clean up existing before 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /harness/src/tools/player-see.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | 4 | export const toolDefinition: Tool = { 5 | name: "ObserveWorld", 6 | description: "Get the state of the world", 7 | input_schema: { 8 | type: "object", 9 | properties: { 10 | ref: { type: "string", description: "The player ref." }, 11 | }, 12 | required: ["ref"], 13 | }, 14 | }; 15 | 16 | export const toolHandler: ToolHandler = async (input) => { 17 | const ref = input.ref as string; 18 | 19 | if (!ref) { 20 | return "Error: Missing required parameter: ref"; 21 | } 22 | 23 | const data = await callGameAPI("vision", [ref]); 24 | 25 | if (!data.result) { 26 | return `Error: ${data.error || "Unknown error - no result in response"}`; 27 | } 28 | 29 | return `Result: ${JSON.stringify(data.result)}`; 30 | }; 31 | -------------------------------------------------------------------------------- /environments/openmonsters/src/collectibles/bomb.ts: -------------------------------------------------------------------------------- 1 | import { value } from "@dreamlab/engine"; 2 | import InventoryBehavior from "../player/inventory.ts"; 3 | import { PlayerMoved } from "../player/movement.ts"; 4 | import TileAction from "../tiles/action.ts"; 5 | 6 | export default class CollectibleBomb extends TileAction { 7 | @value() 8 | count: number = 1; 9 | 10 | #collected = false; 11 | 12 | public onTileEnter(ev: PlayerMoved): void { 13 | if (this.#collected) return; 14 | 15 | const inventory = ev.player.entity.getBehavior(InventoryBehavior); 16 | if (!inventory) return; 17 | 18 | inventory.bombs += this.count; 19 | 20 | this.#collected = true; 21 | } 22 | 23 | public onTileExit(_: PlayerMoved): void { 24 | if (this.#collected) { 25 | this.game.time.waitForNextTick().then(() => { 26 | this.entity.destroy(); 27 | }); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /environments/portalbench/src/collectibles/bomb.ts: -------------------------------------------------------------------------------- 1 | import { value } from "@dreamlab/engine"; 2 | import InventoryBehavior from "../player/inventory.ts"; 3 | import { PlayerMoved } from "../player/movement.ts"; 4 | import TileAction from "../tiles/action.ts"; 5 | 6 | export default class CollectibleBomb extends TileAction { 7 | @value() 8 | count: number = 1; 9 | 10 | #collected = false; 11 | 12 | public onTileEnter(ev: PlayerMoved): void { 13 | if (this.#collected) return; 14 | 15 | const inventory = ev.player.entity.getBehavior(InventoryBehavior); 16 | if (!inventory) return; 17 | 18 | inventory.bombs += this.count; 19 | 20 | this.#collected = true; 21 | } 22 | 23 | public onTileExit(_: PlayerMoved): void { 24 | if (this.#collected) { 25 | this.game.time.waitForNextTick().then(() => { 26 | this.entity.destroy(); 27 | }); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /environments/openmonsters/src/collectibles/key.ts: -------------------------------------------------------------------------------- 1 | import { value } from "@dreamlab/engine"; 2 | import InventoryBehavior from "../player/inventory.ts"; 3 | import { PlayerMoved } from "../player/movement.ts"; 4 | import TileAction from "../tiles/action.ts"; 5 | 6 | export default class CollectibleKey extends TileAction { 7 | @value() 8 | keyColor: "green" | "blue" = "green"; 9 | 10 | #collected = false; 11 | 12 | public onTileEnter(ev: PlayerMoved): void { 13 | if (this.#collected) return; 14 | 15 | const inventory = ev.player.entity.getBehavior(InventoryBehavior); 16 | if (!inventory) return; 17 | 18 | if (this.keyColor === "green") { 19 | inventory.greenKeys += 1; 20 | } else { 21 | inventory.blueKeys += 1; 22 | } 23 | 24 | this.#collected = true; 25 | } 26 | 27 | public onTileExit(ev: PlayerMoved): void { 28 | if (this.#collected) { 29 | this.entity.destroy(); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /environments/portalbench/src/collectibles/key.ts: -------------------------------------------------------------------------------- 1 | import { value } from "@dreamlab/engine"; 2 | import InventoryBehavior from "../player/inventory.ts"; 3 | import { PlayerMoved } from "../player/movement.ts"; 4 | import TileAction from "../tiles/action.ts"; 5 | 6 | export default class CollectibleKey extends TileAction { 7 | @value() 8 | keyColor: "green" | "blue" = "green"; 9 | 10 | #collected = false; 11 | 12 | public onTileEnter(ev: PlayerMoved): void { 13 | if (this.#collected) return; 14 | 15 | const inventory = ev.player.entity.getBehavior(InventoryBehavior); 16 | if (!inventory) return; 17 | 18 | if (this.keyColor === "green") { 19 | inventory.greenKeys += 1; 20 | } else { 21 | inventory.blueKeys += 1; 22 | } 23 | 24 | this.#collected = true; 25 | } 26 | 27 | public onTileExit(ev: PlayerMoved): void { 28 | if (this.#collected) { 29 | this.entity.destroy(); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /environments/portalbench/src/utils/coordinates.ts: -------------------------------------------------------------------------------- 1 | import { IVector2, Vector2 } from "@dreamlab/engine"; 2 | 3 | /** 4 | * Converts level coordinates to world coordinates. 5 | * @param level - Level position (from vision API grid) 6 | * @param worldOrigin - The world position of the vision origin (top-left of visible area) 7 | * @returns World position 8 | */ 9 | export function levelToWorld(level: IVector2, worldOrigin: IVector2): Vector2 { 10 | return new Vector2(worldOrigin.x + level.x, worldOrigin.y - level.y); 11 | } 12 | 13 | /** 14 | * Converts world coordinates to level coordinates. 15 | * @param world - World position 16 | * @param worldOrigin - The world position of the vision origin (top-left of visible area) 17 | * @returns Level position (for vision API grid) 18 | */ 19 | export function worldToLevel(world: IVector2, worldOrigin: IVector2): Vector2 { 20 | return new Vector2(world.x - worldOrigin.x, worldOrigin.y - world.y); 21 | } 22 | -------------------------------------------------------------------------------- /harness/src/tools/restart-level.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | 4 | export const toolDefinition: Tool = { 5 | name: "RestartLevel", 6 | description: 7 | "Restarts the level, resetting all blocks to their initial positions. Does NOT delete the player - call DeletePlayer first, then this, then SpawnPlayer!", 8 | input_schema: { 9 | type: "object", 10 | properties: {}, 11 | required: [], 12 | }, 13 | }; 14 | 15 | export const toolHandler: ToolHandler = async (_input) => { 16 | const data = await callGameAPI("restart-level", []); 17 | 18 | const result = (data.result as { ok?: boolean; error?: string }) || {}; 19 | 20 | if (result.ok) { 21 | return JSON.stringify({ 22 | success: true, 23 | message: "Level restarted successfully", 24 | }); 25 | } else { 26 | return JSON.stringify({ 27 | success: false, 28 | error: result.error || "Unknown error", 29 | }); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /environments/portalbench/src/mechanics/placed-bomb.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Entity, EntityRef, Tilemap, value } from "@dreamlab/engine"; 2 | import { Colors } from "../../lib/colors.ts"; 3 | 4 | export default class PlacedBomb extends Behavior { 5 | @value() 6 | fuseTicks: number = 60; 7 | 8 | @value({ type: EntityRef }) 9 | tilemap: Entity | undefined; 10 | 11 | onTick(): void { 12 | if (!this.game.isServer()) return; 13 | if (this.fuseTicks > 0) { 14 | this.fuseTicks -= 1; 15 | return; 16 | } 17 | 18 | // fuse at 0 19 | const pos = this.entity.pos.floor(); 20 | const tilemap = this.tilemap?.cast(Tilemap); 21 | if (!tilemap) throw new Error("missing tilemap"); 22 | 23 | for (let x = pos.x - 1; x <= pos.x + 1; x++) { 24 | for (let y = pos.y - 1; y <= pos.y + 1; y++) { 25 | const tile = tilemap.getColor(x, y); 26 | if (tile === Colors.BombWall) tilemap.setColor(x, y, Colors.Grass); 27 | } 28 | } 29 | 30 | this.entity.destroy(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /environments/openmonsters/src/mechanics/placed-bomb.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Entity, EntityRef, Tilemap, value } from "@dreamlab/engine"; 2 | import { Colors } from "../../lib/colors.ts"; 3 | 4 | export default class PlacedBomb extends Behavior { 5 | @value() 6 | fuseTicks: number = 60; 7 | 8 | @value({ type: EntityRef }) 9 | tilemap: Entity | undefined; 10 | 11 | onTick(): void { 12 | if (!this.game.isServer()) return; 13 | if (this.fuseTicks > 0) { 14 | this.fuseTicks -= 1; 15 | return; 16 | } 17 | 18 | // fuse at 0 19 | const pos = this.entity.pos.floor(); 20 | const tilemap = this.tilemap?.cast(Tilemap); 21 | if (!tilemap) throw new Error("missing tilemap"); 22 | 23 | for (let x = pos.x - 1; x <= pos.x + 1; x++) { 24 | for (let y = pos.y - 1; y <= pos.y + 1; y++) { 25 | const tile = tilemap.getColor(x, y); 26 | if (tile === Colors.BombWall) tilemap.setColor(x, y, Colors.Grass); 27 | } 28 | } 29 | 30 | this.entity.destroy(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /harness/src/llm/types.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for LLM messages, tools, and content blocks 2 | 3 | export interface TextContent { 4 | type: "text"; 5 | text: string; 6 | } 7 | 8 | export interface ToolUseContent { 9 | type: "tool_use"; 10 | id: string; 11 | name: string; 12 | input: Record; 13 | } 14 | 15 | export interface ToolResultContent { 16 | type: "tool_result"; 17 | tool_use_id: string; 18 | content: string; 19 | } 20 | 21 | export type MessageContent = TextContent | ToolUseContent | ToolResultContent; 22 | 23 | export interface Message { 24 | role: "user" | "assistant" | "tool"; 25 | content: string | MessageContent[]; 26 | reasoning_details?: Array>; 27 | } 28 | 29 | export interface Tool { 30 | name: string; 31 | description: string; 32 | input_schema: { 33 | type: "object"; 34 | properties: Record; 35 | required: string[]; 36 | }; 37 | } 38 | 39 | export type ToolHandler = (input: Record) => Promise; 40 | -------------------------------------------------------------------------------- /harness/src/tools/http-client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared HTTP client for calling the game API. 3 | */ 4 | 5 | const GAME_API_BASE = 6 | "http://localhost:8001/api/v1/instance/00000000-0000-0000-0000-000000000000/call"; 7 | 8 | export interface GameAPIResponse { 9 | result?: unknown; 10 | error?: string; 11 | } 12 | 13 | /** 14 | * Call the game API with an identifier and parameters. 15 | * 16 | * @param identifier - The API method identifier (e.g., "spawn-player", "move-player") 17 | * @param params - Array of parameters to pass to the API method 18 | * @returns The JSON response from the API 19 | */ 20 | export async function callGameAPI( 21 | identifier: string, 22 | params: unknown[] 23 | ): Promise { 24 | console.log(GAME_API_BASE, { identifier, params }); 25 | const response = await fetch(GAME_API_BASE, { 26 | method: "POST", 27 | headers: { "Content-Type": "application/json" }, 28 | body: JSON.stringify({ identifier, params }), 29 | }); 30 | 31 | return await response.json(); 32 | } 33 | -------------------------------------------------------------------------------- /harness/prompts/portals.txt: -------------------------------------------------------------------------------- 1 | You are playing a puzzle game where you can place portals to teleport between locations. 2 | 3 | Portal Rules: 4 | - You can place blue and orange portals 5 | - Portals must be placed on wall tiles 6 | - Portals must be within your line of sight 7 | - Only one portal of each color can exist at a time 8 | - Walking into one portal teleports you to the other portal 9 | - Placing a new portal removes the old portal of the same color 10 | 11 | Available tools: 12 | - SpawnPlayer: Creates a player and returns a ref you'll use for all actions 13 | - ObserveWorld: Shows the current state of the game world around you 14 | - MovePlayer: Moves the player one tile at a time (+1, 0, or -1 for x and y) 15 | - PlacePortal: Places a blue or orange portal at specific tile coordinates 16 | - GetPortals: Shows where portals are currently placed 17 | 18 | Your goal: Explore the world, place portals strategically to solve puzzles, and reach the win tile! 19 | 20 | Start by spawning a player, observing the world, and then experiment with portal placement. 21 | -------------------------------------------------------------------------------- /harness/src/llm/wrapper.ts: -------------------------------------------------------------------------------- 1 | import type { Message, Tool } from "./types.ts"; 2 | 3 | /** 4 | * Abstract base class for LLM wrapper implementations. 5 | * 6 | * All wrappers convert provider-specific formats to/from a common message format 7 | * based on Anthropic's message structure (since it's the most feature-rich). 8 | */ 9 | export abstract class LLMWrapper { 10 | /** 11 | * Stream text responses from the LLM. 12 | * 13 | * @param messages - Conversation history in Anthropic message format 14 | * @param options - Configuration options for the request 15 | * @yields Text chunks as they arrive from the API 16 | */ 17 | abstract streamMessages( 18 | messages: Message[], 19 | options: { 20 | maxTokens: number; 21 | temperature?: number; 22 | tools?: Tool[]; 23 | } 24 | ): AsyncGenerator; 25 | 26 | /** 27 | * Get the final message object after streaming. 28 | * 29 | * @returns Complete message with role, content, and optional tool calls 30 | */ 31 | abstract getFinalMessage(): Message; 32 | } 33 | -------------------------------------------------------------------------------- /harness/src/tools/place-bomb.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | 4 | export const toolDefinition: Tool = { 5 | name: "PlaceBomb", 6 | description: 7 | "Places a bomb at the player's current location. The bomb will explode after a delay, destroying bombable walls (X) and potentially harming enemies.", 8 | input_schema: { 9 | type: "object", 10 | properties: { 11 | ref: { type: "string", description: "The player ref." }, 12 | }, 13 | required: ["ref"], 14 | }, 15 | }; 16 | 17 | export const toolHandler: ToolHandler = async (input) => { 18 | const ref = input.ref as string; 19 | 20 | if (!ref) { 21 | return JSON.stringify({ error: "Missing required parameter: ref" }); 22 | } 23 | 24 | const data = await callGameAPI("place-bomb", [ref]); 25 | 26 | const result = (data.result as { ok?: boolean; error?: string }) || {}; 27 | 28 | if (result.ok) { 29 | return JSON.stringify({ 30 | success: true, 31 | message: "Bomb placed successfully at player location", 32 | }); 33 | } else { 34 | return JSON.stringify({ 35 | success: false, 36 | error: result.error || "Unknown error", 37 | }); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /environments/openmonsters/src/ui/dialogue-text.tsx: -------------------------------------------------------------------------------- 1 | import { UIBehavior, value } from "@dreamlab/engine"; 2 | import type { BaseElement } from "@dreamlab/ui"; 3 | 4 | const css = ` 5 | div.text { 6 | padding: 1.2rem 1.5rem; 7 | background: rgb(0 0 0 / 80%); 8 | transform: translateY(-3rem); 9 | border-radius: 8px; 10 | opacity: 0; 11 | transition: opacity 0.15s ease-in-out; 12 | 13 | &[data-visible] { 14 | opacity: 1; 15 | } 16 | } 17 | `; 18 | 19 | export default class DialogueTileText extends UIBehavior { 20 | @value() 21 | text: string = ""; 22 | 23 | @value() 24 | visible: boolean = false; 25 | 26 | onInitialize(): void { 27 | super.onInitialize(); 28 | this.setCss(css); 29 | 30 | this.values.get("text")?.onChanged(() => this.rerender()); 31 | this.values.get("visible")?.onChanged(() => this.rerender()); 32 | } 33 | 34 | protected render(): BaseElement { 35 | return ( 36 |
46 | {this.text} 47 |
48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /environments/portalbench/src/ui/dialogue-text.tsx: -------------------------------------------------------------------------------- 1 | import { UIBehavior, value } from "@dreamlab/engine"; 2 | import type { BaseElement } from "@dreamlab/ui"; 3 | 4 | const css = ` 5 | div.text { 6 | padding: 1.2rem 1.5rem; 7 | background: rgb(0 0 0 / 80%); 8 | transform: translateY(-3rem); 9 | border-radius: 8px; 10 | opacity: 0; 11 | transition: opacity 0.15s ease-in-out; 12 | 13 | &[data-visible] { 14 | opacity: 1; 15 | } 16 | } 17 | `; 18 | 19 | export default class DialogueTileText extends UIBehavior { 20 | @value() 21 | text: string = ""; 22 | 23 | @value() 24 | visible: boolean = false; 25 | 26 | onInitialize(): void { 27 | super.onInitialize(); 28 | this.setCss(css); 29 | 30 | this.values.get("text")?.onChanged(() => this.rerender()); 31 | this.values.get("visible")?.onChanged(() => this.rerender()); 32 | } 33 | 34 | protected render(): BaseElement { 35 | return ( 36 |
46 | {this.text} 47 |
48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /environments/portalbench/src/tiles/action-manager.ts: -------------------------------------------------------------------------------- 1 | import { Behavior } from "@dreamlab/engine"; 2 | import TileAction from "./action.ts"; 3 | 4 | export default class TileActionManager extends Behavior { 5 | static instance: TileActionManager | undefined = undefined; 6 | 7 | private tileActions = new Map(); 8 | 9 | onInitialize(): void { 10 | if (!this.game.isServer()) return; 11 | if (TileActionManager.instance) { 12 | throw new Error("only one TileActionManager should exist"); 13 | } 14 | TileActionManager.instance = this; 15 | } 16 | 17 | onTick() { 18 | if (!this.game.isServer()) return; 19 | // if (this.game.time.ticks % 240 === 0) { 20 | // for (let v of this.tileActions.entries()) { 21 | // console.log(v[0], v[1].entity.name); 22 | // } 23 | // } 24 | } 25 | 26 | public register(x: number, y: number, action: any): void { 27 | const key = `${Math.floor(x)},${Math.floor(y)}`; 28 | this.tileActions.set(key, action); 29 | } 30 | 31 | public remove(x: number, y: number): void { 32 | const key = `${Math.floor(x)},${Math.floor(y)}`; 33 | this.tileActions.delete(key); 34 | } 35 | 36 | public lookup(x: number, y: number): TileAction | undefined { 37 | const key = `${Math.floor(x)},${Math.floor(y)}`; 38 | return this.tileActions.get(key); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /harness/src/tools/delete-player.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | 4 | export const toolDefinition: Tool = { 5 | name: "DeletePlayer", 6 | description: 7 | "Deletes a player from the game. Use this before restarting a level to clean up the old player instance.", 8 | input_schema: { 9 | type: "object", 10 | properties: { 11 | ref: { type: "string", description: "The player ref to delete." }, 12 | }, 13 | required: ["ref"], 14 | }, 15 | }; 16 | 17 | export const toolHandler: ToolHandler = async (input) => { 18 | const ref = input.ref as string; 19 | 20 | if (!ref) { 21 | return JSON.stringify({ error: "Missing required parameter: ref" }); 22 | } 23 | 24 | const data = await callGameAPI("delete-player", [ref]); 25 | 26 | if (!data.result) { 27 | return JSON.stringify({ 28 | error: data.error || "Unknown error - no result in response", 29 | }); 30 | } 31 | 32 | const result = (data.result as { ok?: boolean; error?: string }) || {}; 33 | 34 | if (result.ok) { 35 | return JSON.stringify({ 36 | success: true, 37 | message: "Player deleted successfully", 38 | }); 39 | } else { 40 | return JSON.stringify({ 41 | success: false, 42 | error: result.error || "Unknown error", 43 | }); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /environments/openmonsters/src/tiles/action-manager.ts: -------------------------------------------------------------------------------- 1 | import { Behavior } from "@dreamlab/engine"; 2 | import TileAction from "./action.ts"; 3 | 4 | export default class TileActionManager extends Behavior { 5 | static instance: TileActionManager | undefined = undefined; 6 | 7 | private tileActions = new Map(); 8 | 9 | onInitialize(): void { 10 | if (!this.game.isServer()) return; 11 | console.log("init"); 12 | if (TileActionManager.instance) { 13 | throw new Error("only one TileActionManager should exist"); 14 | } 15 | TileActionManager.instance = this; 16 | } 17 | 18 | onTick() { 19 | if (!this.game.isServer()) return; 20 | // if (this.game.time.ticks % 240 === 0) { 21 | // for (let v of this.tileActions.entries()) { 22 | // console.log(v[0], v[1].entity.name); 23 | // } 24 | // } 25 | } 26 | 27 | public register(x: number, y: number, action: any): void { 28 | const key = `${Math.floor(x)},${Math.floor(y)}`; 29 | this.tileActions.set(key, action); 30 | } 31 | 32 | public remove(x: number, y: number): void { 33 | const key = `${Math.floor(x)},${Math.floor(y)}`; 34 | this.tileActions.delete(key); 35 | } 36 | 37 | public lookup(x: number, y: number): TileAction | undefined { 38 | const key = `${Math.floor(x)},${Math.floor(y)}`; 39 | return this.tileActions.get(key); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /harness/src/tools/get-portals.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | 4 | export const toolDefinition: Tool = { 5 | name: "GetPortals", 6 | description: 7 | "Gets the current status of all portals in the world. Returns the positions of blue and orange portals if they exist, or null if they don't exist.", 8 | input_schema: { 9 | type: "object", 10 | properties: {}, 11 | required: [], 12 | }, 13 | }; 14 | 15 | export const toolHandler: ToolHandler = async (_input) => { 16 | const data = await callGameAPI("get-portals", []); 17 | 18 | if (!data.result) { 19 | return JSON.stringify({ 20 | error: data.error || "Unknown error - no result in response", 21 | }); 22 | } 23 | 24 | const result = data.result as { 25 | ok?: boolean; 26 | portals?: { 27 | bluePortal?: { x: number; y: number }; 28 | orangePortal?: { x: number; y: number }; 29 | }; 30 | error?: string; 31 | }; 32 | 33 | if (result.ok) { 34 | const portals = result.portals || {}; 35 | const blue = portals.bluePortal; 36 | const orange = portals.orangePortal; 37 | 38 | const status = { 39 | blue_portal: blue ? `at (${blue.x}, ${blue.y})` : "not placed", 40 | orange_portal: orange ? `at (${orange.x}, ${orange.y})` : "not placed", 41 | }; 42 | 43 | return JSON.stringify(status); 44 | } else { 45 | return JSON.stringify({ error: result.error || "Unknown error" }); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /environments/openmonsters/src/mechanics/door-handler.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Entity, EntityRef, Tilemap, value } from "@dreamlab/engine"; 2 | import { Colors } from "../../lib/colors.ts"; 3 | import InventoryBehavior from "../player/inventory.ts"; 4 | import { PlayerMoved } from "../player/movement.ts"; 5 | 6 | export default class DoorHandler extends Behavior { 7 | @value({ type: EntityRef }) 8 | tilemap: Entity | undefined; 9 | 10 | onInitialize(): void { 11 | const tilemap = this.tilemap?.cast(Tilemap); 12 | if (!tilemap) throw new Error("missing tilemap"); 13 | 14 | this.listen(this.game, PlayerMoved, ev => { 15 | const tile = tilemap.getColor(ev.position.x, ev.position.y); 16 | if (tile === undefined) return; 17 | 18 | if (tile === Colors.GreenDoor || tile === Colors.BlueDoor) { 19 | const inventory = ev.player.entity.getBehavior(InventoryBehavior); 20 | if (!inventory) { 21 | ev.cancelled = true; 22 | return; 23 | } 24 | 25 | const isGreenDoor = tile === Colors.GreenDoor; 26 | const hasKey = isGreenDoor ? inventory.greenKeys > 0 : inventory.blueKeys > 0; 27 | 28 | if (hasKey) { 29 | if (isGreenDoor) { 30 | inventory.greenKeys -= 1; 31 | } else { 32 | inventory.blueKeys -= 1; 33 | } 34 | 35 | tilemap.setColor(ev.position.x, ev.position.y, Colors.Grass); 36 | } else { 37 | ev.cancelled = true; 38 | } 39 | } 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /environments/portalbench/src/mechanics/door-handler.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Entity, EntityRef, Tilemap, value } from "@dreamlab/engine"; 2 | import { Colors } from "../../lib/colors.ts"; 3 | import InventoryBehavior from "../player/inventory.ts"; 4 | import { PlayerMoved } from "../player/movement.ts"; 5 | 6 | export default class DoorHandler extends Behavior { 7 | @value({ type: EntityRef }) 8 | tilemap: Entity | undefined; 9 | 10 | onInitialize(): void { 11 | const tilemap = this.tilemap?.cast(Tilemap); 12 | if (!tilemap) throw new Error("missing tilemap"); 13 | 14 | this.listen(this.game, PlayerMoved, ev => { 15 | const tile = tilemap.getColor(ev.position.x, ev.position.y); 16 | if (tile === undefined) return; 17 | 18 | if (tile === Colors.GreenDoor || tile === Colors.BlueDoor) { 19 | const inventory = ev.player.entity.getBehavior(InventoryBehavior); 20 | if (!inventory) { 21 | ev.cancelled = true; 22 | return; 23 | } 24 | 25 | const isGreenDoor = tile === Colors.GreenDoor; 26 | const hasKey = isGreenDoor ? inventory.greenKeys > 0 : inventory.blueKeys > 0; 27 | 28 | if (hasKey) { 29 | if (isGreenDoor) { 30 | inventory.greenKeys -= 1; 31 | } else { 32 | inventory.blueKeys -= 1; 33 | } 34 | 35 | tilemap.setColor(ev.position.x, ev.position.y, Colors.Grass); 36 | } else { 37 | ev.cancelled = true; 38 | } 39 | } 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /environments/openmonsters/src/mechanics/color-action.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Entity, EntityRef, Tilemap, value } from "@dreamlab/engine"; 2 | import { Colors } from "../../lib/colors.ts"; 3 | import { PlayerMoved } from "../player/movement.ts"; 4 | 5 | export default class ColorAction extends Behavior { 6 | @value({ type: EntityRef }) 7 | tilemap: Entity | undefined; 8 | 9 | onInitialize(): void { 10 | const tilemap = this.tilemap?.cast(Tilemap); 11 | if (!tilemap) throw new Error("missing tilemap"); 12 | 13 | this.listen(this.game, PlayerMoved, ev => { 14 | const tile = tilemap.getColor(ev.position.x, ev.position.y); 15 | if (tile === undefined) return; 16 | this.#onTile(tile, ev); 17 | }); 18 | } 19 | 20 | #onTile(color: number, ev: PlayerMoved): void { 21 | switch (color) { 22 | case Colors.Water: { 23 | // Dark abyss - very slow or blocked 24 | ev.delay += 20; 25 | break; 26 | } 27 | 28 | case Colors.Sand: { 29 | // Stone floor (light) - normal speed 30 | break; 31 | } 32 | 33 | case Colors.Grass: { 34 | // Floor tiles (medium) - normal speed 35 | // Could add random encounters or traps here 36 | if (Math.random() > 0.8) { 37 | // console.log("random encounter!!"); 38 | } 39 | break; 40 | } 41 | 42 | case Colors.Slowdown: { 43 | // Mud/tar - slows player down significantly 44 | ev.delay += 15; 45 | break; 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /environments/portalbench/src/mechanics/color-action.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Entity, EntityRef, Tilemap, value } from "@dreamlab/engine"; 2 | import { Colors } from "../../lib/colors.ts"; 3 | import { PlayerMoved } from "../player/movement.ts"; 4 | 5 | export default class ColorAction extends Behavior { 6 | @value({ type: EntityRef }) 7 | tilemap: Entity | undefined; 8 | 9 | onInitialize(): void { 10 | const tilemap = this.tilemap?.cast(Tilemap); 11 | if (!tilemap) throw new Error("missing tilemap"); 12 | 13 | this.listen(this.game, PlayerMoved, ev => { 14 | const tile = tilemap.getColor(ev.position.x, ev.position.y); 15 | if (tile === undefined) return; 16 | this.#onTile(tile, ev); 17 | }); 18 | } 19 | 20 | #onTile(color: number, ev: PlayerMoved): void { 21 | switch (color) { 22 | case Colors.Water: { 23 | // Dark abyss - very slow or blocked 24 | ev.delay += 20; 25 | break; 26 | } 27 | 28 | case Colors.Sand: { 29 | // Stone floor (light) - normal speed 30 | break; 31 | } 32 | 33 | case Colors.Grass: { 34 | // Floor tiles (medium) - normal speed 35 | // Could add random encounters or traps here 36 | if (Math.random() > 0.8) { 37 | // console.log("random encounter!!"); 38 | } 39 | break; 40 | } 41 | 42 | case Colors.Slowdown: { 43 | // Mud/tar - slows player down significantly 44 | ev.delay += 15; 45 | break; 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /harness/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "specifiers": { 4 | "jsr:@std/cli@1": "1.0.22", 5 | "jsr:@std/dotenv@*": "0.225.5", 6 | "jsr:@std/dotenv@0.225": "0.225.5", 7 | "npm:@clack/prompts@0.11": "0.11.0", 8 | "npm:openai@^6.10.0": "6.10.0" 9 | }, 10 | "jsr": { 11 | "@std/cli@1.0.22": { 12 | "integrity": "50d1e4f87887cb8a8afa29b88505ab5081188f5cad3985460c3b471fa49ff21a" 13 | }, 14 | "@std/dotenv@0.225.5": { 15 | "integrity": "9ce6f9d0ec3311f74a32535aa1b8c62ed88b1ab91b7f0815797d77a6f60c922f" 16 | } 17 | }, 18 | "npm": { 19 | "@clack/core@0.5.0": { 20 | "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", 21 | "dependencies": [ 22 | "picocolors", 23 | "sisteransi" 24 | ] 25 | }, 26 | "@clack/prompts@0.11.0": { 27 | "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", 28 | "dependencies": [ 29 | "@clack/core", 30 | "picocolors", 31 | "sisteransi" 32 | ] 33 | }, 34 | "openai@6.10.0": { 35 | "integrity": "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==", 36 | "bin": true 37 | }, 38 | "picocolors@1.1.1": { 39 | "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" 40 | }, 41 | "sisteransi@1.0.5": { 42 | "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" 43 | } 44 | }, 45 | "workspace": { 46 | "dependencies": [ 47 | "jsr:@std/cli@1", 48 | "jsr:@std/dotenv@0.225", 49 | "npm:@clack/prompts@0.11", 50 | "npm:openai@^6.10.0" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /harness/src/context.ts: -------------------------------------------------------------------------------- 1 | let modelName = "Puppet"; 2 | 3 | export function setModelName(name: string): void { 4 | modelName = name; 5 | } 6 | 7 | export function getModelName(): string { 8 | return modelName; 9 | } 10 | 11 | export function extractShortModelName(model: string): string { 12 | const modelLower = model.toLowerCase(); 13 | 14 | if (modelLower.includes("gpt-4o")) { 15 | return "GPT4o"; 16 | } else if (modelLower.includes("gpt-4")) { 17 | return "GPT4"; 18 | } else if (modelLower.includes("gpt-3.5") || modelLower.includes("gpt-35")) { 19 | return "GPT35"; 20 | } else if (modelLower.includes("o1") || modelLower.includes("o3")) { 21 | return modelLower.includes("o1") ? "O1" : "O3"; 22 | } else if (modelLower.includes("claude")) { 23 | if (modelLower.includes("opus")) { 24 | return "Opus"; 25 | } else if (modelLower.includes("sonnet")) { 26 | return "Sonnet"; 27 | } else if (modelLower.includes("haiku")) { 28 | return "Haiku"; 29 | } else { 30 | return "Claude"; 31 | } 32 | } else if (modelLower.includes("gemini")) { 33 | if (modelLower.includes("flash")) { 34 | return "GeminiFlash"; 35 | } else if (modelLower.includes("pro")) { 36 | return "Gemini Pro 3"; 37 | } else { 38 | return "Gemini"; 39 | } 40 | } else if (modelLower.includes("llama")) { 41 | return "Llama"; 42 | } else if (modelLower.includes("mistral")) { 43 | return "Mistral"; 44 | } else if (modelLower.includes("deepseek")) { 45 | return "DeepSeek"; 46 | } else { 47 | const parts = model.split("/"); 48 | const lastPart = parts[parts.length - 1]; 49 | const firstWord = lastPart.split("-")[0]; 50 | return ( 51 | firstWord.substring(0, 10).charAt(0).toUpperCase() + 52 | firstWord.substring(1, 10) 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /harness/src/tools/level-select.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | 4 | export const toolDefinition: Tool = { 5 | name: "LevelSelect", 6 | description: 7 | "Teleports a player to a specific level. Use this to navigate between normal levels or sokoban puzzle levels.", 8 | input_schema: { 9 | type: "object", 10 | properties: { 11 | gametype: { 12 | type: "string", 13 | enum: ["normal", "sokoban"], 14 | description: 15 | "The type of game level - either 'normal' for standard levels or 'sokoban' for puzzle levels", 16 | }, 17 | level_number: { 18 | type: "integer", 19 | minimum: 1, 20 | description: "The level number to teleport to (must be positive)", 21 | }, 22 | ref: { 23 | type: "string", 24 | description: "The player ref to teleport", 25 | }, 26 | }, 27 | required: ["gametype", "level_number", "ref"], 28 | }, 29 | }; 30 | 31 | export const toolHandler: ToolHandler = async (input) => { 32 | const gametype = input.gametype as string; 33 | const levelNumber = input.level_number as number; 34 | const ref = input.ref as string; 35 | 36 | if (!gametype || !levelNumber || !ref) { 37 | return JSON.stringify({ 38 | error: "Missing required parameters: gametype, level_number, or ref", 39 | }); 40 | } 41 | 42 | const data = await callGameAPI("level-select", [gametype, levelNumber, ref]); 43 | 44 | if (!data.result) { 45 | return JSON.stringify({ 46 | error: data.error || "Unknown error - no result in response", 47 | }); 48 | } 49 | 50 | const result = (data.result as { ok?: boolean; error?: string }) || {}; 51 | 52 | if (result.ok) { 53 | return JSON.stringify({ 54 | success: true, 55 | message: `Player teleported to ${gametype} level ${levelNumber}`, 56 | }); 57 | } else { 58 | return JSON.stringify({ 59 | success: false, 60 | error: result.error || "Unknown error", 61 | }); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /harness/src/tools/place-portal.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | 4 | export const toolDefinition: Tool = { 5 | name: "PlacePortal", 6 | description: 7 | "Places a blue or orange portal at the specified tile coordinates. The portal must be placed on a wall tile that is within the player's line of sight. Only one portal of each color can exist at a time - placing a new portal removes the old one of the same color.", 8 | input_schema: { 9 | type: "object", 10 | properties: { 11 | ref: { 12 | type: "string", 13 | description: "The player ref.", 14 | }, 15 | color: { 16 | type: "string", 17 | enum: ["blue", "orange"], 18 | description: "The color of the portal to place (blue or orange).", 19 | }, 20 | x: { 21 | type: "number", 22 | description: "The x coordinate of the target tile.", 23 | }, 24 | y: { 25 | type: "number", 26 | description: "The y coordinate of the target tile.", 27 | }, 28 | }, 29 | required: ["ref", "color", "x", "y"], 30 | }, 31 | }; 32 | 33 | export const toolHandler: ToolHandler = async (input) => { 34 | const ref = input.ref as string; 35 | const color = input.color as string; 36 | const x = input.x as number; 37 | const y = input.y as number; 38 | 39 | const data = await callGameAPI("place-portal", [ 40 | ref, 41 | color, 42 | { x: Math.floor(x), y: Math.floor(y) }, 43 | ]); 44 | 45 | if (!data.result) { 46 | return JSON.stringify({ 47 | error: data.error || "Unknown error - no result in response", 48 | }); 49 | } 50 | 51 | const result = data.result as { ok?: boolean; error?: string }; 52 | if (result.ok) { 53 | return JSON.stringify({ 54 | success: true, 55 | message: `${color.charAt(0).toUpperCase() + color.slice(1)} portal placed at (${x}, ${y})`, 56 | }); 57 | } else { 58 | return JSON.stringify({ 59 | success: false, 60 | error: result.error || "Unknown error", 61 | }); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /harness/src/tools/player-move.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | import { callGameAPI } from "./http-client.ts"; 3 | 4 | export const toolDefinition: Tool = { 5 | name: "MovePlayer", 6 | description: 7 | "Moves player in multiple directions. Each move's x and y can only be +1 or -1. Executes moves sequentially and returns all results.", 8 | input_schema: { 9 | type: "object", 10 | properties: { 11 | ref: { type: "string", description: "The player ref." }, 12 | moves: { 13 | type: "array", 14 | description: 15 | "Array of moves to execute in sequence. Each x or y value can only be 1, -1, or 0. +y is down, +x is right. Diagonal moves supported.", 16 | items: { 17 | type: "object", 18 | properties: { 19 | x: { type: "number", description: "x coordinate to move" }, 20 | y: { type: "number", description: "y coordinate to move" }, 21 | }, 22 | required: ["x", "y"], 23 | }, 24 | }, 25 | }, 26 | required: ["ref", "moves"], 27 | }, 28 | }; 29 | 30 | export const toolHandler: ToolHandler = async (input) => { 31 | const ref = input.ref as string; 32 | const moves = input.moves as Array<{ x: number; y: number }>; 33 | 34 | if (!ref || !moves) { 35 | return JSON.stringify({ 36 | error: "Missing required parameters: ref or moves", 37 | }); 38 | } 39 | 40 | const results: unknown[] = []; 41 | 42 | for (const move of moves) { 43 | const x = move.x; 44 | const y = move.y; 45 | 46 | // It absolutely breaks its brain to have -y be down so we just flip the sign 47 | const data = await callGameAPI("move-player", [ref, { x: x, y: y * -1 }]); 48 | 49 | await new Promise((resolve) => setTimeout(resolve, 500)); 50 | 51 | if (!data.result) { 52 | return JSON.stringify({ 53 | error: data.error || "Unknown error - no result in response", 54 | }); 55 | } 56 | 57 | results.push(data.result); 58 | } 59 | 60 | return JSON.stringify({ 61 | moves_executed: results.length, 62 | results: results, 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /environments/openmonsters/src/tiles/action.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, EntityDestroyed, IVector2, value, Vector2 } from "@dreamlab/engine"; 2 | import { PlayerMoved } from "../player/movement.ts"; 3 | import TileActionManager from "./action-manager.ts"; 4 | 5 | export default class TileAction extends Behavior { 6 | @value() 7 | debug: boolean = false; 8 | 9 | get tilePos(): IVector2 { 10 | return this.entity.pos.round(); 11 | } 12 | 13 | onInitialize(): void { 14 | if (!this.game.isServer()) return; 15 | if (!this.debug) { 16 | // disable any children used for editor visibility 17 | for (const child of this.entity.children.values()) { 18 | // child.enabled = false; 19 | } 20 | } 21 | 22 | this.entity.pos = this.entity.pos.round(); 23 | 24 | // Register this tile action with the managert 25 | 26 | this.game.time.waitForNextTick().then(() => { 27 | const manager = TileActionManager.instance; 28 | if (manager) { 29 | const pos = this.tilePos; 30 | manager.register(pos.x, pos.y, this); 31 | } 32 | }); 33 | 34 | this.listen(this.entity, EntityDestroyed, () => { 35 | const manager = TileActionManager.instance; 36 | if (manager) { 37 | const pos = this.tilePos; 38 | manager.remove(pos.x, pos.y); 39 | } 40 | }); 41 | 42 | this.listen(this.game, PlayerMoved, ev => { 43 | const inTile = Vector2.eq(ev.position, this.tilePos); 44 | if (!this.#inTile && inTile) this.onTileEnter(ev); 45 | else if (this.#inTile && !inTile) this.onTileExit(ev); 46 | 47 | this.#inTile = inTile; 48 | }); 49 | } 50 | 51 | onDestroy(): void { 52 | // Unregister from the manager when destroyed 53 | const manager = TileActionManager.instance; 54 | if (manager) { 55 | const pos = this.tilePos; 56 | manager.remove(pos.x, pos.y); 57 | } 58 | } 59 | 60 | #inTile: boolean = false; 61 | 62 | // deno-lint-ignore no-unused-vars 63 | public onTileEnter(ev: PlayerMoved): void { 64 | // implemented in child classes 65 | } 66 | 67 | // deno-lint-ignore no-unused-vars 68 | public onTileExit(ev: PlayerMoved): void { 69 | // implemented in child classes 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /environments/portalbench/src/tiles/action.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, EntityDestroyed, IVector2, value, Vector2 } from "@dreamlab/engine"; 2 | import { PlayerMoved } from "../player/movement.ts"; 3 | import TileActionManager from "./action-manager.ts"; 4 | 5 | export default class TileAction extends Behavior { 6 | @value() 7 | debug: boolean = false; 8 | 9 | get tilePos(): IVector2 { 10 | return this.entity.pos.round(); 11 | } 12 | 13 | onInitialize(): void { 14 | if (!this.game.isServer()) return; 15 | if (!this.debug) { 16 | // disable any children used for editor visibility 17 | for (const child of this.entity.children.values()) { 18 | // child.enabled = false; 19 | } 20 | } 21 | 22 | this.entity.pos = this.entity.pos.round(); 23 | 24 | // Register this tile action with the managert 25 | 26 | this.game.time.waitForNextTick().then(() => { 27 | const manager = TileActionManager.instance; 28 | if (manager) { 29 | const pos = this.tilePos; 30 | manager.register(pos.x, pos.y, this); 31 | } 32 | }); 33 | 34 | this.listen(this.entity, EntityDestroyed, () => { 35 | const manager = TileActionManager.instance; 36 | if (manager) { 37 | const pos = this.tilePos; 38 | manager.remove(pos.x, pos.y); 39 | } 40 | }); 41 | 42 | this.listen(this.game, PlayerMoved, ev => { 43 | const inTile = Vector2.eq(ev.position, this.tilePos); 44 | if (!this.#inTile && inTile) this.onTileEnter(ev); 45 | else if (this.#inTile && !inTile) this.onTileExit(ev); 46 | 47 | this.#inTile = inTile; 48 | }); 49 | } 50 | 51 | onDestroy(): void { 52 | // Unregister from the manager when destroyed 53 | const manager = TileActionManager.instance; 54 | if (manager) { 55 | const pos = this.tilePos; 56 | manager.remove(pos.x, pos.y); 57 | } 58 | } 59 | 60 | #inTile: boolean = false; 61 | 62 | // deno-lint-ignore no-unused-vars 63 | public onTileEnter(ev: PlayerMoved): void { 64 | // implemented in child classes 65 | } 66 | 67 | // deno-lint-ignore no-unused-vars 68 | public onTileExit(ev: PlayerMoved): void { 69 | // implemented in child classes 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /environments/portalbench/src/tiles/dialogue.ts: -------------------------------------------------------------------------------- 1 | import { Entity, EntityDestroyed, EntityRef, UIPanel, value } from "@dreamlab/engine"; 2 | import DialogueTileText from "../ui/dialogue-text.tsx"; 3 | import PlayerMetrics from "../player/metrics.ts"; 4 | import { PlayerMoved } from "../player/movement.ts"; 5 | import TileAction from "./action.ts"; 6 | 7 | export default class DialogueTile extends TileAction { 8 | @value({ type: EntityRef }) 9 | prefab: Entity | undefined; 10 | 11 | @value() 12 | text: string = ""; 13 | 14 | @value() 15 | isShowing = false; 16 | 17 | #ui: UIPanel | undefined; 18 | 19 | onInitialize(): void { 20 | super.onInitialize(); 21 | 22 | if (!this.prefab) throw new Error("missing text prefab"); 23 | 24 | if (this.game.isClient()) { 25 | this.#ui = this.prefab 26 | .cloneInto(this.game.local, { 27 | name: `text_${this.ref}`, 28 | transform: { position: this.tilePos }, 29 | }) 30 | .cast(UIPanel); 31 | 32 | const display = this.#ui.getBehavior(DialogueTileText); 33 | display.text = this.text; 34 | } 35 | 36 | this.values.get("text")?.onChanged(() => { 37 | if (!this.#ui) return; 38 | const display = this.#ui.getBehavior(DialogueTileText); 39 | display.text = this.text; 40 | }); 41 | 42 | this.values.get("isShowing")?.onChanged(() => { 43 | if (!this.#ui) return; 44 | this.#ui.getBehavior(DialogueTileText).visible = this.isShowing; 45 | }); 46 | 47 | this.listen(this.entity, EntityDestroyed, () => { 48 | this.isShowing = false; 49 | }); 50 | } 51 | 52 | public onTileEnter(ev: PlayerMoved): void { 53 | this.isShowing = true; 54 | 55 | ev.actions.push({ id: "dialogue", data: { text: this.text, visible: true } }); 56 | 57 | if (this.text.toLowerCase().includes("won") || this.entity.name === "Win") { 58 | const metrics = ev.player.entity.getBehavior(PlayerMetrics); 59 | if (metrics) { 60 | metrics.recordFinish(); 61 | } 62 | } 63 | } 64 | 65 | public onTileExit(ev: PlayerMoved): void { 66 | this.isShowing = false; 67 | 68 | ev.actions.push({ id: "dialogue", data: { text: this.text, visible: false } }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /environments/openmonsters/src/tiles/dialogue.ts: -------------------------------------------------------------------------------- 1 | import { Entity, EntityDestroyed, EntityRef, UIPanel, value } from "@dreamlab/engine"; 2 | import DialogueTileText from "../ui/dialogue-text.tsx"; 3 | import PlayerMetrics from "../player/metrics.ts"; 4 | import { PlayerMoved } from "../player/movement.ts"; 5 | import TileAction from "./action.ts"; 6 | 7 | export default class DialogueTile extends TileAction { 8 | @value({ type: EntityRef }) 9 | prefab: Entity | undefined; 10 | 11 | @value() 12 | text: string = ""; 13 | 14 | @value() 15 | isShowing = false; 16 | 17 | #ui: UIPanel | undefined; 18 | 19 | onInitialize(): void { 20 | super.onInitialize(); 21 | 22 | if (!this.prefab) throw new Error("missing text prefab"); 23 | 24 | if (this.game.isClient()) { 25 | this.#ui = this.prefab 26 | .cloneInto(this.game.local, { 27 | name: `text_${this.ref}`, 28 | transform: { position: this.tilePos }, 29 | }) 30 | .cast(UIPanel); 31 | 32 | const display = this.#ui.getBehavior(DialogueTileText); 33 | display.text = this.text; 34 | } 35 | 36 | this.values.get("text")?.onChanged(() => { 37 | if (!this.#ui) return; 38 | const display = this.#ui.getBehavior(DialogueTileText); 39 | display.text = this.text; 40 | }); 41 | 42 | this.values.get("isShowing")?.onChanged(() => { 43 | if (!this.#ui) return; 44 | this.#ui.getBehavior(DialogueTileText).visible = this.isShowing; 45 | }); 46 | 47 | this.listen(this.entity, EntityDestroyed, () => { 48 | this.isShowing = false; 49 | }); 50 | } 51 | 52 | public onTileEnter(ev: PlayerMoved): void { 53 | this.isShowing = true; 54 | 55 | ev.actions.push({ id: "dialogue", data: { text: this.text, visible: true } }); 56 | 57 | if (this.text.toLowerCase().includes("won") || this.entity.name === "Win") { 58 | const metrics = ev.player.entity.getBehavior(PlayerMetrics); 59 | if (metrics) { 60 | metrics.recordFinish(); 61 | } 62 | } 63 | } 64 | 65 | public onTileExit(ev: PlayerMoved): void { 66 | this.isShowing = false; 67 | 68 | ev.actions.push({ id: "dialogue", data: { text: this.text, visible: false } }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /environments/openmonsters/src/enemies/movement.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Behavior, 3 | Entity, 4 | EntityRef, 5 | IVector2, 6 | Tilemap, 7 | value, 8 | Vector2, 9 | } from "@dreamlab/engine"; 10 | import { Colors } from "../../lib/colors.ts"; 11 | import { PlayerMoved } from "../player/movement.ts"; 12 | 13 | export const enemyRegistry: EnemyMovement[] = []; 14 | 15 | export default class EnemyMovement extends Behavior { 16 | @value({ type: EntityRef }) 17 | tilemap: Entity | undefined; 18 | 19 | #pos: Vector2 = this.entity.pos.floor(); 20 | 21 | get pos() { 22 | return this.#pos.clone(); 23 | } 24 | 25 | private path = [ 26 | { x: 1, y: 0 }, // right 27 | { x: 1, y: 0 }, 28 | { x: 1, y: 0 }, 29 | 30 | { x: 0, y: 1 }, // down 31 | { x: 0, y: 1 }, 32 | { x: 0, y: 1 }, 33 | 34 | { x: -1, y: 0 }, // left 35 | { x: -1, y: 0 }, 36 | { x: -1, y: 0 }, 37 | 38 | { x: 0, y: -1 }, // up 39 | { x: 0, y: -1 }, 40 | { x: 0, y: -1 }, 41 | ]; 42 | 43 | private index = 0; 44 | 45 | onInitialize(): void { 46 | this.game.on(PlayerMoved, ev => { 47 | const step = this.path[this.index]; 48 | const newPos = { x: this.#pos.x + step.x, y: this.#pos.y + step.y }; 49 | 50 | // Check if enemy is on the same tile as the player 51 | if (Vector2.eq(this.#pos, ev.position)) { 52 | ev.player.isDead = true; 53 | } 54 | 55 | // Only move if the new position is valid 56 | if (this.#isValidMove(newPos)) { 57 | this.entity.pos.x += step.x; 58 | this.entity.pos.y += step.y; 59 | this.#pos.assign(newPos); 60 | } 61 | 62 | this.index = (this.index + 1) % this.path.length; // loop forever 63 | 64 | if (Vector2.eq(this.#pos, ev.position)) { 65 | ev.player.isDead = true; 66 | } 67 | }); 68 | } 69 | 70 | onDestroy(): void { 71 | } 72 | 73 | onTick(): void { 74 | } 75 | 76 | #isValidMove(pos: IVector2): boolean { 77 | const tilemap = this.tilemap?.cast(Tilemap); 78 | if (!tilemap) return false; 79 | 80 | const color = tilemap.getColor(pos.x, pos.y); 81 | 82 | // Block walls and doors 83 | return color !== Colors.Wall 84 | && color !== Colors.GreenDoor 85 | && color !== Colors.BlueDoor; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WorldQL 2 | 3 | ## Latest News - Winter Hackathon! 4 | Frame 6 5 | 6 | Join our brand new Discord to register (or if you just want to join the community): [https://discord.gg/nPWVJzZFnP](https://discord.gg/nPWVJzZFnP) 7 | 8 | ## Motivation / Why build this? 9 | Demand for environments to evaluate and train AI continues to grow. However, as environments grow in complexity, the headless scripts that power them can become difficult to debug and observe. Developers have to waste time to solve painful state management problems that have been solved before. 10 | 11 | **Any sufficiently complex state management problem eventually becomes a game engine.** Whether you're building a game-like eval, a UI clone for RL training, or something totally unique, the WorldQL game engine is the fastest way to build robust environments that both AIs and humans can interact with! 12 | 13 | ## About 14 | 15 | WorldQL is a game engine for building environments for RL or evaluation. It includes a graphical browser-based editor and a scaffold for connecting your environments with any model. 16 | 17 | image 18 | 19 | 20 | ## Get Started 21 | Enter this command in your terminal to get started. This will set up the engine and our CLI tool and clone this repo: 22 | ``` 23 | curl -fsSL https://worldql.com/install.sh | sh 24 | ``` 25 | Supports macOS, Linux, and Windows via WSL. 26 | 27 | Then, to run PortalBench 28 | ``` 29 | cd worldql/environments/portalbench 30 | wql up 31 | ``` 32 | and the game server will start up 33 | 34 | To run an AI to test it with, open another terminal and navigate to the harness folder and run it: 35 | ``` 36 | cd worldql/harness 37 | deno task start 38 | ``` 39 | and an interactive interface will start allowing you to select models and run. To configure API keys, create a `.env.local` file in the `harness` folder and enter: 40 | ``` 41 | OPENROUTER_API_KEY=your-key-here-if-using-openrouter 42 | 43 | # if using openai compatible models (including local ones) 44 | OPENAI_BASE_URL=http://localhost/v1 45 | OPENAI_API_KEY=something 46 | ``` 47 | 48 | We recommend using OpenRouter to test a wide variety of models. 49 | 50 | 51 | -------------------------------------------------------------------------------- /harness/src/tools/registry.ts: -------------------------------------------------------------------------------- 1 | import type { Tool, ToolHandler } from "../llm/types.ts"; 2 | 3 | // Import active tools 4 | import { 5 | toolDefinition as playerSpawnDef, 6 | toolHandler as playerSpawnHandler, 7 | } from "./player-spawn.ts"; 8 | import { 9 | toolDefinition as playerMoveDef, 10 | toolHandler as playerMoveHandler, 11 | } from "./player-move.ts"; 12 | import { 13 | toolDefinition as playerSeeDef, 14 | toolHandler as playerSeeHandler, 15 | } from "./player-see.ts"; 16 | 17 | // Import inactive tools (not registered by default) 18 | import { 19 | toolDefinition as placeBombDef, 20 | toolHandler as placeBombHandler, 21 | } from "./place-bomb.ts"; 22 | import { 23 | toolDefinition as deletePlayerDef, 24 | toolHandler as deletePlayerHandler, 25 | } from "./delete-player.ts"; 26 | import { 27 | toolDefinition as levelSelectDef, 28 | toolHandler as levelSelectHandler, 29 | } from "./level-select.ts"; 30 | import { 31 | toolDefinition as restartLevelDef, 32 | toolHandler as restartLevelHandler, 33 | } from "./restart-level.ts"; 34 | import { 35 | toolDefinition as placePortalDef, 36 | toolHandler as placePortalHandler, 37 | } from "./place-portal.ts"; 38 | 39 | /** 40 | * Active tools that are registered with the LLM. 41 | * These are the tools the LLM can use by default. 42 | */ 43 | export const ACTIVE_TOOLS: Tool[] = [ 44 | playerMoveDef, 45 | playerSpawnDef, 46 | playerSeeDef, 47 | ]; 48 | 49 | /** 50 | * Tool registry mapping tool names to their handler functions. 51 | * Only includes active tools. 52 | */ 53 | export const TOOL_REGISTRY: Record = { 54 | SpawnPlayer: playerSpawnHandler, 55 | MovePlayer: playerMoveHandler, 56 | ObserveWorld: playerSeeHandler, 57 | }; 58 | 59 | /** 60 | * All available tools (active and inactive). 61 | * Inactive tools can be enabled by adding them to ACTIVE_TOOLS and TOOL_REGISTRY. 62 | */ 63 | export const ALL_TOOLS: Tool[] = [ 64 | ...ACTIVE_TOOLS, 65 | placePortalDef, 66 | placeBombDef, 67 | deletePlayerDef, 68 | levelSelectDef, 69 | restartLevelDef, 70 | ]; 71 | 72 | /** 73 | * All tool handlers (active and inactive). 74 | * To activate an inactive tool, add its entry to TOOL_REGISTRY. 75 | */ 76 | export const ALL_TOOL_HANDLERS: Record = { 77 | ...TOOL_REGISTRY, 78 | PlacePortal: placePortalHandler, 79 | PlaceBomb: placeBombHandler, 80 | DeletePlayer: deletePlayerHandler, 81 | LevelSelect: levelSelectHandler, 82 | RestartLevel: restartLevelHandler, 83 | }; 84 | -------------------------------------------------------------------------------- /environments/openmonsters/src/mechanics/portal-placement.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, EntityRef, IVector2, rpc, Tilemap, value } from "@dreamlab/engine"; 2 | import PlayerMovement from "../player/movement.ts"; 3 | import PortalManager from "./portal-manager.ts"; 4 | 5 | export default class PortalPlacement extends Behavior { 6 | @value({ type: EntityRef }) 7 | tilemap: Tilemap | undefined; 8 | 9 | @value() 10 | currentPortalColor: "blue" | "orange" = "blue"; 11 | 12 | @value() 13 | lastPlacementError: string = ""; 14 | 15 | #leftClick = this.inputs.create("@portal/placeBlue", "Place Blue Portal", "MouseLeft"); 16 | #rightClick = this.inputs.create("@portal/placeOrange", "Place Orange Portal", "MouseRight"); 17 | #togglePortal = this.inputs.create("@portal/toggle", "Toggle Portal Color", "KeyQ"); 18 | 19 | onTick(): void { 20 | if (!this.game.isClient()) return; 21 | if (!this.hasAuthority()) return; 22 | 23 | if (this.#togglePortal.pressed) { 24 | this.currentPortalColor = this.currentPortalColor === "blue" ? "orange" : "blue"; 25 | } 26 | 27 | if (this.#leftClick.pressed) { 28 | this.currentPortalColor = "blue"; 29 | this.#placePortalAtCursor("blue"); 30 | } 31 | 32 | if (this.#rightClick.pressed) { 33 | this.currentPortalColor = "orange"; 34 | this.#placePortalAtCursor("orange"); 35 | } 36 | } 37 | 38 | #placePortalAtCursor(portalColor: "blue" | "orange"): void { 39 | const cursorWorldPos = this.inputs.cursor.world; 40 | if (!cursorWorldPos) return; 41 | 42 | const tileX = Math.round(cursorWorldPos.x); 43 | const tileY = Math.round(cursorWorldPos.y); 44 | 45 | this.#requestPortalPlacement(portalColor, { x: tileX, y: tileY }); 46 | } 47 | 48 | @rpc.server() 49 | #requestPortalPlacement(portalColor: "blue" | "orange", targetTile: IVector2) { 50 | const portalManager = PortalManager.instance; 51 | if (!portalManager) { 52 | this.lastPlacementError = "Portal system not available"; 53 | return; 54 | } 55 | 56 | const playerMovement = this.entity.getBehavior(PlayerMovement); 57 | const playerPos = playerMovement.pos; 58 | 59 | const result = portalManager.placePortal(portalColor, targetTile, { 60 | x: playerPos.x, 61 | y: playerPos.y, 62 | }); 63 | 64 | if (result.success) { 65 | this.lastPlacementError = ""; 66 | } else { 67 | this.lastPlacementError = result.error || "Unknown error"; 68 | } 69 | } 70 | 71 | setPortalColor(color: "blue" | "orange"): void { 72 | this.currentPortalColor = color; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /environments/openmonsters/src/camera/pan-zoom.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Camera, value, Vector2 } from "@dreamlab/engine"; 2 | 3 | // TODO: this is temporary will improve soon 4 | 5 | export default class CameraPanZoom extends Behavior { 6 | @value() 7 | active = false; 8 | 9 | #isDragging = false; 10 | #lastMousePos: Vector2 | null = null; 11 | #zoomLevel = 1; 12 | 13 | private readonly MIN_ZOOM = 0.1; 14 | private readonly MAX_ZOOM = 3; 15 | private readonly ZOOM_SPEED = 0.05; 16 | private readonly PAN_SPEED = 0.02; 17 | 18 | onInitialize(): void { 19 | if (!this.game.isClient()) return; 20 | 21 | window.addEventListener("mousedown", this.#onMouseDown); 22 | window.addEventListener("mouseup", this.#onMouseUp); 23 | window.addEventListener("mousemove", this.#onMouseMove); 24 | window.addEventListener("wheel", this.#onWheel, { passive: false }); 25 | window.addEventListener("contextmenu", this.#onContextMenu); 26 | } 27 | 28 | onDestroy(): void { 29 | if (!this.game.isClient()) return; 30 | 31 | window.removeEventListener("mousedown", this.#onMouseDown); 32 | window.removeEventListener("mouseup", this.#onMouseUp); 33 | window.removeEventListener("mousemove", this.#onMouseMove); 34 | window.removeEventListener("wheel", this.#onWheel); 35 | window.removeEventListener("contextmenu", this.#onContextMenu); 36 | } 37 | 38 | #onContextMenu = (event: MouseEvent) => { 39 | if (this.active) { 40 | event.preventDefault(); 41 | } 42 | }; 43 | 44 | #onMouseDown = (event: MouseEvent) => { 45 | if (!this.active) return; 46 | 47 | if (event.button === 1) { 48 | event.preventDefault(); 49 | this.#isDragging = true; 50 | this.#lastMousePos = new Vector2(event.clientX, event.clientY); 51 | } 52 | }; 53 | 54 | #onMouseUp = (event: MouseEvent) => { 55 | if (event.button === 1) { 56 | this.#isDragging = false; 57 | this.#lastMousePos = null; 58 | } 59 | }; 60 | 61 | #onMouseMove = (event: MouseEvent) => { 62 | if (!this.active || !this.#isDragging || !this.#lastMousePos) return; 63 | 64 | const camera = Camera.getActive(this.game); 65 | if (!camera) return; 66 | 67 | const currentMousePos = new Vector2(event.clientX, event.clientY); 68 | const delta = Vector2.sub(this.#lastMousePos, currentMousePos); 69 | 70 | camera.pos.x += (delta.x * this.PAN_SPEED) / this.#zoomLevel; 71 | camera.pos.y -= (delta.y * this.PAN_SPEED) / this.#zoomLevel; 72 | 73 | this.#lastMousePos = currentMousePos; 74 | }; 75 | 76 | #onWheel = (event: WheelEvent) => { 77 | if (!this.active) return; 78 | 79 | event.preventDefault(); 80 | 81 | const camera = Camera.getActive(this.game); 82 | if (!camera) return; 83 | 84 | const zoomDelta = event.deltaY > 0 ? -this.ZOOM_SPEED : this.ZOOM_SPEED; 85 | this.#zoomLevel = Math.max( 86 | this.MIN_ZOOM, 87 | Math.min(this.MAX_ZOOM, this.#zoomLevel + zoomDelta), 88 | ); 89 | 90 | camera.zoom = this.#zoomLevel; 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /environments/portalbench/src/camera/pan-zoom.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Camera, value, Vector2 } from "@dreamlab/engine"; 2 | 3 | // TODO: this is temporary will improve soon 4 | 5 | export default class CameraPanZoom extends Behavior { 6 | @value() 7 | active = false; 8 | 9 | #isDragging = false; 10 | #lastMousePos: Vector2 | null = null; 11 | #zoomLevel = 1; 12 | 13 | private readonly MIN_ZOOM = 0.1; 14 | private readonly MAX_ZOOM = 3; 15 | private readonly ZOOM_SPEED = 0.05; 16 | private readonly PAN_SPEED = 0.02; 17 | 18 | onInitialize(): void { 19 | if (!this.game.isClient()) return; 20 | 21 | window.addEventListener("mousedown", this.#onMouseDown); 22 | window.addEventListener("mouseup", this.#onMouseUp); 23 | window.addEventListener("mousemove", this.#onMouseMove); 24 | window.addEventListener("wheel", this.#onWheel, { passive: false }); 25 | window.addEventListener("contextmenu", this.#onContextMenu); 26 | } 27 | 28 | onDestroy(): void { 29 | if (!this.game.isClient()) return; 30 | 31 | window.removeEventListener("mousedown", this.#onMouseDown); 32 | window.removeEventListener("mouseup", this.#onMouseUp); 33 | window.removeEventListener("mousemove", this.#onMouseMove); 34 | window.removeEventListener("wheel", this.#onWheel); 35 | window.removeEventListener("contextmenu", this.#onContextMenu); 36 | } 37 | 38 | #onContextMenu = (event: MouseEvent) => { 39 | if (this.active) { 40 | event.preventDefault(); 41 | } 42 | }; 43 | 44 | #onMouseDown = (event: MouseEvent) => { 45 | if (!this.active) return; 46 | 47 | if (event.button === 1) { 48 | event.preventDefault(); 49 | this.#isDragging = true; 50 | this.#lastMousePos = new Vector2(event.clientX, event.clientY); 51 | } 52 | }; 53 | 54 | #onMouseUp = (event: MouseEvent) => { 55 | if (event.button === 1) { 56 | this.#isDragging = false; 57 | this.#lastMousePos = null; 58 | } 59 | }; 60 | 61 | #onMouseMove = (event: MouseEvent) => { 62 | if (!this.active || !this.#isDragging || !this.#lastMousePos) return; 63 | 64 | const camera = Camera.getActive(this.game); 65 | if (!camera) return; 66 | 67 | const currentMousePos = new Vector2(event.clientX, event.clientY); 68 | const delta = Vector2.sub(this.#lastMousePos, currentMousePos); 69 | 70 | camera.pos.x += (delta.x * this.PAN_SPEED) / this.#zoomLevel; 71 | camera.pos.y -= (delta.y * this.PAN_SPEED) / this.#zoomLevel; 72 | 73 | this.#lastMousePos = currentMousePos; 74 | }; 75 | 76 | #onWheel = (event: WheelEvent) => { 77 | if (!this.active) return; 78 | 79 | event.preventDefault(); 80 | 81 | const camera = Camera.getActive(this.game); 82 | if (!camera) return; 83 | 84 | const zoomDelta = event.deltaY > 0 ? -this.ZOOM_SPEED : this.ZOOM_SPEED; 85 | this.#zoomLevel = Math.max( 86 | this.MIN_ZOOM, 87 | Math.min(this.MAX_ZOOM, this.#zoomLevel + zoomDelta), 88 | ); 89 | 90 | camera.zoom = this.#zoomLevel; 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /environments/portalbench/src/mechanics/portal-placement.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Behavior, 3 | EntityRef, 4 | IVector2, 5 | rpc, 6 | Tilemap, 7 | value, 8 | } from "@dreamlab/engine"; 9 | import PlayerMovement from "../player/movement.ts"; 10 | import PortalManager from "./portal-manager.ts"; 11 | import { Colors } from "../../lib/colors.ts"; 12 | import ParticleRender from "../effects/particle.ts"; 13 | 14 | export default class PortalPlacement extends Behavior { 15 | @value({ type: EntityRef }) 16 | tilemap: Tilemap | undefined; 17 | 18 | @value() 19 | currentPortalColor: "blue" | "orange" = "blue"; 20 | 21 | @value() 22 | lastPlacementError: string = ""; 23 | 24 | #leftClick = this.inputs.create( 25 | "@portal/placeBlue", 26 | "Place Blue Portal", 27 | "MouseLeft" 28 | ); 29 | #rightClick = this.inputs.create( 30 | "@portal/placeOrange", 31 | "Place Orange Portal", 32 | "MouseRight" 33 | ); 34 | #togglePortal = this.inputs.create( 35 | "@portal/toggle", 36 | "Toggle Portal Color", 37 | "KeyQ" 38 | ); 39 | 40 | onTick(): void { 41 | if (!this.game.isClient()) return; 42 | if (!this.hasAuthority()) return; 43 | 44 | if (this.#togglePortal.pressed) { 45 | this.currentPortalColor = 46 | this.currentPortalColor === "blue" ? "orange" : "blue"; 47 | } 48 | 49 | if (this.#leftClick.pressed) { 50 | this.currentPortalColor = "blue"; 51 | this.#placePortalAtCursor("blue"); 52 | } 53 | 54 | if (this.#rightClick.pressed) { 55 | this.currentPortalColor = "orange"; 56 | this.#placePortalAtCursor("orange"); 57 | } 58 | } 59 | 60 | #placePortalAtCursor(portalColor: "blue" | "orange"): void { 61 | const cursorWorldPos = this.inputs.cursor.world; 62 | if (!cursorWorldPos) return; 63 | 64 | const tileX = Math.round(cursorWorldPos.x); 65 | const tileY = Math.round(cursorWorldPos.y); 66 | 67 | this.#requestPortalPlacement(portalColor, { x: tileX, y: tileY }); 68 | } 69 | 70 | @rpc.server() 71 | #requestPortalPlacement( 72 | portalColor: "blue" | "orange", 73 | targetTile: IVector2 74 | ) { 75 | const portalManager = PortalManager.instance; 76 | if (!portalManager) { 77 | this.lastPlacementError = "Portal system not available"; 78 | return; 79 | } 80 | 81 | const playerMovement = this.entity.getBehavior(PlayerMovement); 82 | const playerPos = playerMovement.pos; 83 | 84 | const result = portalManager.placePortal( 85 | portalColor, 86 | targetTile, 87 | { 88 | x: playerPos.x, 89 | y: playerPos.y, 90 | }, 91 | this.entity.ref 92 | ); 93 | 94 | if (result.success) { 95 | this.lastPlacementError = ""; 96 | } else { 97 | this.lastPlacementError = result.error || "Unknown error"; 98 | } 99 | } 100 | 101 | setPortalColor(color: "blue" | "orange"): void { 102 | this.currentPortalColor = color; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /environments/openmonsters/src/player/metrics.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, rpc, value } from "@dreamlab/engine"; 2 | 3 | export default class PlayerMetrics extends Behavior { 4 | @value() 5 | totalMoves = 0; 6 | 7 | @value() 8 | finishReached = false; 9 | 10 | @value() 11 | startTime = 0; 12 | 13 | @value() 14 | endTime = 0; 15 | 16 | @value() 17 | previousScoresJSON = "[]"; 18 | 19 | onInitialize(): void { 20 | if (this.game.isClient()) return; 21 | } 22 | 23 | getPreviousScores(): Array<{ score: number; moves: number; time: number }> { 24 | try { 25 | return JSON.parse(this.previousScoresJSON); 26 | } catch { 27 | return []; 28 | } 29 | } 30 | 31 | recordMove(): void { 32 | if (this.game.isClient()) return; 33 | 34 | if (this.finishReached) { 35 | const previous = this.getPreviousScores(); 36 | previous.push({ 37 | score: this.calculateScore(), 38 | moves: this.totalMoves, 39 | time: this.getElapsedTime(), 40 | }); 41 | this.previousScoresJSON = JSON.stringify(previous); 42 | this.totalMoves = 0; 43 | this.finishReached = false; 44 | this.startTime = 0; 45 | this.endTime = 0; 46 | return; 47 | } 48 | 49 | if (this.totalMoves === 0) { 50 | this.startTime = Date.now(); 51 | } 52 | 53 | this.totalMoves++; 54 | } 55 | 56 | recordFinish(): void { 57 | if (this.game.isClient()) return; 58 | if (!this.finishReached) { 59 | this.finishReached = true; 60 | this.endTime = Date.now(); 61 | } 62 | } 63 | 64 | /** 65 | * Scoring methodology: 66 | * - Base score: 1000 points 67 | * - Finishing: required for any score 68 | * - Move penalty: -2 points per move 69 | */ 70 | calculateScore(): number { 71 | if (!this.finishReached) { 72 | return 0; 73 | } 74 | 75 | let score = 1000; 76 | score -= this.totalMoves * 2; 77 | 78 | return Math.max(0, Math.round(score)); 79 | } 80 | 81 | getElapsedTime(): number { 82 | if (this.startTime === 0) { 83 | return 0; 84 | } 85 | 86 | if (this.finishReached && this.endTime > 0) { 87 | return (this.endTime - this.startTime) / 1000; 88 | } 89 | 90 | return (Date.now() - this.startTime) / 1000; 91 | } 92 | 93 | getSummary(): { 94 | totalMoves: number; 95 | finishReached: boolean; 96 | score: number; 97 | elapsedTime: number; 98 | } { 99 | return { 100 | totalMoves: this.totalMoves, 101 | finishReached: this.finishReached, 102 | score: this.calculateScore(), 103 | elapsedTime: this.getElapsedTime(), 104 | }; 105 | } 106 | 107 | @rpc.server() 108 | reset(): void { 109 | this.totalMoves = 0; 110 | this.finishReached = false; 111 | this.startTime = 0; 112 | this.endTime = 0; 113 | } 114 | 115 | @rpc.server() 116 | clearPreviousScore(index: number): void { 117 | const previous = this.getPreviousScores(); 118 | if (index >= 0 && index < previous.length) { 119 | previous.splice(index, 1); 120 | this.previousScoresJSON = JSON.stringify(previous); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /environments/portalbench/src/player/metrics.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, rpc, value } from "@dreamlab/engine"; 2 | 3 | export default class PlayerMetrics extends Behavior { 4 | @value() 5 | totalMoves = 0; 6 | 7 | @value() 8 | finishReached = false; 9 | 10 | @value() 11 | startTime = 0; 12 | 13 | @value() 14 | endTime = 0; 15 | 16 | @value() 17 | previousScoresJSON = "[]"; 18 | 19 | onInitialize(): void { 20 | if (this.game.isClient()) return; 21 | } 22 | 23 | getPreviousScores(): Array<{ score: number; moves: number; time: number }> { 24 | try { 25 | return JSON.parse(this.previousScoresJSON); 26 | } catch { 27 | return []; 28 | } 29 | } 30 | 31 | recordMove(): void { 32 | if (this.game.isClient()) return; 33 | 34 | if (this.finishReached) { 35 | const previous = this.getPreviousScores(); 36 | previous.push({ 37 | score: this.calculateScore(), 38 | moves: this.totalMoves, 39 | time: this.getElapsedTime(), 40 | }); 41 | this.previousScoresJSON = JSON.stringify(previous); 42 | this.totalMoves = 0; 43 | this.finishReached = false; 44 | this.startTime = 0; 45 | this.endTime = 0; 46 | return; 47 | } 48 | 49 | if (this.totalMoves === 0) { 50 | this.startTime = Date.now(); 51 | } 52 | 53 | this.totalMoves++; 54 | } 55 | 56 | recordFinish(): void { 57 | if (this.game.isClient()) return; 58 | if (!this.finishReached) { 59 | this.finishReached = true; 60 | this.endTime = Date.now(); 61 | } 62 | } 63 | 64 | /** 65 | * Scoring methodology: 66 | * - Base score: 1000 points 67 | * - Finishing: required for any score 68 | * - Move penalty: -2 points per move 69 | */ 70 | calculateScore(): number { 71 | if (!this.finishReached) { 72 | return 0; 73 | } 74 | 75 | let score = 1000; 76 | score -= this.totalMoves * 2; 77 | 78 | return Math.max(0, Math.round(score)); 79 | } 80 | 81 | getElapsedTime(): number { 82 | if (this.startTime === 0) { 83 | return 0; 84 | } 85 | 86 | if (this.finishReached && this.endTime > 0) { 87 | return (this.endTime - this.startTime) / 1000; 88 | } 89 | 90 | return (Date.now() - this.startTime) / 1000; 91 | } 92 | 93 | getSummary(): { 94 | totalMoves: number; 95 | finishReached: boolean; 96 | score: number; 97 | elapsedTime: number; 98 | } { 99 | return { 100 | totalMoves: this.totalMoves, 101 | finishReached: this.finishReached, 102 | score: this.calculateScore(), 103 | elapsedTime: this.getElapsedTime(), 104 | }; 105 | } 106 | 107 | @rpc.server() 108 | reset(): void { 109 | this.totalMoves = 0; 110 | this.finishReached = false; 111 | this.startTime = 0; 112 | this.endTime = 0; 113 | } 114 | 115 | @rpc.server() 116 | clearPreviousScore(index: number): void { 117 | const previous = this.getPreviousScores(); 118 | if (index >= 0 && index < previous.length) { 119 | previous.splice(index, 1); 120 | this.previousScoresJSON = JSON.stringify(previous); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /environments/portalbench/scene-description.md: -------------------------------------------------------------------------------- 1 | The following is a description of the current scene in a compact format. (posX, posY, scaleX, scaleY). All child positions and scale are relative to parent. 2 | 3 | world: 4 | - TileActionManager (Empty) (-1.7, 0.04, 1, 1) 5 | - src/tiles/action-manager.ts 6 | - Tilemap (Tilemap) (0, 0, 1, 1) 7 | - RoomOffset (Empty) (-29.35, -62.59, 1, 1) 8 | - PortalManager (Empty) (-36.51, -69.13, 1, 1) 9 | - src/mechanics/portal-manager.ts 10 | - Portals (Empty) (0, 0, 1, 1) 11 | - Level1 (Empty) (-29.58, -62.64, 1, 1) 12 | - src/mechanics/pushable-block-manager.ts 13 | - LevelStart (Empty) (4.68, -7.56, 1, 1) 14 | - Win (Empty) (12.47, -3.66, 1, 1) 15 | - src/tiles/dialogue.ts 16 | - src/tiles/teleport.ts 17 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 18 | - Level2 (Empty) (-8.69, -62.14, 1, 1) 19 | - src/mechanics/pushable-block-manager.ts 20 | - Win (Empty) (10.11, -4.45, 1, 1) 21 | - src/tiles/dialogue.ts 22 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 23 | - LevelStart (Empty) (3.15, -4.28, 1, 1) 24 | - MetricsUI (UILayer) (-48.82, -67.28, 1, 1) 25 | - src/ui/player-metrics.tsx 26 | - GameMenu (UILayer) (-12.29, -74.01, 1, 1) 27 | - src/ui/game-selection.tsx 28 | 29 | local: 30 | - Camera (Camera) (-22.42, -69.97, 1, 1) 31 | - src/camera/pan-zoom.ts 32 | 33 | server: 34 | - PlayerSpawner (Empty) (-41.88, -91.05, 1, 1) 35 | - src/player/spawner.ts 36 | 37 | prefabs: 38 | - DialogueText (UIPanel) (0, 0, 1, 1) 39 | - src/ui/dialogue-text.tsx 40 | - Player (Empty) (-23.41, -70.23, 1, 1) 41 | - src/player/movement.ts 42 | - src/camera/follow.ts 43 | - src/player/inventory.ts 44 | - src/player/metrics.ts 45 | - src/mechanics/portal-placement.ts 46 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 47 | - LightOverlay (RawPixi) (0, 0, 1, 1) 48 | - src/effects/light-overlay.ts 49 | - Name (RichText) (0, 0, 1, 1) 50 | - GoldPile (Empty) (3.82, -2, 1, 1) 51 | - src/collectibles/gold.ts 52 | - Gold.1 (ColoredSquare) (-0.16, 0.19, 0.3, 0.32) 53 | - Gold.2 (ColoredSquare) (0.24, -0.26, 0.31, 0.32) 54 | - Gold.3 (ColoredSquare) (-0.21, -0.26, 0.31, 0.32) 55 | - BlueKey (Empty) (4.5, -5.65, 1, 1) 56 | - src/collectibles/key.ts 57 | - src/tiles/dialogue.ts 58 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 59 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 60 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 61 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 62 | - GreenKey (Empty) (-2.39, -2.76, 1, 1) 63 | - src/collectibles/key.ts 64 | - src/tiles/dialogue.ts 65 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 66 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 67 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 68 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 69 | - BombPickup (Empty) (2, 1, 1, 1) 70 | - src/collectibles/bomb.ts 71 | - src/tiles/dialogue.ts 72 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 73 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 74 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 75 | - BombPlaced (Empty) (1, 1, 1, 1) 76 | - src/mechanics/placed-bomb.ts 77 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 78 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 79 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 80 | - ParticleContainer (RawPixi) (2.63, -73.3, 1, 1) 81 | - src/effects/particle.ts 82 | -------------------------------------------------------------------------------- /harness/prompts/sokoban.txt: -------------------------------------------------------------------------------- 1 | Solve the Sokoban puzzle by pushing all blocks onto their goal positions! 2 | 3 | Make sure to level-select the sokoban level 1 to start. After succesfully completing the level go to the next level. 4 | 5 | When you observe, you will get a grid representing the level: 6 | @ represents your player 7 | O represents a goal position (empty - needs a block) 8 | P represents a pushable block (NOT on a goal yet) 9 | p represents a pushable block ON a goal (✓ solved!) 10 | W represents walls 11 | F represents floor 12 | E represents enemies (they can move) 13 | b represents collectible bombs (pickup items) 14 | * represents bombs that were placed 15 | X represents bombable walls that can be destroyed 16 | 17 | SOKOBAN RULES: 18 | - Push blocks by moving into them (you cannot pull blocks) 19 | - CRITICAL: When you move INTO a block, the block moves in the SAME direction you're moving 20 | Example: If you're at (0,0) and move RIGHT (+x) to (1,0) where a block is, the block moves RIGHT to (2,0) 21 | Example: If you're at (3,3) and move UP (-y) to (3,2) where a block is, the block moves UP to (3,1) 22 | - You can only push ONE block at a time (cannot push multiple blocks together) 23 | - Blocks can only be pushed if the space behind them is empty (F, O, or other walkable tiles) 24 | - You CANNOT push a block into: walls (W), bomb walls (X), other blocks (P or p), or enemies (E) 25 | - Goal: Get ALL blocks onto goal positions (all P become p) 26 | - Be careful! Blocks can get stuck against walls or in corners 27 | - USE RestartLevel IMMEDIATELY if you: push a block into a deadlock, get stuck with no valid moves, or realize you made an irreversible mistake 28 | 29 | IMPORTANT STRATEGY TIPS: 30 | 1. ANALYZE EVERY MOVE: Before moving, check if there's a block in that direction and WHERE IT WILL END UP 31 | 2. Plan your moves carefully - blocks can become permanently stuck 32 | 3. Never push a block into a corner unless that corner is a goal 33 | 4. Avoid pushing blocks against walls parallel to other walls 34 | 5. Work backwards from the goals - think about the last move first 35 | 6. You can walk on empty goals (O) - they're just floor tiles marking where blocks should go 36 | 7. Blocks on goals (p) can be pushed off and moved to other goals if needed 37 | 8. RESTART IMMEDIATELY when stuck - don't waste moves on an unsolvable state 38 | 39 | DEADLOCK EXAMPLES (positions where blocks get permanently stuck): 40 | - Block in corner: W W / W P (unless corner has a goal) 41 | - Block against two adjacent walls with no goal there 42 | 43 | BEFORE EACH MOVE, THINK: 44 | 1. "Is there a block where I'm about to move?" 45 | 2. "If yes, where will that block end up? (it moves in the direction I'm moving)" 46 | 3. "Will that destination create a deadlock? (corner, against wall, etc.)" 47 | 4. "Is this move safe or should I try a different approach?" 48 | 49 | IF YOU REALIZE YOU'RE STUCK OR MADE A MISTAKE: 50 | - DON'T try to continue - you'll waste time on an unsolvable state 51 | - USE RestartLevel IMMEDIATELY to reset and try a new approach 52 | - Deadlock indicators: blocks in corners without goals, blocks against parallel walls, no more valid moves 53 | 54 | Think step-by-step about: 55 | - Which blocks need to go to which goals 56 | - The order to push blocks (some may block paths to others) 57 | - Avoiding deadlock positions where blocks cannot be moved 58 | - The path you need to take to push each block 59 | 60 | The world progresses one time tick every time you move. Enemies move on the same tick system. 61 | 62 | Other characters represent mystery things you will have to discover. -------------------------------------------------------------------------------- /environments/portalbench/src/mechanics/pushable-block-manager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Behavior, 3 | Entity, 4 | EntityRef, 5 | type IVector2, 6 | Tilemap, 7 | value, 8 | Vector2, 9 | } from "@dreamlab/engine"; 10 | import { Colors } from "../../lib/colors.ts"; 11 | import { PlayerMoved } from "../player/movement.ts"; 12 | 13 | export default class PushableBlockManager extends Behavior { 14 | @value({ type: EntityRef }) 15 | tilemap: Entity | undefined; 16 | 17 | #lastPlayerPos: Map = new Map(); 18 | #initialBlockPositions: Vector2[] = []; 19 | #goalPositions: Set = new Set(); 20 | 21 | public static instance: PushableBlockManager | undefined; 22 | 23 | public onInitialize() { 24 | PushableBlockManager.instance = this; 25 | 26 | const tilemap = this.tilemap?.cast(Tilemap); 27 | if (!tilemap) throw new Error("missing tilemap"); 28 | 29 | this.#scanTilemap(tilemap); 30 | 31 | this.listen(this.game, PlayerMoved, ev => { 32 | const playerId = ev.player.entity.id; 33 | const to = ev.position; 34 | const from = this.#lastPlayerPos.get(playerId); 35 | 36 | this.#lastPlayerPos.set(playerId, new Vector2(to.x, to.y)); 37 | 38 | if (!from) return; 39 | 40 | const blockColor = tilemap.getColor(to.x, to.y); 41 | if (blockColor !== Colors.PushableBlock && blockColor !== Colors.BlockOnGoal) return; 42 | 43 | const direction: IVector2 = { 44 | x: to.x - from.x, 45 | y: to.y - from.y, 46 | }; 47 | 48 | const oppositePos: IVector2 = { 49 | x: to.x + direction.x, 50 | y: to.y + direction.y, 51 | }; 52 | 53 | const fromKey = `${to.x},${to.y}`; 54 | const toKey = `${oppositePos.x},${oppositePos.y}`; 55 | 56 | const restoreColor = this.#goalPositions.has(fromKey) ? Colors.BlockGoal : Colors.Grass; 57 | tilemap.setColor(to.x, to.y, restoreColor); 58 | 59 | const newBlockColor = this.#goalPositions.has(toKey) 60 | ? Colors.BlockOnGoal 61 | : Colors.PushableBlock; 62 | tilemap.setColor(oppositePos.x, oppositePos.y, newBlockColor); 63 | }); 64 | } 65 | 66 | #scanTilemap(tilemap: Tilemap) { 67 | const scanRange = 100; 68 | 69 | for (let y = -scanRange; y <= scanRange; y++) { 70 | for (let x = -scanRange; x <= scanRange; x++) { 71 | const color = tilemap.getColor(x, y); 72 | if (!color) continue; // Skip empty/null tiles 73 | 74 | if (color === Colors.PushableBlock || color === Colors.BlockOnGoal) { 75 | this.#initialBlockPositions.push(new Vector2(x, y)); 76 | } 77 | 78 | if (color === Colors.BlockGoal || color === Colors.BlockOnGoal) { 79 | this.#goalPositions.add(`${x},${y}`); 80 | } 81 | } 82 | } 83 | } 84 | 85 | public isGoalPosition(x: number, y: number): boolean { 86 | return this.#goalPositions.has(`${x},${y}`); 87 | } 88 | 89 | public restart() { 90 | const tilemap = this.tilemap?.cast(Tilemap); 91 | if (!tilemap) throw new Error("missing tilemap"); 92 | 93 | const scanRange = 100; 94 | for (let y = -scanRange; y <= scanRange; y++) { 95 | for (let x = -scanRange; x <= scanRange; x++) { 96 | const color = tilemap.getColor(x, y); 97 | if (color === Colors.PushableBlock || color === Colors.BlockOnGoal) { 98 | const key = `${x},${y}`; 99 | const restoreColor = this.#goalPositions.has(key) ? Colors.BlockGoal : Colors.Grass; 100 | tilemap.setColor(x, y, restoreColor); 101 | } 102 | } 103 | } 104 | 105 | for (const pos of this.#initialBlockPositions) { 106 | const key = `${pos.x},${pos.y}`; 107 | const blockColor = this.#goalPositions.has(key) 108 | ? Colors.BlockOnGoal 109 | : Colors.PushableBlock; 110 | tilemap.setColor(pos.x, pos.y, blockColor); 111 | } 112 | 113 | this.#lastPlayerPos.clear(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /environments/openmonsters/src/effects/light-overlay.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Behavior, 3 | BehaviorDestroyed, 4 | Entity, 5 | EntityRef, 6 | RawPixi, 7 | Tilemap, 8 | value, 9 | } from "@dreamlab/engine"; 10 | import * as PIXI from "@dreamlab/vendor/pixi.ts"; 11 | import { Colors } from "../../lib/colors.ts"; 12 | 13 | export default class LightOverlay extends Behavior { 14 | @value({ type: EntityRef }) 15 | tilemap: Entity | undefined; 16 | 17 | @value() 18 | rays: number = 360; 19 | 20 | @value() 21 | maxDistance: number = 8; 22 | 23 | #pixi = this.entity.cast(RawPixi); 24 | #overlay!: PIXI.Graphics; 25 | #mask!: PIXI.Graphics; 26 | 27 | #overlayCtx = new PIXI.GraphicsContext() 28 | .rect(-10000, -10000, 20000, 20000) 29 | .fill({ color: "black", alpha: 1 }); 30 | 31 | onInitialize(): void { 32 | if (!this.game.isClient()) return; 33 | 34 | this.#mask = new PIXI.Graphics(); 35 | this.#overlay = new PIXI.Graphics(this.#overlayCtx); 36 | this.#overlay.setMask({ 37 | mask: this.#mask, 38 | inverse: true, 39 | }); 40 | 41 | if (this.#pixi.container) { 42 | this.#pixi.container.addChild(this.#mask); 43 | this.#pixi.container.addChild(this.#overlay); 44 | } 45 | 46 | this.on(BehaviorDestroyed, () => { 47 | this.#overlay?.destroy(); 48 | this.#overlayCtx?.destroy(); 49 | this.#mask?.destroy(); 50 | }); 51 | } 52 | 53 | onFrame(): void { 54 | if (!this.game.isClient()) return; 55 | if (!this.entity.parent || !this.tilemap) return; 56 | 57 | this.#mask.clear(); 58 | 59 | const playerPos = this.entity.parent.pos; 60 | const tilemapEntity = this.tilemap.cast(Tilemap); 61 | const rays = this.rays; 62 | 63 | const localX = playerPos.x - this.entity.pos.x; 64 | const localY = playerPos.y - this.entity.pos.y; 65 | 66 | this.#mask.moveTo(localX, -localY); 67 | 68 | for (let i = 0; i < rays + 1; i++) { 69 | const angle = (i / rays) * Math.PI * 2; 70 | const dirX = Math.cos(angle); 71 | const dirY = Math.sin(angle); 72 | 73 | const hitPoint = this.#castTilemapRay( 74 | playerPos.x, 75 | playerPos.y, 76 | dirX, 77 | dirY, 78 | this.maxDistance + 1, 79 | tilemapEntity, 80 | ); 81 | 82 | const hitLocalX = hitPoint.x - this.entity.pos.x; 83 | const hitLocalY = hitPoint.y - this.entity.pos.y; 84 | 85 | this.#mask.lineTo(hitLocalX, -hitLocalY); 86 | } 87 | 88 | this.#mask.lineTo(localX, -localY).fill("white"); 89 | } 90 | 91 | #castTilemapRay( 92 | startX: number, 93 | startY: number, 94 | dirX: number, 95 | dirY: number, 96 | maxDist: number, 97 | tilemap: Tilemap, 98 | ): { x: number; y: number } { 99 | let currentTileX = Math.round(startX); 100 | let currentTileY = Math.round(startY); 101 | 102 | const stepX = dirX > 0 ? 1 : -1; 103 | const stepY = dirY > 0 ? 1 : -1; 104 | 105 | const tMaxX = dirX !== 0 106 | ? ((dirX > 0 ? currentTileX + 0.5 : currentTileX - 0.5) - startX) / dirX 107 | : Infinity; 108 | const tMaxY = dirY !== 0 109 | ? ((dirY > 0 ? currentTileY + 0.5 : currentTileY - 0.5) - startY) / dirY 110 | : Infinity; 111 | 112 | const tDeltaX = dirX !== 0 ? Math.abs(1 / dirX) : Infinity; 113 | const tDeltaY = dirY !== 0 ? Math.abs(1 / dirY) : Infinity; 114 | 115 | let t = 0; 116 | let nextTMaxX = tMaxX; 117 | let nextTMaxY = tMaxY; 118 | 119 | while (t < maxDist) { 120 | const color = tilemap.getColor(currentTileX, currentTileY); 121 | 122 | if ( 123 | color === Colors.Wall 124 | || color === Colors.BombWall 125 | || color === Colors.GreenDoor 126 | || color === Colors.BlueDoor 127 | ) { 128 | const extendedT = t + 0.7; 129 | return { 130 | x: startX + dirX * extendedT, 131 | y: startY + dirY * extendedT, 132 | }; 133 | } 134 | 135 | if (nextTMaxX < nextTMaxY) { 136 | currentTileX += stepX; 137 | t = nextTMaxX; 138 | nextTMaxX += tDeltaX; 139 | } else { 140 | currentTileY += stepY; 141 | t = nextTMaxY; 142 | nextTMaxY += tDeltaY; 143 | } 144 | } 145 | 146 | return { 147 | x: startX + dirX * maxDist, 148 | y: startY + dirY * maxDist, 149 | }; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /environments/portalbench/src/effects/light-overlay.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Behavior, 3 | BehaviorDestroyed, 4 | Entity, 5 | EntityRef, 6 | RawPixi, 7 | Tilemap, 8 | value, 9 | } from "@dreamlab/engine"; 10 | import * as PIXI from "@dreamlab/vendor/pixi.ts"; 11 | import { Colors } from "../../lib/colors.ts"; 12 | 13 | export default class LightOverlay extends Behavior { 14 | @value({ type: EntityRef }) 15 | tilemap: Entity | undefined; 16 | 17 | @value() 18 | rays: number = 360; 19 | 20 | @value() 21 | maxDistance: number = 8; 22 | 23 | #pixi = this.entity.cast(RawPixi); 24 | #overlay!: PIXI.Graphics; 25 | #mask!: PIXI.Graphics; 26 | 27 | #overlayCtx = new PIXI.GraphicsContext() 28 | .rect(-10000, -10000, 20000, 20000) 29 | .fill({ color: "black", alpha: 1 }); 30 | 31 | onInitialize(): void { 32 | if (!this.game.isClient()) return; 33 | 34 | this.#mask = new PIXI.Graphics(); 35 | this.#overlay = new PIXI.Graphics(this.#overlayCtx); 36 | this.#overlay.setMask({ 37 | mask: this.#mask, 38 | inverse: true, 39 | }); 40 | 41 | if (this.#pixi.container) { 42 | this.#pixi.container.addChild(this.#mask); 43 | this.#pixi.container.addChild(this.#overlay); 44 | } 45 | 46 | this.on(BehaviorDestroyed, () => { 47 | this.#overlay?.destroy(); 48 | this.#overlayCtx?.destroy(); 49 | this.#mask?.destroy(); 50 | }); 51 | } 52 | 53 | onFrame(): void { 54 | if (!this.game.isClient()) return; 55 | if (!this.entity.parent || !this.tilemap) return; 56 | 57 | this.#mask.clear(); 58 | 59 | const playerPos = this.entity.parent.pos; 60 | const tilemapEntity = this.tilemap.cast(Tilemap); 61 | const rays = this.rays; 62 | 63 | const localX = playerPos.x - this.entity.pos.x; 64 | const localY = playerPos.y - this.entity.pos.y; 65 | 66 | this.#mask.moveTo(localX, -localY); 67 | 68 | for (let i = 0; i < rays + 1; i++) { 69 | const angle = (i / rays) * Math.PI * 2; 70 | const dirX = Math.cos(angle); 71 | const dirY = Math.sin(angle); 72 | 73 | const hitPoint = this.#castTilemapRay( 74 | playerPos.x, 75 | playerPos.y, 76 | dirX, 77 | dirY, 78 | this.maxDistance + 1, 79 | tilemapEntity 80 | ); 81 | 82 | const hitLocalX = hitPoint.x - this.entity.pos.x; 83 | const hitLocalY = hitPoint.y - this.entity.pos.y; 84 | 85 | this.#mask.lineTo(hitLocalX, -hitLocalY); 86 | } 87 | 88 | this.#mask.lineTo(localX, -localY).fill("white"); 89 | } 90 | 91 | #castTilemapRay( 92 | startX: number, 93 | startY: number, 94 | dirX: number, 95 | dirY: number, 96 | maxDist: number, 97 | tilemap: Tilemap 98 | ): { x: number; y: number } { 99 | let currentTileX = Math.round(startX); 100 | let currentTileY = Math.round(startY); 101 | 102 | const stepX = dirX > 0 ? 1 : -1; 103 | const stepY = dirY > 0 ? 1 : -1; 104 | 105 | const tMaxX = 106 | dirX !== 0 107 | ? ((dirX > 0 ? currentTileX + 0.5 : currentTileX - 0.5) - startX) / dirX 108 | : Infinity; 109 | const tMaxY = 110 | dirY !== 0 111 | ? ((dirY > 0 ? currentTileY + 0.5 : currentTileY - 0.5) - startY) / dirY 112 | : Infinity; 113 | 114 | const tDeltaX = dirX !== 0 ? Math.abs(1 / dirX) : Infinity; 115 | const tDeltaY = dirY !== 0 ? Math.abs(1 / dirY) : Infinity; 116 | 117 | let t = 0; 118 | let nextTMaxX = tMaxX; 119 | let nextTMaxY = tMaxY; 120 | 121 | while (t < maxDist) { 122 | const color = tilemap.getColor(currentTileX, currentTileY); 123 | 124 | if ( 125 | color === Colors.Wall || 126 | color === Colors.BombWall || 127 | color === Colors.GreenDoor || 128 | color === Colors.BlueDoor 129 | ) { 130 | const extendedT = t + 0.7; 131 | return { 132 | x: startX + dirX * extendedT, 133 | y: startY + dirY * extendedT, 134 | }; 135 | } 136 | 137 | if (nextTMaxX < nextTMaxY) { 138 | currentTileX += stepX; 139 | t = nextTMaxX; 140 | nextTMaxX += tDeltaX; 141 | } else { 142 | currentTileY += stepY; 143 | t = nextTMaxY; 144 | nextTMaxY += tDeltaY; 145 | } 146 | } 147 | 148 | return { 149 | x: startX + dirX * maxDist, 150 | y: startY + dirY * maxDist, 151 | }; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /environments/openmonsters/src/tiles/tilemap.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Rng, Tilemap, value, Vector2, Vector2Adapter } from "@dreamlab/engine"; 2 | import { Colors } from "../../lib/colors.ts"; 3 | 4 | export default class GenerateTilemap extends Behavior { 5 | #tilemap = this.entity.cast(Tilemap); 6 | 7 | @value() 8 | seed: number = 0; 9 | 10 | @value({ type: Vector2Adapter }) 11 | halfExtents: Vector2 = new Vector2(50, 50); 12 | 13 | @value({ type: Vector2Adapter }) 14 | safeZone: Vector2 = new Vector2(1, 1); 15 | 16 | onInitialize(): void { 17 | const prng = Rng.Seeded(BigInt(this.seed)); 18 | 19 | // Create concentric maze rooms around spawn 20 | // Room 1 (inner): 6x6 square around spawn 21 | // Room 2 (middle): 24x24 square 22 | // Room 3 (outer): 40x40 square 23 | 24 | const room1Size = 6; 25 | const room2Size = 24; 26 | const room3Size = 40; 27 | 28 | // Fill everything with floor first 29 | for (let x = -this.halfExtents.x; x < this.halfExtents.x; x++) { 30 | for (let y = -this.halfExtents.y; y < this.halfExtents.y; y++) { 31 | this.#tilemap.setColor(x, y, Colors.Grass); 32 | } 33 | } 34 | 35 | // Helper function to create a square room with walls 36 | const createRoom = ( 37 | size: number, 38 | greenDoorPos: { x: number; y: number }, 39 | blueDoorPos: { x: number; y: number }, 40 | ) => { 41 | const half = Math.floor(size / 2); 42 | 43 | // Create walls around the room 44 | for (let i = -half; i <= half; i++) { 45 | // Top and bottom walls 46 | this.#tilemap.setColor(i, -half, Colors.Wall); 47 | this.#tilemap.setColor(i, half, Colors.Wall); 48 | 49 | // Left and right walls 50 | this.#tilemap.setColor(-half, i, Colors.Wall); 51 | this.#tilemap.setColor(half, i, Colors.Wall); 52 | } 53 | 54 | // Place doors 55 | this.#tilemap.setColor(greenDoorPos.x, greenDoorPos.y, Colors.GreenDoor); 56 | this.#tilemap.setColor(blueDoorPos.x, blueDoorPos.y, Colors.BlueDoor); 57 | }; 58 | 59 | // Room 3 (outermost) 60 | createRoom(room3Size, { x: 0, y: -room3Size / 2 }, { x: room3Size / 2, y: 0 }); 61 | 62 | // Room 2 (middle) 63 | createRoom(room2Size, { x: 0, y: room2Size / 2 }, { x: -room2Size / 2, y: 0 }); 64 | 65 | // Room 1 (innermost) 66 | createRoom(room1Size, { x: room1Size / 2, y: 0 }, { x: 0, y: room1Size / 2 }); 67 | 68 | // Add obstacles to each room 69 | const addObstacles = (minRadius: number, maxRadius: number, count: number) => { 70 | let placed = 0; 71 | let attempts = 0; 72 | const maxAttempts = count * 10; // Avoid infinite loops 73 | 74 | while (placed < count && attempts < maxAttempts) { 75 | attempts++; 76 | const angle = prng() * Math.PI * 2; 77 | const radius = minRadius + prng() * (maxRadius - minRadius); 78 | const x = Math.floor(Math.cos(angle) * radius); 79 | const y = Math.floor(Math.sin(angle) * radius); 80 | 81 | // Check if current position is valid for obstacle placement 82 | const currentTile = this.#tilemap.getColor(x, y); 83 | 84 | // Only place on grass/floor tiles (not walls, doors, or safe zone) 85 | if ( 86 | currentTile === Colors.Grass 87 | && (Math.abs(x) > this.safeZone.x || Math.abs(y) > this.safeZone.y) 88 | ) { 89 | // Randomly place walls or slowdown tiles 90 | if (prng() > 0.6) { 91 | this.#tilemap.setColor(x, y, Colors.Wall); 92 | } else { 93 | this.#tilemap.setColor(x, y, Colors.Slowdown); 94 | } 95 | placed++; 96 | } 97 | } 98 | }; 99 | 100 | // // Add obstacles to room 1 (inner ring between room 1 and 2 walls) 101 | // addObstacles(room1Size / 2 + 1, room2Size / 2 - 1, 12); 102 | 103 | // // Add obstacles to room 2 (middle ring between room 2 and 3 walls) 104 | // addObstacles(room2Size / 2 + 1, room3Size / 2 - 1, 40); 105 | 106 | // // Add obstacles to room 3 (outer ring beyond room 3 wall) 107 | // addObstacles(room3Size / 2 + 1, Math.min(this.halfExtents.x, this.halfExtents.y) - 1, 60); 108 | 109 | // Clear the spawn safe zone 110 | for (let x = -this.safeZone.x; x <= this.safeZone.x; x++) { 111 | for (let y = -this.safeZone.y; y <= this.safeZone.y; y++) { 112 | this.#tilemap.setColor(x, y, Colors.Sand); 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /environments/portalbench/src/tiles/tilemap.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, Rng, Tilemap, value, Vector2, Vector2Adapter } from "@dreamlab/engine"; 2 | import { Colors } from "../../lib/colors.ts"; 3 | 4 | export default class GenerateTilemap extends Behavior { 5 | #tilemap = this.entity.cast(Tilemap); 6 | 7 | @value() 8 | seed: number = 0; 9 | 10 | @value({ type: Vector2Adapter }) 11 | halfExtents: Vector2 = new Vector2(50, 50); 12 | 13 | @value({ type: Vector2Adapter }) 14 | safeZone: Vector2 = new Vector2(1, 1); 15 | 16 | onInitialize(): void { 17 | const prng = Rng.Seeded(BigInt(this.seed)); 18 | 19 | // Create concentric maze rooms around spawn 20 | // Room 1 (inner): 6x6 square around spawn 21 | // Room 2 (middle): 24x24 square 22 | // Room 3 (outer): 40x40 square 23 | 24 | const room1Size = 6; 25 | const room2Size = 24; 26 | const room3Size = 40; 27 | 28 | // Fill everything with floor first 29 | for (let x = -this.halfExtents.x; x < this.halfExtents.x; x++) { 30 | for (let y = -this.halfExtents.y; y < this.halfExtents.y; y++) { 31 | this.#tilemap.setColor(x, y, Colors.Grass); 32 | } 33 | } 34 | 35 | // Helper function to create a square room with walls 36 | const createRoom = ( 37 | size: number, 38 | greenDoorPos: { x: number; y: number }, 39 | blueDoorPos: { x: number; y: number }, 40 | ) => { 41 | const half = Math.floor(size / 2); 42 | 43 | // Create walls around the room 44 | for (let i = -half; i <= half; i++) { 45 | // Top and bottom walls 46 | this.#tilemap.setColor(i, -half, Colors.Wall); 47 | this.#tilemap.setColor(i, half, Colors.Wall); 48 | 49 | // Left and right walls 50 | this.#tilemap.setColor(-half, i, Colors.Wall); 51 | this.#tilemap.setColor(half, i, Colors.Wall); 52 | } 53 | 54 | // Place doors 55 | this.#tilemap.setColor(greenDoorPos.x, greenDoorPos.y, Colors.GreenDoor); 56 | this.#tilemap.setColor(blueDoorPos.x, blueDoorPos.y, Colors.BlueDoor); 57 | }; 58 | 59 | // Room 3 (outermost) 60 | createRoom(room3Size, { x: 0, y: -room3Size / 2 }, { x: room3Size / 2, y: 0 }); 61 | 62 | // Room 2 (middle) 63 | createRoom(room2Size, { x: 0, y: room2Size / 2 }, { x: -room2Size / 2, y: 0 }); 64 | 65 | // Room 1 (innermost) 66 | createRoom(room1Size, { x: room1Size / 2, y: 0 }, { x: 0, y: room1Size / 2 }); 67 | 68 | // Add obstacles to each room 69 | const addObstacles = (minRadius: number, maxRadius: number, count: number) => { 70 | let placed = 0; 71 | let attempts = 0; 72 | const maxAttempts = count * 10; // Avoid infinite loops 73 | 74 | while (placed < count && attempts < maxAttempts) { 75 | attempts++; 76 | const angle = prng() * Math.PI * 2; 77 | const radius = minRadius + prng() * (maxRadius - minRadius); 78 | const x = Math.floor(Math.cos(angle) * radius); 79 | const y = Math.floor(Math.sin(angle) * radius); 80 | 81 | // Check if current position is valid for obstacle placement 82 | const currentTile = this.#tilemap.getColor(x, y); 83 | 84 | // Only place on grass/floor tiles (not walls, doors, or safe zone) 85 | if ( 86 | currentTile === Colors.Grass 87 | && (Math.abs(x) > this.safeZone.x || Math.abs(y) > this.safeZone.y) 88 | ) { 89 | // Randomly place walls or slowdown tiles 90 | if (prng() > 0.6) { 91 | this.#tilemap.setColor(x, y, Colors.Wall); 92 | } else { 93 | this.#tilemap.setColor(x, y, Colors.Slowdown); 94 | } 95 | placed++; 96 | } 97 | } 98 | }; 99 | 100 | // // Add obstacles to room 1 (inner ring between room 1 and 2 walls) 101 | // addObstacles(room1Size / 2 + 1, room2Size / 2 - 1, 12); 102 | 103 | // // Add obstacles to room 2 (middle ring between room 2 and 3 walls) 104 | // addObstacles(room2Size / 2 + 1, room3Size / 2 - 1, 40); 105 | 106 | // // Add obstacles to room 3 (outer ring beyond room 3 wall) 107 | // addObstacles(room3Size / 2 + 1, Math.min(this.halfExtents.x, this.halfExtents.y) - 1, 60); 108 | 109 | // Clear the spawn safe zone 110 | for (let x = -this.safeZone.x; x <= this.safeZone.x; x++) { 111 | for (let y = -this.safeZone.y; y <= this.safeZone.y; y++) { 112 | this.#tilemap.setColor(x, y, Colors.Sand); 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /harness/main.ts: -------------------------------------------------------------------------------- 1 | import { load as loadEnv } from "@std/dotenv"; 2 | import { parseArgs } from "@std/cli"; 3 | import * as p from "@clack/prompts"; 4 | import { OpenAIWrapper } from "./src/llm/openai-wrapper.ts"; 5 | import { CLI } from "./src/cli.ts"; 6 | import type { Tool } from "./src/llm/types.ts"; 7 | import { 8 | buildToolRegistry, 9 | DEFAULT_TOOL_NAMES, 10 | promptForInitialPrompt, 11 | promptForModel, 12 | promptForProvider, 13 | promptForTools, 14 | validateApiKey, 15 | } from "./src/interactive-prompts.ts"; 16 | import { ALL_TOOLS } from "./src/tools/registry.ts"; 17 | import { extractShortModelName, setModelName } from "./src/context.ts"; 18 | 19 | // Load environment variables 20 | await loadEnv({ envPath: ".env.local", export: true }); 21 | await loadEnv({ envPath: ".env", export: true }); 22 | 23 | // Parse CLI arguments 24 | const args = parseArgs(Deno.args, { 25 | string: ["provider", "model", "base-url"], 26 | alias: { 27 | p: "provider", 28 | m: "model", 29 | b: "base-url", 30 | }, 31 | }); 32 | 33 | // Determine if interactive mode (no CLI args provided) 34 | const useInteractive = !args.provider && !args.model; 35 | 36 | let provider: string; 37 | let model: string; 38 | let selectedTools: Tool[]; 39 | let selectedPrompt: string | null = null; 40 | 41 | if (useInteractive) { 42 | // Interactive mode with Clack prompts 43 | p.intro("WorldQL LLM Harness"); 44 | 45 | provider = await promptForProvider(); 46 | validateApiKey(provider); 47 | model = await promptForModel(provider); 48 | 49 | // Get default tools (matches Python DEFAULT_TOOLS) 50 | const defaultTools = ALL_TOOLS.filter((t) => 51 | DEFAULT_TOOL_NAMES.includes(t.name) 52 | ); 53 | selectedTools = await promptForTools(defaultTools); 54 | 55 | selectedPrompt = await promptForInitialPrompt(); 56 | 57 | // Show configuration summary 58 | const toolsList = selectedTools.map((t) => t.name).join(", "); 59 | const promptInfo = selectedPrompt 60 | ? `\nPrompt: ${selectedPrompt.split("/").pop()}` 61 | : ""; 62 | p.note( 63 | `Provider: ${provider}\nModel: ${model}\nTools: ${toolsList}${promptInfo}`, 64 | "Configuration" 65 | ); 66 | 67 | console.log(); 68 | } else { 69 | // Non-interactive mode (CLI arguments) 70 | provider = args.provider || Deno.env.get("LLM_PROVIDER") || "openrouter"; 71 | validateApiKey(provider); 72 | 73 | if (args.model) { 74 | model = args.model; 75 | } else if (provider === "openai") { 76 | model = Deno.env.get("OPENAI_MODEL") || "gpt-4o"; 77 | } else { 78 | model = Deno.env.get("OPENAI_MODEL") || "gpt-4o"; 79 | } 80 | 81 | // Use default tools 82 | selectedTools = ALL_TOOLS.filter((t) => 83 | ["MovePlayer", "SpawnPlayer", "ObserveWorld"].includes(t.name) 84 | ); 85 | 86 | console.log(`\n✓ Using ${provider} with model: ${model}`); 87 | console.log( 88 | `✓ Enabled tools: ${selectedTools.map((t) => t.name).join(", ")}` 89 | ); 90 | console.log(); 91 | } 92 | 93 | // Set model name in context for player spawning 94 | const shortName = extractShortModelName(model); 95 | setModelName(shortName); 96 | 97 | // Get API key and base URL based on provider 98 | let apiKey: string; 99 | let baseURL: string; 100 | 101 | if (provider === "openrouter") { 102 | apiKey = 103 | Deno.env.get("OPENROUTER_API_KEY") || Deno.env.get("OPENAI_API_KEY") || ""; 104 | baseURL = 105 | args["base-url"] || 106 | Deno.env.get("OPENAI_BASE_URL") || 107 | "https://openrouter.ai/api/v1"; 108 | } else if (provider === "openai") { 109 | apiKey = Deno.env.get("OPENAI_API_KEY") || ""; 110 | baseURL = 111 | args["base-url"] || 112 | Deno.env.get("OPENAI_BASE_URL") || 113 | "https://api.openai.com/v1"; 114 | } else if (provider === "anthropic") { 115 | // Anthropic via OpenRouter (no native wrapper yet) 116 | apiKey = 117 | Deno.env.get("OPENROUTER_API_KEY") || Deno.env.get("OPENAI_API_KEY") || ""; 118 | baseURL = args["base-url"] || "https://openrouter.ai/api/v1"; 119 | } else { 120 | console.error( 121 | `Error: Unknown provider '${provider}'. Use 'openai', 'openrouter', or 'anthropic'` 122 | ); 123 | Deno.exit(1); 124 | } 125 | 126 | // Create LLM wrapper 127 | const llm = new OpenAIWrapper(apiKey, model, baseURL); 128 | 129 | // Build tool registry 130 | const toolRegistry = buildToolRegistry(selectedTools); 131 | 132 | // Create CLI with tools 133 | const cli = new CLI(llm, selectedTools, toolRegistry); 134 | 135 | // Load initial prompt if selected 136 | if (selectedPrompt) { 137 | try { 138 | const content = await Deno.readTextFile(selectedPrompt); 139 | console.log( 140 | `\nLoaded ${content.length} characters from ${selectedPrompt}\n` 141 | ); 142 | await cli.processInitialMessage(content); 143 | } catch (error) { 144 | const message = error instanceof Error ? error.message : String(error); 145 | console.log(`⚠️ Warning: Could not load prompt file: ${message}\n`); 146 | } 147 | } 148 | 149 | // Run interactive CLI 150 | await cli.runInteractive(); 151 | -------------------------------------------------------------------------------- /environments/openmonsters/src/mechanics/pushable-block-manager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Behavior, 3 | Entity, 4 | EntityRef, 5 | type IVector2, 6 | Tilemap, 7 | value, 8 | Vector2, 9 | } from "@dreamlab/engine"; 10 | import { Colors } from "../../lib/colors.ts"; 11 | import { PlayerMoved } from "../player/movement.ts"; 12 | import PortalManager from "./portal-manager.ts"; 13 | 14 | export default class PushableBlockManager extends Behavior { 15 | @value({ type: EntityRef }) 16 | tilemap: Entity | undefined; 17 | 18 | #lastPlayerPos: Map = new Map(); 19 | #initialBlockPositions: Vector2[] = []; 20 | #goalPositions: Set = new Set(); 21 | 22 | public static instance: PushableBlockManager | undefined; 23 | 24 | public onInitialize() { 25 | PushableBlockManager.instance = this; 26 | 27 | const tilemap = this.tilemap?.cast(Tilemap); 28 | if (!tilemap) throw new Error("missing tilemap"); 29 | 30 | this.#scanTilemap(tilemap); 31 | 32 | this.listen(this.game, PlayerMoved, ev => { 33 | const playerId = ev.player.entity.id; 34 | const to = ev.position; 35 | const from = this.#lastPlayerPos.get(playerId); 36 | 37 | this.#lastPlayerPos.set(playerId, new Vector2(to.x, to.y)); 38 | 39 | if (!from) return; 40 | 41 | const blockColor = tilemap.getColor(to.x, to.y); 42 | if (blockColor !== Colors.PushableBlock && blockColor !== Colors.BlockOnGoal) return; 43 | 44 | const direction: IVector2 = { 45 | x: to.x - from.x, 46 | y: to.y - from.y, 47 | }; 48 | 49 | const oppositePos: IVector2 = { 50 | x: to.x + direction.x, 51 | y: to.y + direction.y, 52 | }; 53 | 54 | // Check if the block is being pushed into a portal 55 | const targetColor = tilemap.getColor(oppositePos.x, oppositePos.y); 56 | let finalBlockPos = oppositePos; 57 | 58 | if ((targetColor === Colors.BluePortal || targetColor === Colors.OrangePortal) && PortalManager.instance) { 59 | const portalInfo = PortalManager.instance.getPortalInfo(); 60 | 61 | if (portalInfo.bluePortal && portalInfo.orangePortal) { 62 | // Determine which portal the block is entering 63 | let exitPortal: { x: number; y: number } | null = null; 64 | 65 | if (targetColor === Colors.BluePortal) { 66 | exitPortal = portalInfo.orangePortal; 67 | } else if (targetColor === Colors.OrangePortal) { 68 | exitPortal = portalInfo.bluePortal; 69 | } 70 | 71 | if (exitPortal) { 72 | // Find the nearest adjacent floor tile to the exit portal 73 | const adjacentPositions = [ 74 | { x: exitPortal.x + 1, y: exitPortal.y }, // right 75 | { x: exitPortal.x - 1, y: exitPortal.y }, // left 76 | { x: exitPortal.x, y: exitPortal.y + 1 }, // down 77 | { x: exitPortal.x, y: exitPortal.y - 1 }, // up 78 | ]; 79 | 80 | for (const pos of adjacentPositions) { 81 | const adjColor = tilemap.getColor(pos.x, pos.y); 82 | if (adjColor === Colors.Grass) { 83 | finalBlockPos = pos; 84 | break; 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | const fromKey = `${to.x},${to.y}`; 92 | const toKey = `${finalBlockPos.x},${finalBlockPos.y}`; 93 | 94 | const restoreColor = this.#goalPositions.has(fromKey) ? Colors.BlockGoal : Colors.Grass; 95 | tilemap.setColor(to.x, to.y, restoreColor); 96 | 97 | const newBlockColor = this.#goalPositions.has(toKey) 98 | ? Colors.BlockOnGoal 99 | : Colors.PushableBlock; 100 | tilemap.setColor(finalBlockPos.x, finalBlockPos.y, newBlockColor); 101 | }); 102 | } 103 | 104 | #scanTilemap(tilemap: Tilemap) { 105 | const scanRange = 100; 106 | 107 | for (let y = -scanRange; y <= scanRange; y++) { 108 | for (let x = -scanRange; x <= scanRange; x++) { 109 | const color = tilemap.getColor(x, y); 110 | if (!color) continue; // Skip empty/null tiles 111 | 112 | if (color === Colors.PushableBlock || color === Colors.BlockOnGoal) { 113 | this.#initialBlockPositions.push(new Vector2(x, y)); 114 | } 115 | 116 | if (color === Colors.BlockGoal || color === Colors.BlockOnGoal) { 117 | this.#goalPositions.add(`${x},${y}`); 118 | } 119 | } 120 | } 121 | } 122 | 123 | public isGoalPosition(x: number, y: number): boolean { 124 | return this.#goalPositions.has(`${x},${y}`); 125 | } 126 | 127 | public restart() { 128 | const tilemap = this.tilemap?.cast(Tilemap); 129 | if (!tilemap) throw new Error("missing tilemap"); 130 | 131 | const scanRange = 100; 132 | for (let y = -scanRange; y <= scanRange; y++) { 133 | for (let x = -scanRange; x <= scanRange; x++) { 134 | const color = tilemap.getColor(x, y); 135 | if (color === Colors.PushableBlock || color === Colors.BlockOnGoal) { 136 | const key = `${x},${y}`; 137 | const restoreColor = this.#goalPositions.has(key) ? Colors.BlockGoal : Colors.Grass; 138 | tilemap.setColor(x, y, restoreColor); 139 | } 140 | } 141 | } 142 | 143 | for (const pos of this.#initialBlockPositions) { 144 | const key = `${pos.x},${pos.y}`; 145 | const blockColor = this.#goalPositions.has(key) 146 | ? Colors.BlockOnGoal 147 | : Colors.PushableBlock; 148 | tilemap.setColor(pos.x, pos.y, blockColor); 149 | } 150 | 151 | this.#lastPlayerPos.clear(); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /environments/openmonsters/src/mechanics/portal-manager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Behavior, 3 | EntityRef, 4 | IVector2, 5 | Tilemap, 6 | value, 7 | Vector2, 8 | } from "@dreamlab/engine"; 9 | import { Colors } from "../../lib/colors.ts"; 10 | import { PlayerMoved } from "../player/movement.ts"; 11 | 12 | type PortalData = { 13 | position: Vector2; 14 | originalTileColor: number; 15 | }; 16 | 17 | export default class PortalManager extends Behavior { 18 | static instance: PortalManager | undefined; 19 | 20 | @value({ type: EntityRef }) 21 | tilemap: Tilemap | undefined; 22 | 23 | private bluePortal: PortalData | undefined; 24 | private orangePortal: PortalData | undefined; 25 | 26 | onInitialize(): void { 27 | if (!this.game.isServer()) return; 28 | PortalManager.instance = this; 29 | 30 | this.listen(this.game, PlayerMoved, (event) => { 31 | this.handlePlayerMovement(event); 32 | }); 33 | } 34 | 35 | onDestroy(): void { 36 | if (PortalManager.instance === this) { 37 | PortalManager.instance = undefined; 38 | } 39 | } 40 | 41 | placePortal( 42 | portalColor: "blue" | "orange", 43 | targetTile: IVector2, 44 | playerPos: IVector2 45 | ): { success: boolean; error?: string } { 46 | if (!this.tilemap) { 47 | return { success: false, error: "Tilemap not found" }; 48 | } 49 | 50 | const tileColor = this.tilemap.getColor(targetTile.x, targetTile.y); 51 | 52 | console.log(tileColor); 53 | 54 | if ( 55 | tileColor !== Colors.Wall && 56 | tileColor !== Colors.BombWall && 57 | tileColor !== Colors.GlassWall 58 | ) { 59 | return { success: false, error: "Can only place portals on walls" }; 60 | } 61 | 62 | if (!this.isInLineOfSight(playerPos, targetTile)) { 63 | return { success: false, error: "Target not in line of sight" }; 64 | } 65 | 66 | if (portalColor === "blue" && this.bluePortal) { 67 | this.removePortal("blue"); 68 | } else if (portalColor === "orange" && this.orangePortal) { 69 | this.removePortal("orange"); 70 | } 71 | 72 | const portalData: PortalData = { 73 | position: new Vector2(targetTile.x, targetTile.y), 74 | originalTileColor: tileColor, 75 | }; 76 | 77 | if (portalColor === "blue") { 78 | this.bluePortal = portalData; 79 | this.tilemap.setColor(targetTile.x, targetTile.y, Colors.BluePortal); 80 | } else { 81 | this.orangePortal = portalData; 82 | this.tilemap.setColor(targetTile.x, targetTile.y, Colors.OrangePortal); 83 | } 84 | 85 | return { success: true }; 86 | } 87 | 88 | private isInLineOfSight(from: IVector2, to: IVector2): boolean { 89 | if (!this.tilemap) return false; 90 | 91 | const dx = to.x - from.x; 92 | const dy = to.y - from.y; 93 | const distance = Math.sqrt(dx * dx + dy * dy); 94 | 95 | if (distance === 0) return true; 96 | 97 | const steps = Math.ceil(distance * 4); 98 | 99 | const checkedTiles = new Set(); 100 | let reachedTarget = false; 101 | 102 | for (let i = 1; i <= steps; i++) { 103 | const t = i / steps; 104 | const worldX = from.x + dx * t; 105 | const worldY = from.y + dy * t; 106 | 107 | const worldDistToTarget = Math.sqrt( 108 | Math.pow(worldX - to.x, 2) + Math.pow(worldY - to.y, 2) 109 | ); 110 | 111 | const x = Math.round(worldX); 112 | const y = Math.round(worldY); 113 | 114 | const key = `${x},${y}`; 115 | if (checkedTiles.has(key)) continue; 116 | checkedTiles.add(key); 117 | 118 | if (x === from.x && y === from.y) continue; 119 | 120 | if (x === to.x && y === to.y) { 121 | reachedTarget = true; 122 | break; 123 | } 124 | 125 | if (worldDistToTarget < 0.6) { 126 | reachedTarget = true; 127 | break; 128 | } 129 | 130 | const tileColor = this.tilemap.getColor(x, y); 131 | 132 | if ( 133 | tileColor === Colors.Wall || 134 | tileColor === Colors.BombWall || 135 | tileColor === Colors.GreenDoor || 136 | tileColor === Colors.BlueDoor 137 | ) { 138 | return false; 139 | } 140 | } 141 | 142 | if (reachedTarget) { 143 | return true; 144 | } 145 | 146 | return false; 147 | } 148 | 149 | removePortal(portalColor: "blue" | "orange"): void { 150 | if (!this.tilemap) return; 151 | 152 | const portal = portalColor === "blue" ? this.bluePortal : this.orangePortal; 153 | if (!portal) return; 154 | 155 | this.tilemap.setColor( 156 | portal.position.x, 157 | portal.position.y, 158 | portal.originalTileColor 159 | ); 160 | 161 | if (portalColor === "blue") { 162 | this.bluePortal = undefined; 163 | } else { 164 | this.orangePortal = undefined; 165 | } 166 | } 167 | 168 | clearAllPortals(): void { 169 | this.removePortal("blue"); 170 | this.removePortal("orange"); 171 | } 172 | 173 | private handlePlayerMovement(event: PlayerMoved): void { 174 | if (!this.tilemap) return; 175 | if (!this.bluePortal || !this.orangePortal) return; 176 | 177 | const playerPos = event.position; 178 | 179 | if ( 180 | playerPos.x === this.bluePortal.position.x && 181 | playerPos.y === this.bluePortal.position.y 182 | ) { 183 | event.teleport = this.orangePortal.position.clone(); 184 | return; 185 | } 186 | 187 | if ( 188 | playerPos.x === this.orangePortal.position.x && 189 | playerPos.y === this.orangePortal.position.y 190 | ) { 191 | event.teleport = this.bluePortal.position.clone(); 192 | return; 193 | } 194 | } 195 | 196 | getPortalInfo(): { 197 | bluePortal: { x: number; y: number } | null; 198 | orangePortal: { x: number; y: number } | null; 199 | } { 200 | return { 201 | bluePortal: this.bluePortal 202 | ? { x: this.bluePortal.position.x, y: this.bluePortal.position.y } 203 | : null, 204 | orangePortal: this.orangePortal 205 | ? { x: this.orangePortal.position.x, y: this.orangePortal.position.y } 206 | : null, 207 | }; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /harness/src/cli.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Message, 3 | MessageContent, 4 | Tool, 5 | ToolHandler, 6 | ToolResultContent, 7 | } from "./llm/types.ts"; 8 | import type { LLMWrapper } from "./llm/wrapper.ts"; 9 | 10 | /** 11 | * CLI class that manages the interactive REPL and conversation with the LLM. 12 | */ 13 | export class CLI { 14 | private messages: Message[] = []; 15 | 16 | constructor( 17 | private llm: LLMWrapper, 18 | private tools: Tool[], 19 | private toolRegistry: Record, 20 | ) {} 21 | 22 | /** 23 | * Run the interactive CLI session. 24 | */ 25 | async runInteractive(): Promise { 26 | console.log( 27 | "Type 'quit' to exit, '/new' for new conversation, '/load ' to load a file, or '/tools' to reconfigure tools", 28 | ); 29 | 30 | const decoder = new TextDecoder(); 31 | const buffer = new Uint8Array(1024); 32 | 33 | while (true) { 34 | try { 35 | // Write prompt 36 | await Deno.stdout.write(new TextEncoder().encode("> ")); 37 | 38 | // Read input 39 | const n = await Deno.stdin.read(buffer); 40 | if (n === null) break; 41 | 42 | const input = decoder.decode(buffer.subarray(0, n)).trim(); 43 | 44 | if (input === "quit" || input === "exit") { 45 | break; 46 | } 47 | 48 | if (input) { 49 | await this.processMessage(input); 50 | } 51 | } catch (error) { 52 | if (error instanceof Deno.errors.Interrupted) { 53 | console.log("\nGoodbye!"); 54 | break; 55 | } 56 | throw error; 57 | } 58 | } 59 | } 60 | 61 | /** 62 | * Process a user message through the LLM. 63 | */ 64 | private async processMessage(message: string): Promise { 65 | // Handle slash commands 66 | if (message.startsWith("/")) { 67 | await this.handleCommand(message); 68 | return; 69 | } 70 | 71 | // Add user message to history 72 | this.messages.push({ 73 | role: "user", 74 | content: [ 75 | { 76 | type: "text", 77 | text: message, 78 | }, 79 | ], 80 | }); 81 | 82 | console.log(); 83 | 84 | // Stream response 85 | const encoder = new TextEncoder(); 86 | for await (const text of this.llm.streamMessages(this.messages, { 87 | maxTokens: 10000, 88 | tools: this.tools, 89 | })) { 90 | await Deno.stdout.write(encoder.encode(text)); 91 | } 92 | 93 | // Get final message and add to history 94 | const final = this.llm.getFinalMessage(); 95 | this.messages.push(final); 96 | 97 | // Handle tool calls 98 | await this.handleToolCalls(final); 99 | } 100 | 101 | /** 102 | * Handle slash commands. 103 | */ 104 | private async handleCommand(command: string): Promise { 105 | const parts = command.trim().split(/\s+/, 2); 106 | const cmd = parts[0].toLowerCase(); 107 | 108 | if (cmd === "/new") { 109 | this.messages = []; 110 | console.log("Started new conversation"); 111 | } else if (cmd === "/load") { 112 | if (parts.length < 2) { 113 | console.log("Usage: /load "); 114 | return; 115 | } 116 | await this.loadFile(parts[1]); 117 | } else if (cmd === "/tools") { 118 | await this.reconfigureTools(); 119 | } else { 120 | console.log(`Unknown command: ${command}`); 121 | } 122 | } 123 | 124 | /** 125 | * Load a file from the current path and use its contents as a prompt. 126 | */ 127 | private async loadFile(filename: string): Promise { 128 | try { 129 | const content = await Deno.readTextFile(filename); 130 | console.log(`Loaded ${content.length} characters from ${filename}`); 131 | await this.processMessage(content); 132 | } catch (error) { 133 | if (error instanceof Deno.errors.NotFound) { 134 | console.log(`Error: File '${filename}' not found`); 135 | } else { 136 | console.log(`Error reading file: ${error}`); 137 | } 138 | } 139 | } 140 | 141 | /** 142 | * Handle any tool calls in the final message. 143 | */ 144 | private async handleToolCalls(finalMessage: Message): Promise { 145 | if (typeof finalMessage.content === "string") { 146 | return; 147 | } 148 | 149 | const toolResults: ToolResultContent[] = []; 150 | 151 | for (const contentBlock of finalMessage.content) { 152 | if (contentBlock.type === "tool_use") { 153 | const handler = this.toolRegistry[contentBlock.name]; 154 | 155 | if (handler) { 156 | const result = await handler(contentBlock.input); 157 | console.log(result); 158 | toolResults.push({ 159 | type: "tool_result", 160 | tool_use_id: contentBlock.id, 161 | content: result, 162 | }); 163 | } else { 164 | console.log(`Unknown tool: ${contentBlock.name}`); 165 | } 166 | } 167 | } 168 | 169 | if (toolResults.length > 0) { 170 | this.messages.push({ 171 | role: "user", 172 | content: toolResults, 173 | }); 174 | await this.processToolResponse(); 175 | } 176 | } 177 | 178 | /** 179 | * Process the response after tool execution. 180 | */ 181 | private async processToolResponse(): Promise { 182 | console.log(); 183 | 184 | const encoder = new TextEncoder(); 185 | for await (const text of this.llm.streamMessages(this.messages, { 186 | maxTokens: 4096, 187 | tools: this.tools, 188 | })) { 189 | await Deno.stdout.write(encoder.encode(text)); 190 | } 191 | 192 | console.log(); 193 | const final = this.llm.getFinalMessage(); 194 | this.messages.push(final); 195 | 196 | // Recursively handle additional tool calls 197 | await this.handleToolCalls(final); 198 | } 199 | 200 | /** 201 | * Reconfigure tools during the session. 202 | */ 203 | private async reconfigureTools(): Promise { 204 | const { promptForTools, buildToolRegistry } = await import( 205 | "./interactive-prompts.ts" 206 | ); 207 | 208 | this.tools = await promptForTools(this.tools); 209 | this.toolRegistry = buildToolRegistry(this.tools); 210 | 211 | console.log( 212 | `\n✓ Updated tools: ${this.tools.map((t) => t.name).join(", ")}\n`, 213 | ); 214 | } 215 | 216 | /** 217 | * Process an initial message before entering the interactive loop. 218 | * Useful for loading initial prompts. 219 | */ 220 | async processInitialMessage(message: string): Promise { 221 | await this.processMessage(message); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /environments/portalbench/src/effects/particle.ts: -------------------------------------------------------------------------------- 1 | import { Behavior, ColorAdapter, RawPixi, syncedValue } from "@dreamlab/engine"; 2 | import * as PIXI from "@dreamlab/vendor/pixi.ts"; 3 | 4 | // example particle implementation 5 | export default class ParticleRender extends Behavior { 6 | @syncedValue() 7 | particleCount = 40; 8 | 9 | @syncedValue() 10 | ringRadius = 0.4; 11 | 12 | @syncedValue() 13 | swirSpeed = 0.15; 14 | 15 | @syncedValue() 16 | minSize = 0.08; 17 | 18 | @syncedValue() 19 | maxSize = 0.12; 20 | 21 | @syncedValue(ColorAdapter) 22 | particleColor = "#ffffff"; 23 | 24 | private particles: Array<{ 25 | sprite: PIXI.Graphics; 26 | angle: number; 27 | angularVelocity: number; 28 | radius: number; 29 | radiusVelocity: number; 30 | x: number; 31 | y: number; 32 | size: number; 33 | rotation: number; 34 | rotationSpeed: number; 35 | lifespan: number; 36 | maxLifespan: number; 37 | }> = []; 38 | 39 | @syncedValue() 40 | burstOriginX = 0; 41 | 42 | @syncedValue() 43 | burstOriginY = 0; 44 | 45 | private container: PIXI.Container | null = null; 46 | 47 | onInitialize(): void { 48 | if (!this.game.isClient()) return; 49 | if (!(this.entity instanceof RawPixi)) return; 50 | if (!this.entity.container) return; 51 | 52 | this.container = this.entity.container; 53 | 54 | // Create the particles but don't add them to the container yet 55 | this.prepareParticles(); 56 | 57 | // Add a small delay before adding particles to the container 58 | // This ensures they're properly positioned before becoming visible 59 | setTimeout(() => { 60 | this.addParticlesToContainer(); 61 | }, 0); 62 | 63 | // Destroy the entity after portal effect completes 64 | setTimeout(() => { 65 | this.entity.destroy(); 66 | }, 700); 67 | } 68 | 69 | onTick(): void { 70 | if (!this.game.isClient() || !this.container) return; 71 | 72 | const deltaTime = this.game.physics.tickDelta / 16; 73 | 74 | // Update each particle in the swirling ring 75 | for (let i = this.particles.length - 1; i >= 0; i--) { 76 | const particle = this.particles[i]; 77 | 78 | // Update angle for swirling motion 79 | particle.angle += particle.angularVelocity * deltaTime; 80 | 81 | // Update radius (particles expand outward) 82 | particle.radius += particle.radiusVelocity * deltaTime; 83 | 84 | // Calculate position based on angle and radius 85 | particle.x = 86 | this.burstOriginX + Math.cos(particle.angle) * particle.radius; 87 | particle.y = 88 | this.burstOriginY + Math.sin(particle.angle) * particle.radius; 89 | 90 | // Rotate individual particles for extra effect 91 | particle.rotation += particle.rotationSpeed * deltaTime; 92 | particle.sprite.rotation = particle.rotation; 93 | 94 | // Update sprite position 95 | particle.sprite.position.x = particle.x; 96 | particle.sprite.position.y = particle.y; 97 | 98 | // Decrease lifespan 99 | particle.lifespan -= deltaTime; 100 | 101 | // Calculate progress (0 to 1) 102 | const progress = 1 - particle.lifespan / particle.maxLifespan; 103 | 104 | // Fade in quickly, then fade out 105 | if (progress < 0.2) { 106 | particle.sprite.alpha = progress / 0.2; 107 | } else { 108 | particle.sprite.alpha = Math.max(0, 1 - (progress - 0.2) / 0.8); 109 | } 110 | 111 | // Scale particles for extra visual effect 112 | const scale = 1 + Math.sin(progress * Math.PI) * 0.5; 113 | particle.sprite.scale.set(scale); 114 | 115 | // Remove particles that have lived their life 116 | if (particle.lifespan <= 0) { 117 | this.container.removeChild(particle.sprite); 118 | this.particles.splice(i, 1); 119 | } 120 | } 121 | } 122 | 123 | prepareParticles(): void { 124 | if (!this.container) return; 125 | 126 | // Clear any existing particles 127 | for (const particle of this.particles) { 128 | if (particle.sprite.parent) { 129 | particle.sprite.parent.removeChild(particle.sprite); 130 | } 131 | } 132 | this.particles = []; 133 | 134 | const maxLifespan = 30; // Half second at 60fps 135 | 136 | // Create particles arranged in a ring 137 | for (let i = 0; i < this.particleCount; i++) { 138 | const g = new PIXI.Graphics(); 139 | const size = this.minSize + Math.random() * (this.maxSize - this.minSize); 140 | 141 | // Create circular particles for a smoother portal effect 142 | g.circle(0, 0, size / 2); 143 | 144 | // Choose color - use the selected color unless multiple colors enabled 145 | const color = this.particleColor; 146 | 147 | g.fill({ color }); 148 | 149 | // Distribute particles evenly around the ring with some randomness 150 | const baseAngle = (i / this.particleCount) * Math.PI * 2; 151 | const angleVariation = (Math.random() - 0.5) * 0.3; 152 | const angle = baseAngle + angleVariation; 153 | 154 | // Start at a smaller radius and expand outward 155 | const startRadius = this.ringRadius * (0.3 + Math.random() * 0.2); 156 | const radiusVelocity = 0.015 + Math.random() * 0.01; 157 | 158 | // Angular velocity for swirling (some particles go faster) 159 | const angularVelocity = this.swirSpeed * (0.8 + Math.random() * 0.4); 160 | 161 | // Initial position 162 | const x = this.burstOriginX + Math.cos(angle) * startRadius; 163 | const y = this.burstOriginY + Math.sin(angle) * startRadius; 164 | 165 | g.position.set(x, y); 166 | 167 | // Random initial rotation 168 | const rotation = Math.random() * Math.PI * 2; 169 | g.rotation = rotation; 170 | 171 | // Add some stagger to the lifespan for a more organic look 172 | const lifespanVariation = (Math.random() - 0.5) * 8; 173 | const lifespan = maxLifespan + lifespanVariation; 174 | 175 | // Store particle data with properties for swirling ring behavior 176 | this.particles.push({ 177 | sprite: g, 178 | angle: angle, 179 | angularVelocity: angularVelocity, 180 | radius: startRadius, 181 | radiusVelocity: radiusVelocity, 182 | x: x, 183 | y: y, 184 | size: size, 185 | rotation: rotation, 186 | rotationSpeed: (Math.random() - 0.5) * 0.08, 187 | lifespan: lifespan, 188 | maxLifespan: lifespan, 189 | }); 190 | } 191 | } 192 | 193 | addParticlesToContainer(): void { 194 | if (!this.container) return; 195 | 196 | // Add all prepared particles to the container 197 | for (const particle of this.particles) { 198 | this.container.addChild(particle.sprite); 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /harness/src/interactive-prompts.ts: -------------------------------------------------------------------------------- 1 | import * as p from "@clack/prompts"; 2 | import type { Tool, ToolHandler } from "./llm/types.ts"; 3 | import { ALL_TOOLS, ALL_TOOL_HANDLERS } from "./tools/registry.ts"; 4 | 5 | /** 6 | * Model choices per provider 7 | */ 8 | const MODEL_CHOICES = { 9 | openai: [ 10 | { value: "gpt-5.2", label: "GPT 5.2" }, 11 | { value: "__CUSTOM__", label: "Custom..." }, 12 | ], 13 | openrouter: [ 14 | { value: "anthropic/claude-sonnet-4.5", label: "Claude Sonnet 4.5" }, 15 | { value: "openai/gpt-5.2", label: "GPT 5.2" }, 16 | { value: "google/gemini-3-pro-preview", label: "Gemini 3 Pro" }, 17 | { value: "google/gemini-2.0-flash-exp", label: "Gemini 2.0 Flash" }, 18 | { value: "__CUSTOM__", label: "Custom..." }, 19 | ], 20 | }; 21 | 22 | /** 23 | * Default tool names (matches Python DEFAULT_TOOLS) 24 | */ 25 | export const DEFAULT_TOOL_NAMES = [ 26 | "MovePlayer", 27 | "SpawnPlayer", 28 | "ObserveWorld", 29 | "PlacePortal", 30 | ]; 31 | 32 | /** 33 | * Prompt user to select a provider 34 | */ 35 | export async function promptForProvider(): Promise { 36 | const provider = await p.select({ 37 | message: "Select a provider:", 38 | options: [ 39 | { value: "openrouter", label: "OpenRouter" }, 40 | 41 | { value: "openai", label: "OpenAI-compatible (Local supported)" }, 42 | ], 43 | }); 44 | 45 | if (p.isCancel(provider)) { 46 | p.cancel("Operation cancelled."); 47 | Deno.exit(0); 48 | } 49 | 50 | return provider as string; 51 | } 52 | 53 | /** 54 | * Prompt user to select or enter a model name 55 | */ 56 | export async function promptForModel(provider: string): Promise { 57 | const choices = 58 | MODEL_CHOICES[provider as keyof typeof MODEL_CHOICES] || 59 | MODEL_CHOICES.openrouter; 60 | 61 | const model = await p.select({ 62 | message: "Select a model:", 63 | options: choices, 64 | }); 65 | 66 | if (p.isCancel(model)) { 67 | p.cancel("Operation cancelled."); 68 | Deno.exit(0); 69 | } 70 | 71 | if (model === "__CUSTOM__") { 72 | const customModel = await p.text({ 73 | message: "Enter model name:", 74 | placeholder: "e.g., gpt-4o-mini", 75 | }); 76 | 77 | if (p.isCancel(customModel)) { 78 | p.cancel("Operation cancelled."); 79 | Deno.exit(0); 80 | } 81 | 82 | if (!customModel || !customModel.trim()) { 83 | console.log(`\nNo model entered. Using default for ${provider}`); 84 | if (provider === "anthropic") { 85 | return "claude-sonnet-4-5-20250929"; 86 | } else if (provider === "openai") { 87 | return "gpt-4o"; 88 | } else { 89 | return "anthropic/claude-sonnet-4-5"; 90 | } 91 | } 92 | 93 | return customModel.trim(); 94 | } 95 | 96 | return model as string; 97 | } 98 | 99 | /** 100 | * Prompt user to select tools via checkbox interface 101 | */ 102 | export async function promptForTools(defaultTools?: Tool[]): Promise { 103 | const defaultNames = new Set( 104 | defaultTools?.map((t) => t.name) || DEFAULT_TOOL_NAMES 105 | ); 106 | 107 | const options = ALL_TOOLS.map((tool) => { 108 | let description = tool.description || "No description"; 109 | if (description.length > 80) { 110 | description = description.slice(0, 77) + "..."; 111 | } 112 | 113 | return { 114 | value: tool.name, 115 | label: `${tool.name}: ${description}`, 116 | hint: defaultNames.has(tool.name) ? "default" : undefined, 117 | }; 118 | }); 119 | 120 | const selectedNames = await p.multiselect({ 121 | message: "Select tools to enable (Space to toggle, Enter to confirm):", 122 | options, 123 | initialValues: Array.from(defaultNames), 124 | }); 125 | 126 | if (p.isCancel(selectedNames)) { 127 | p.cancel("Operation cancelled."); 128 | Deno.exit(0); 129 | } 130 | 131 | if (!selectedNames || selectedNames.length === 0) { 132 | console.log( 133 | "\n⚠️ Warning: No tools selected. The assistant will have limited capabilities." 134 | ); 135 | const proceed = await p.confirm({ 136 | message: "Continue anyway?", 137 | initialValue: false, 138 | }); 139 | 140 | if (p.isCancel(proceed)) { 141 | p.cancel("Operation cancelled."); 142 | Deno.exit(0); 143 | } 144 | 145 | if (!proceed) { 146 | console.log("Please select at least one tool."); 147 | return promptForTools(defaultTools); 148 | } 149 | } 150 | 151 | return ALL_TOOLS.filter((t) => (selectedNames as string[]).includes(t.name)); 152 | } 153 | 154 | /** 155 | * Prompt user to select an initial prompt file from the prompts directory 156 | */ 157 | export async function promptForInitialPrompt(): Promise { 158 | const promptsDir = "./prompts"; 159 | 160 | try { 161 | // Check if prompts directory exists 162 | const dirInfo = await Deno.stat(promptsDir); 163 | if (!dirInfo.isDirectory) { 164 | console.log( 165 | `\n⚠️ '${promptsDir}' is not a directory. Skipping prompt selection.` 166 | ); 167 | return null; 168 | } 169 | } catch { 170 | console.log( 171 | `\n⚠️ Prompts directory '${promptsDir}' not found. Skipping prompt selection.` 172 | ); 173 | return null; 174 | } 175 | 176 | // Find all .txt files 177 | const promptFiles: string[] = []; 178 | for await (const entry of Deno.readDir(promptsDir)) { 179 | if (entry.isFile && entry.name.endsWith(".txt")) { 180 | promptFiles.push(`${promptsDir}/${entry.name}`); 181 | } 182 | } 183 | 184 | if (promptFiles.length === 0) { 185 | console.log( 186 | `\n⚠️ No .txt files found in '${promptsDir}' directory. Skipping prompt selection.` 187 | ); 188 | return null; 189 | } 190 | 191 | // Sort files alphabetically 192 | promptFiles.sort(); 193 | 194 | const options = promptFiles.map((path) => { 195 | const name = path.split("/").pop()?.replace(".txt", "") || path; 196 | return { value: path, label: name }; 197 | }); 198 | 199 | options.push({ value: "__SKIP__", label: "Skip (no initial prompt)" }); 200 | 201 | const selected = await p.select({ 202 | message: "Select an initial prompt (or skip):", 203 | options, 204 | }); 205 | 206 | if (p.isCancel(selected) || selected === "__SKIP__") { 207 | return null; 208 | } 209 | 210 | return selected as string; 211 | } 212 | 213 | /** 214 | * Validate that the required API key exists for the provider 215 | */ 216 | export function validateApiKey(provider: string): void { 217 | let key: string | undefined; 218 | let keyName: string; 219 | 220 | if (provider === "anthropic") { 221 | key = Deno.env.get("ANTHROPIC_API_KEY"); 222 | keyName = "ANTHROPIC_API_KEY"; 223 | } else if (provider === "openrouter") { 224 | key = Deno.env.get("OPENROUTER_API_KEY") || Deno.env.get("OPENAI_API_KEY"); 225 | keyName = "OPENROUTER_API_KEY"; 226 | } else if (provider === "openai") { 227 | key = Deno.env.get("OPENAI_API_KEY"); 228 | keyName = "OPENAI_API_KEY"; 229 | } else { 230 | return; 231 | } 232 | 233 | if (!key) { 234 | p.cancel(`❌ ERROR: ${keyName} not found in environment`); 235 | console.log(`Please add to .env.local: ${keyName}=your-key-here`); 236 | Deno.exit(1); 237 | } 238 | } 239 | 240 | /** 241 | * Build tool registry mapping names to handlers for selected tools 242 | */ 243 | export function buildToolRegistry( 244 | selectedTools: Tool[] 245 | ): Record { 246 | const selectedNames = selectedTools.map((t) => t.name); 247 | const registry: Record = {}; 248 | 249 | for (const [name, handler] of Object.entries(ALL_TOOL_HANDLERS)) { 250 | if (selectedNames.includes(name)) { 251 | registry[name] = handler; 252 | } 253 | } 254 | 255 | return registry; 256 | } 257 | -------------------------------------------------------------------------------- /environments/portalbench/src/ui/level-navigation.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | EntityRef, 4 | LocalRoot, 5 | PlayerJoined, 6 | UIBehavior, 7 | value, 8 | Vector2, 9 | } from "@dreamlab/engine"; 10 | import PlayerMetrics from "../player/metrics.ts"; 11 | import PlayerMovement from "../player/movement.ts"; 12 | 13 | type GameType = "KeysAndDoors" | "Portals" | "PushableBlocks" | "Sokoban" | "Bombs" | null; 14 | 15 | interface GameConfig { 16 | maxLevels: number; 17 | displayName: string; 18 | hasLevelStart: boolean; 19 | } 20 | 21 | const GAME_CONFIGS: Record = { 22 | KeysAndDoors: { maxLevels: 2, displayName: "🔑 Keys & Doors", hasLevelStart: true }, 23 | Portals: { maxLevels: 3, displayName: "🌀 Portals", hasLevelStart: true }, 24 | PushableBlocks: { maxLevels: 2, displayName: "📦 Pushable Blocks", hasLevelStart: true }, 25 | Sokoban: { maxLevels: 8, displayName: "🎯 Sokoban", hasLevelStart: false }, 26 | Bombs: { maxLevels: 2, displayName: "💣 Bombs", hasLevelStart: true }, 27 | }; 28 | 29 | export default class GameLevelNavigationUI extends UIBehavior { 30 | @value({ type: EntityRef }) 31 | player: Entity | undefined; 32 | 33 | private currentLevel = 1; 34 | private currentGame: GameType = null; 35 | private isInGameArea = false; 36 | 37 | override onInitialize() { 38 | super.onInitialize(); 39 | if (!this.game.isClient()) return; 40 | 41 | this.findLocalPlayer(); 42 | 43 | this.listen(this.game, PlayerJoined, () => { 44 | this.findLocalPlayer(); 45 | }); 46 | 47 | this.onTick = () => { 48 | if (!this.player) return; 49 | 50 | const wasInGame = this.isInGameArea; 51 | const prevGame = this.currentGame; 52 | const prevLevel = this.currentLevel; 53 | 54 | const { gameType, level, inArea } = this.detectCurrentGameAndLevel(); 55 | 56 | if (wasInGame !== inArea || prevGame !== gameType || prevLevel !== level) { 57 | this.currentGame = gameType; 58 | this.currentLevel = level; 59 | this.isInGameArea = inArea; 60 | this.rerender(); 61 | } 62 | }; 63 | } 64 | 65 | private findLocalPlayer() { 66 | const players = this.game.world.entities.lookupByBehavior(PlayerMovement); 67 | 68 | const localPlayer = players.find(e => 69 | e.root instanceof LocalRoot 70 | && !e.name.includes("Puppet") 71 | ); 72 | 73 | if (localPlayer && !this.player) { 74 | this.player = localPlayer; 75 | } 76 | } 77 | 78 | private detectCurrentGameAndLevel(): { gameType: GameType; level: number; inArea: boolean } { 79 | if (!this.player) { 80 | return { gameType: null, level: 1, inArea: false }; 81 | } 82 | 83 | let globalClosestGame: GameType = null; 84 | let globalClosestLevel = 1; 85 | let globalClosestDistance = Infinity; 86 | 87 | for (const [gameName, config] of Object.entries(GAME_CONFIGS)) { 88 | const gameEntity = this.game.world._[gameName]; 89 | if (!gameEntity) { 90 | continue; 91 | } 92 | 93 | for (let i = 1; i <= config.maxLevels; i++) { 94 | const levelEntity = gameEntity._["Level" + i]; 95 | if (!levelEntity) { 96 | continue; 97 | } 98 | 99 | let refPos = levelEntity.pos; 100 | if (config.hasLevelStart && levelEntity._.LevelStart) { 101 | refPos = levelEntity._.LevelStart.pos; 102 | } 103 | 104 | const distance = Vector2.distance(this.player.pos, refPos); 105 | 106 | if (distance < globalClosestDistance) { 107 | globalClosestDistance = distance; 108 | globalClosestLevel = i; 109 | globalClosestGame = gameName as GameType; 110 | } 111 | } 112 | } 113 | 114 | if (globalClosestDistance < 50) { 115 | return { gameType: globalClosestGame, level: globalClosestLevel, inArea: true }; 116 | } 117 | 118 | return { gameType: this.currentGame, level: this.currentLevel, inArea: false }; 119 | } 120 | 121 | private navigateToLevel(levelNumber: number) { 122 | if (!this.player || !this.currentGame || levelNumber < 1) return; 123 | 124 | const config = GAME_CONFIGS[this.currentGame]; 125 | if (!config || levelNumber > config.maxLevels) return; 126 | 127 | const playerMovement = this.player.getBehaviorIfExists(PlayerMovement); 128 | if (!playerMovement) return; 129 | 130 | const gameEntity = this.game.world._[this.currentGame]; 131 | if (!gameEntity) return; 132 | 133 | const levelEntity = gameEntity._["Level" + levelNumber]; 134 | if (!levelEntity) return; 135 | 136 | let targetPos = levelEntity.pos; 137 | if (config.hasLevelStart && levelEntity._.LevelStart) { 138 | targetPos = levelEntity._.LevelStart.pos; 139 | } 140 | 141 | playerMovement.requestTeleport(targetPos.x, targetPos.y); 142 | 143 | const metrics = this.player.getBehaviorIfExists(PlayerMetrics); 144 | if (metrics) { 145 | metrics.reset(); 146 | } 147 | 148 | this.currentLevel = levelNumber; 149 | this.rerender(); 150 | } 151 | 152 | private handlePrevious = () => { 153 | if (this.currentLevel > 1) { 154 | this.navigateToLevel(this.currentLevel - 1); 155 | } 156 | }; 157 | 158 | private handleNext = () => { 159 | if (!this.currentGame) return; 160 | const config = GAME_CONFIGS[this.currentGame]; 161 | if (this.currentLevel < config.maxLevels) { 162 | this.navigateToLevel(this.currentLevel + 1); 163 | } 164 | }; 165 | 166 | override render() { 167 | if (!this.player || !this.isInGameArea || !this.currentGame) { 168 | return
; 169 | } 170 | 171 | const config = GAME_CONFIGS[this.currentGame]; 172 | 173 | const isPreviousDisabled = this.currentLevel <= 1; 174 | const isNextDisabled = this.currentLevel >= config.maxLevels; 175 | 176 | return ( 177 |
195 | 226 | 227 |
239 |
240 | {config.displayName} 241 |
242 |
LEVEL {this.currentLevel}/{config.maxLevels}
243 |
244 | 245 | 276 |
277 | ); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /environments/openmonsters/src/ui/level-navigation.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | EntityRef, 4 | LocalRoot, 5 | PlayerJoined, 6 | UIBehavior, 7 | value, 8 | Vector2, 9 | } from "@dreamlab/engine"; 10 | import PlayerMetrics from "../player/metrics.ts"; 11 | import PlayerMovement from "../player/movement.ts"; 12 | 13 | type GameType = "KeysAndDoors" | "Portals" | "PushableBlocks" | "Sokoban" | "Bombs" | null; 14 | 15 | interface GameConfig { 16 | maxLevels: number; 17 | displayName: string; 18 | hasLevelStart: boolean; 19 | } 20 | 21 | const GAME_CONFIGS: Record = { 22 | KeysAndDoors: { maxLevels: 2, displayName: "🔑 Keys & Doors", hasLevelStart: true }, 23 | Portals: { maxLevels: 3, displayName: "🌀 Portals", hasLevelStart: true }, 24 | PushableBlocks: { maxLevels: 2, displayName: "📦 Pushable Blocks", hasLevelStart: true }, 25 | Sokoban: { maxLevels: 8, displayName: "🎯 Sokoban", hasLevelStart: false }, 26 | Bombs: { maxLevels: 2, displayName: "💣 Bombs", hasLevelStart: true }, 27 | }; 28 | 29 | export default class GameLevelNavigationUI extends UIBehavior { 30 | @value({ type: EntityRef }) 31 | player: Entity | undefined; 32 | 33 | private currentLevel = 1; 34 | private currentGame: GameType = null; 35 | private isInGameArea = false; 36 | 37 | override onInitialize() { 38 | super.onInitialize(); 39 | if (!this.game.isClient()) return; 40 | 41 | this.findLocalPlayer(); 42 | 43 | this.listen(this.game, PlayerJoined, () => { 44 | this.findLocalPlayer(); 45 | }); 46 | 47 | this.onTick = () => { 48 | if (!this.player) return; 49 | 50 | const wasInGame = this.isInGameArea; 51 | const prevGame = this.currentGame; 52 | const prevLevel = this.currentLevel; 53 | 54 | const { gameType, level, inArea } = this.detectCurrentGameAndLevel(); 55 | 56 | if (wasInGame !== inArea || prevGame !== gameType || prevLevel !== level) { 57 | this.currentGame = gameType; 58 | this.currentLevel = level; 59 | this.isInGameArea = inArea; 60 | this.rerender(); 61 | } 62 | }; 63 | } 64 | 65 | private findLocalPlayer() { 66 | const players = this.game.world.entities.lookupByBehavior(PlayerMovement); 67 | 68 | const localPlayer = players.find(e => 69 | e.root instanceof LocalRoot 70 | && !e.name.includes("Puppet") 71 | ); 72 | 73 | if (localPlayer && !this.player) { 74 | this.player = localPlayer; 75 | } 76 | } 77 | 78 | private detectCurrentGameAndLevel(): { gameType: GameType; level: number; inArea: boolean } { 79 | if (!this.player) { 80 | return { gameType: null, level: 1, inArea: false }; 81 | } 82 | 83 | let globalClosestGame: GameType = null; 84 | let globalClosestLevel = 1; 85 | let globalClosestDistance = Infinity; 86 | 87 | for (const [gameName, config] of Object.entries(GAME_CONFIGS)) { 88 | const gameEntity = this.game.world._[gameName]; 89 | if (!gameEntity) { 90 | continue; 91 | } 92 | 93 | for (let i = 1; i <= config.maxLevels; i++) { 94 | const levelEntity = gameEntity._["Level" + i]; 95 | if (!levelEntity) { 96 | continue; 97 | } 98 | 99 | let refPos = levelEntity.pos; 100 | if (config.hasLevelStart && levelEntity._.LevelStart) { 101 | refPos = levelEntity._.LevelStart.pos; 102 | } 103 | 104 | const distance = Vector2.distance(this.player.pos, refPos); 105 | 106 | if (distance < globalClosestDistance) { 107 | globalClosestDistance = distance; 108 | globalClosestLevel = i; 109 | globalClosestGame = gameName as GameType; 110 | } 111 | } 112 | } 113 | 114 | if (globalClosestDistance < 50) { 115 | return { gameType: globalClosestGame, level: globalClosestLevel, inArea: true }; 116 | } 117 | 118 | return { gameType: this.currentGame, level: this.currentLevel, inArea: false }; 119 | } 120 | 121 | private navigateToLevel(levelNumber: number) { 122 | if (!this.player || !this.currentGame || levelNumber < 1) return; 123 | 124 | const config = GAME_CONFIGS[this.currentGame]; 125 | if (!config || levelNumber > config.maxLevels) return; 126 | 127 | const playerMovement = this.player.getBehaviorIfExists(PlayerMovement); 128 | if (!playerMovement) return; 129 | 130 | const gameEntity = this.game.world._[this.currentGame]; 131 | if (!gameEntity) return; 132 | 133 | const levelEntity = gameEntity._["Level" + levelNumber]; 134 | if (!levelEntity) return; 135 | 136 | let targetPos = levelEntity.pos; 137 | if (config.hasLevelStart && levelEntity._.LevelStart) { 138 | targetPos = levelEntity._.LevelStart.pos; 139 | } 140 | 141 | playerMovement.requestTeleport(targetPos.x, targetPos.y); 142 | 143 | const metrics = this.player.getBehaviorIfExists(PlayerMetrics); 144 | if (metrics) { 145 | metrics.reset(); 146 | } 147 | 148 | this.currentLevel = levelNumber; 149 | this.rerender(); 150 | } 151 | 152 | private handlePrevious = () => { 153 | if (this.currentLevel > 1) { 154 | this.navigateToLevel(this.currentLevel - 1); 155 | } 156 | }; 157 | 158 | private handleNext = () => { 159 | if (!this.currentGame) return; 160 | const config = GAME_CONFIGS[this.currentGame]; 161 | if (this.currentLevel < config.maxLevels) { 162 | this.navigateToLevel(this.currentLevel + 1); 163 | } 164 | }; 165 | 166 | override render() { 167 | if (!this.player || !this.isInGameArea || !this.currentGame) { 168 | return
; 169 | } 170 | 171 | const config = GAME_CONFIGS[this.currentGame]; 172 | 173 | const isPreviousDisabled = this.currentLevel <= 1; 174 | const isNextDisabled = this.currentLevel >= config.maxLevels; 175 | 176 | return ( 177 |
195 | 226 | 227 |
239 |
240 | {config.displayName} 241 |
242 |
LEVEL {this.currentLevel}/{config.maxLevels}
243 |
244 | 245 | 276 |
277 | ); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /harness/src/llm/openai-wrapper.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import type { Message, Tool, MessageContent, ToolUseContent } from "./types.ts"; 3 | import { LLMWrapper } from "./wrapper.ts"; 4 | 5 | /** 6 | * OpenAI-compatible API wrapper. 7 | * By default, it connects to OpenRouter which provides access to many models. 8 | */ 9 | export class OpenAIWrapper extends LLMWrapper { 10 | private client: OpenAI; 11 | private model: string; 12 | private _finalMessage: Message | null = null; 13 | 14 | constructor( 15 | apiKey: string, 16 | model: string = "gpt-4o", 17 | baseURL: string = "https://openrouter.ai/api/v1" 18 | ) { 19 | super(); 20 | this.client = new OpenAI({ apiKey, baseURL }); 21 | this.model = model; 22 | } 23 | 24 | /** 25 | * Convert our Anthropic-style messages to OpenAI format. 26 | */ 27 | private _convertMessagesToOpenAI( 28 | messages: Message[] 29 | ): Array> { 30 | const openaiMessages: Array> = []; 31 | 32 | for (const msg of messages) { 33 | if (typeof msg.content === "string") { 34 | // Simple text message 35 | const openaiMsg: Record = { 36 | role: msg.role, 37 | content: msg.content, 38 | }; 39 | if (msg.reasoning_details) { 40 | openaiMsg.reasoning_details = msg.reasoning_details; 41 | } 42 | openaiMessages.push(openaiMsg); 43 | } else { 44 | // Complex message with content blocks 45 | const openaiContent: Array> = []; 46 | const toolCalls: Array> = []; 47 | 48 | for (const contentBlock of msg.content) { 49 | if (contentBlock.type === "text") { 50 | openaiContent.push({ 51 | type: "text", 52 | text: contentBlock.text, 53 | }); 54 | } else if (contentBlock.type === "tool_use") { 55 | // Convert to OpenAI tool_calls format 56 | const args = 57 | typeof contentBlock.input === "object" 58 | ? JSON.stringify(contentBlock.input) 59 | : contentBlock.input; 60 | toolCalls.push({ 61 | id: contentBlock.id, 62 | type: "function", 63 | function: { 64 | name: contentBlock.name, 65 | arguments: args, 66 | }, 67 | }); 68 | } else if (contentBlock.type === "tool_result") { 69 | // Convert to separate tool role message 70 | openaiMessages.push({ 71 | role: "tool", 72 | tool_call_id: contentBlock.tool_use_id, 73 | content: contentBlock.content, 74 | }); 75 | } 76 | } 77 | 78 | // Add assistant message with tool calls 79 | if (toolCalls.length > 0) { 80 | const assistantMsg: Record = { 81 | role: "assistant", 82 | tool_calls: toolCalls, 83 | }; 84 | if (msg.reasoning_details) { 85 | assistantMsg.reasoning_details = msg.reasoning_details; 86 | } 87 | openaiMessages.push(assistantMsg); 88 | } else if (openaiContent.length > 0) { 89 | // Text content only 90 | const combinedText = 91 | openaiContent.length === 1 92 | ? (openaiContent[0].text as string) 93 | : openaiContent.map((block) => block.text).join("\n"); 94 | const assistantMsg: Record = { 95 | role: msg.role, 96 | content: combinedText, 97 | }; 98 | if (msg.reasoning_details) { 99 | assistantMsg.reasoning_details = msg.reasoning_details; 100 | } 101 | openaiMessages.push(assistantMsg); 102 | } 103 | } 104 | } 105 | 106 | return openaiMessages; 107 | } 108 | 109 | /** 110 | * Convert our tool format to OpenAI's format. 111 | */ 112 | private _convertToolsToOpenAI( 113 | tools: Tool[] | undefined 114 | ): Array> | undefined { 115 | if (!tools || tools.length === 0) { 116 | return undefined; 117 | } 118 | 119 | return tools.map((tool) => ({ 120 | type: "function", 121 | function: { 122 | name: tool.name, 123 | description: tool.description || "", 124 | parameters: tool.input_schema || {}, 125 | }, 126 | })); 127 | } 128 | 129 | /** 130 | * Stream text responses from OpenAI's API. 131 | */ 132 | async *streamMessages( 133 | messages: Message[], 134 | options: { 135 | maxTokens: number; 136 | temperature?: number; 137 | tools?: Tool[]; 138 | } 139 | ): AsyncGenerator { 140 | const openaiMessages = this._convertMessagesToOpenAI(messages); 141 | const openaiTools = this._convertToolsToOpenAI(options.tools); 142 | 143 | const streamKwargs: Record = { 144 | model: this.model, 145 | max_tokens: options.maxTokens, 146 | temperature: options.temperature ?? 1.0, 147 | messages: openaiMessages, 148 | stream: true, 149 | }; 150 | 151 | if (openaiTools) { 152 | streamKwargs.tools = openaiTools; 153 | // Disable parallel tool calls - some models handle them poorly 154 | streamKwargs.parallel_tool_calls = false; 155 | } 156 | 157 | // Enable reasoning tokens for models that support it (OpenRouter-specific) 158 | if (this.client.baseURL?.includes("openrouter.ai")) { 159 | streamKwargs.extra_body = { include_reasoning: true }; 160 | } 161 | 162 | const stream = await this.client.chat.completions.create( 163 | streamKwargs as OpenAI.ChatCompletionCreateParamsStreaming 164 | ); 165 | 166 | const accumulatedContent: string[] = []; 167 | const accumulatedReasoning: string[] = []; 168 | const accumulatedToolCalls: Record< 169 | number, 170 | { id: string; name: string; arguments: string } 171 | > = {}; 172 | const accumulatedReasoningDetails: Array> = []; 173 | let role: string | null = null; 174 | 175 | for await (const chunk of stream) { 176 | if (chunk.choices && chunk.choices.length > 0) { 177 | const delta = chunk.choices[0].delta; 178 | 179 | if (delta.role) { 180 | role = delta.role; 181 | } 182 | 183 | // Handle reasoning/thinking tokens (OpenRouter DeepSeek R1, Gemini Thinking, etc.) 184 | if ("reasoning" in delta && delta.reasoning) { 185 | accumulatedReasoning.push(delta.reasoning as string); 186 | yield delta.reasoning as string; 187 | } 188 | 189 | if (delta.content) { 190 | accumulatedContent.push(delta.content); 191 | yield delta.content; 192 | } 193 | 194 | if ("reasoning_details" in delta && delta.reasoning_details) { 195 | const details = delta.reasoning_details as Array>; 196 | for (const detail of details) { 197 | accumulatedReasoningDetails.push(detail); 198 | } 199 | } 200 | 201 | if (delta.tool_calls) { 202 | for (const toolCall of delta.tool_calls) { 203 | const idx = toolCall.index ?? 0; 204 | if (!(idx in accumulatedToolCalls)) { 205 | accumulatedToolCalls[idx] = { 206 | id: "", 207 | name: "", 208 | arguments: "", 209 | }; 210 | } 211 | 212 | if (toolCall.id) { 213 | accumulatedToolCalls[idx].id = toolCall.id; 214 | } 215 | if (toolCall.function?.name) { 216 | accumulatedToolCalls[idx].name = toolCall.function.name; 217 | } 218 | if (toolCall.function?.arguments) { 219 | accumulatedToolCalls[idx].arguments += 220 | toolCall.function.arguments; 221 | } 222 | } 223 | } 224 | } 225 | } 226 | 227 | // Build final message in Anthropic format 228 | const finalContent: MessageContent[] = []; 229 | 230 | // Note: We don't include reasoning in the message history as it can be very verbose 231 | // and is typically not needed in conversation context 232 | 233 | if (accumulatedContent.length > 0) { 234 | finalContent.push({ 235 | type: "text", 236 | text: accumulatedContent.join(""), 237 | }); 238 | } 239 | 240 | if (Object.keys(accumulatedToolCalls).length > 0) { 241 | for (const toolCall of Object.values(accumulatedToolCalls)) { 242 | let parsedArgs: Record; 243 | try { 244 | parsedArgs = JSON.parse(toolCall.arguments); 245 | } catch { 246 | parsedArgs = { raw: toolCall.arguments }; 247 | } 248 | 249 | finalContent.push({ 250 | type: "tool_use", 251 | id: toolCall.id, 252 | name: toolCall.name, 253 | input: parsedArgs, 254 | } as ToolUseContent); 255 | } 256 | } 257 | 258 | this._finalMessage = { 259 | role: (role as "user" | "assistant" | "tool") || "assistant", 260 | content: finalContent.length > 0 ? finalContent : [], 261 | }; 262 | 263 | if (accumulatedReasoningDetails.length > 0) { 264 | this._finalMessage.reasoning_details = accumulatedReasoningDetails; 265 | } 266 | } 267 | 268 | /** 269 | * Get the final message from the current stream. 270 | */ 271 | getFinalMessage(): Message { 272 | if (this._finalMessage === null) { 273 | throw new Error( 274 | "No final message available - stream may not have completed" 275 | ); 276 | } 277 | return this._finalMessage; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /environments/portalbench/src/mechanics/portal-manager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Behavior, 3 | EntityRef, 4 | IVector2, 5 | Tilemap, 6 | value, 7 | Vector2, 8 | } from "@dreamlab/engine"; 9 | import { Colors } from "../../lib/colors.ts"; 10 | import { PlayerMoved } from "../player/movement.ts"; 11 | import ParticleRender from "../effects/particle.ts"; 12 | 13 | type PortalData = { 14 | position: Vector2; 15 | originalTileColor: number; 16 | }; 17 | 18 | const PLAYER_COLORS = [ 19 | [Colors.BluePortal, Colors.OrangePortal], // Player 1 20 | [0xff00ff, 0x00ffff], // Player 2: Magenta/Cyan 21 | [0xffff00, 0x00ff00], // Player 3: Yellow/Lime 22 | [0xff0080, 0x0080ff], // Player 4: Pink/Sky Blue 23 | ]; 24 | 25 | export default class PortalManager extends Behavior { 26 | static instance: PortalManager | undefined; 27 | 28 | @value({ type: EntityRef }) 29 | tilemap: Tilemap | undefined; 30 | 31 | private playerPortals: Map< 32 | string, 33 | { blue?: PortalData; orange?: PortalData } 34 | > = new Map(); 35 | 36 | private playerColorIndex: Map = new Map(); 37 | private nextPlayerIndex = 0; 38 | 39 | onInitialize(): void { 40 | if (!this.game.isServer()) return; 41 | PortalManager.instance = this; 42 | 43 | this.listen(this.game, PlayerMoved, (event) => { 44 | this.handlePlayerMovement(event); 45 | }); 46 | } 47 | 48 | onDestroy(): void { 49 | if (PortalManager.instance === this) { 50 | PortalManager.instance = undefined; 51 | } 52 | } 53 | 54 | placePortal( 55 | portalColor: "blue" | "orange", 56 | targetTile: IVector2, 57 | playerPos: IVector2, 58 | playerRef: string 59 | ): { success: boolean; error?: string; position?: IVector2 } { 60 | if (!this.tilemap) { 61 | return { success: false, error: "Tilemap not found" }; 62 | } 63 | 64 | const tileColor = this.tilemap.getColor(targetTile.x, targetTile.y); 65 | 66 | if ( 67 | tileColor !== Colors.Wall && 68 | tileColor !== Colors.BombWall && 69 | tileColor !== Colors.GlassWall 70 | ) { 71 | return { 72 | success: false, 73 | error: "Can only place portals on wall", 74 | }; 75 | } 76 | 77 | if (!this.isInLineOfSight(playerPos, targetTile)) { 78 | return { success: false, error: "Target not in line of sight" }; 79 | } 80 | 81 | const dx = targetTile.x - playerPos.x; 82 | const dy = targetTile.y - playerPos.y; 83 | const distance = Math.sqrt(dx * dx + dy * dy); 84 | 85 | if (distance === 0) { 86 | return { 87 | success: false, 88 | error: "Cannot place portal at player position", 89 | }; 90 | } 91 | 92 | const steps = Math.ceil(distance * 4); 93 | const checkedTiles = new Set(); 94 | let portalX = Math.round(playerPos.x); 95 | let portalY = Math.round(playerPos.y); 96 | let portalTileColor: number | undefined; 97 | 98 | for (let i = 1; i <= steps; i++) { 99 | const t = i / steps; 100 | const worldX = playerPos.x + dx * t; 101 | const worldY = playerPos.y + dy * t; 102 | 103 | const x = Math.round(worldX); 104 | const y = Math.round(worldY); 105 | 106 | const key = `${x},${y}`; 107 | if (checkedTiles.has(key)) continue; 108 | checkedTiles.add(key); 109 | 110 | const color = this.tilemap.getColor(x, y); 111 | 112 | if (x === targetTile.x && y === targetTile.y) { 113 | break; 114 | } 115 | 116 | if (color === Colors.Wall || color === Colors.BombWall) { 117 | break; 118 | } 119 | 120 | if (color === Colors.GlassWall) { 121 | continue; 122 | } 123 | 124 | portalX = x; 125 | portalY = y; 126 | portalTileColor = color; 127 | } 128 | 129 | if (portalTileColor === undefined) { 130 | const distToWall = 131 | Math.abs(targetTile.x - portalX) + Math.abs(targetTile.y - portalY); 132 | if (distToWall <= 1) { 133 | portalTileColor = this.tilemap.getColor(portalX, portalY); 134 | } else { 135 | return { success: false, error: "No valid space in front of wall" }; 136 | } 137 | } 138 | 139 | if (!this.playerPortals.has(playerRef)) { 140 | this.playerPortals.set(playerRef, {}); 141 | } 142 | const portals = this.playerPortals.get(playerRef)!; 143 | 144 | if (portalColor === "blue" && portals.blue) { 145 | this.removePortal("blue", playerRef); 146 | } else if (portalColor === "orange" && portals.orange) { 147 | this.removePortal("orange", playerRef); 148 | } 149 | 150 | for (const [otherPlayerRef, otherPortals] of this.playerPortals.entries()) { 151 | if ( 152 | otherPortals.blue?.position.x === portalX && 153 | otherPortals.blue?.position.y === portalY 154 | ) { 155 | this.removePortal("blue", otherPlayerRef); 156 | } 157 | if ( 158 | otherPortals.orange?.position.x === portalX && 159 | otherPortals.orange?.position.y === portalY 160 | ) { 161 | this.removePortal("orange", otherPlayerRef); 162 | } 163 | } 164 | 165 | portalTileColor = this.tilemap.getColor(portalX, portalY); 166 | 167 | if ( 168 | portalTileColor === Colors.BluePortal || 169 | portalTileColor === Colors.OrangePortal 170 | ) { 171 | portalTileColor = Colors.Grass; 172 | } 173 | 174 | const portalData: PortalData = { 175 | position: new Vector2(portalX, portalY), 176 | originalTileColor: portalTileColor, 177 | }; 178 | 179 | // Get player color index 180 | if (!this.playerColorIndex.has(playerRef)) { 181 | this.playerColorIndex.set(playerRef, this.nextPlayerIndex % PLAYER_COLORS.length); 182 | this.nextPlayerIndex++; 183 | } 184 | const colorIdx = this.playerColorIndex.get(playerRef)!; 185 | const playerColors = PLAYER_COLORS[colorIdx]; 186 | const color = portalColor === "blue" ? playerColors[0] : playerColors[1]; 187 | 188 | if (portalColor === "blue") { 189 | portals.blue = portalData; 190 | this.tilemap.setColor(portalX, portalY, color); 191 | } else { 192 | portals.orange = portalData; 193 | this.tilemap.setColor(portalX, portalY, color); 194 | } 195 | 196 | this.game.prefabs._.ParticleContainer.cloneInto(this.game.world, { 197 | transform: { position: { x: portalX, y: portalY } }, 198 | behaviors: [ 199 | { 200 | type: ParticleRender, 201 | values: { 202 | particleColor: color, 203 | }, 204 | }, 205 | ], 206 | }); 207 | 208 | return { success: true, position: { x: portalX, y: portalY } }; 209 | } 210 | 211 | private isInLineOfSight(from: IVector2, to: IVector2): boolean { 212 | if (!this.tilemap) return false; 213 | 214 | const dx = to.x - from.x; 215 | const dy = to.y - from.y; 216 | const distance = Math.sqrt(dx * dx + dy * dy); 217 | 218 | if (distance === 0) return true; 219 | 220 | const steps = Math.ceil(distance * 4); 221 | 222 | const checkedTiles = new Set(); 223 | let reachedTarget = false; 224 | 225 | for (let i = 1; i <= steps; i++) { 226 | const t = i / steps; 227 | const worldX = from.x + dx * t; 228 | const worldY = from.y + dy * t; 229 | 230 | const worldDistToTarget = Math.sqrt( 231 | Math.pow(worldX - to.x, 2) + Math.pow(worldY - to.y, 2) 232 | ); 233 | 234 | const x = Math.round(worldX); 235 | const y = Math.round(worldY); 236 | 237 | const key = `${x},${y}`; 238 | if (checkedTiles.has(key)) continue; 239 | checkedTiles.add(key); 240 | 241 | if (x === from.x && y === from.y) continue; 242 | 243 | if (x === to.x && y === to.y) { 244 | reachedTarget = true; 245 | break; 246 | } 247 | 248 | if (worldDistToTarget < 0.6) { 249 | reachedTarget = true; 250 | break; 251 | } 252 | 253 | const tileColor = this.tilemap.getColor(x, y); 254 | 255 | if ( 256 | tileColor === Colors.Wall || 257 | tileColor === Colors.BombWall || 258 | tileColor === Colors.GreenDoor || 259 | tileColor === Colors.BlueDoor 260 | ) { 261 | return false; 262 | } 263 | } 264 | 265 | if (reachedTarget) { 266 | return true; 267 | } 268 | 269 | return false; 270 | } 271 | 272 | removePortal(portalColor: "blue" | "orange", playerRef: string): void { 273 | if (!this.tilemap) return; 274 | 275 | const portals = this.playerPortals.get(playerRef); 276 | if (!portals) return; 277 | 278 | const portal = portalColor === "blue" ? portals.blue : portals.orange; 279 | if (!portal) return; 280 | 281 | this.tilemap.setColor( 282 | portal.position.x, 283 | portal.position.y, 284 | portal.originalTileColor 285 | ); 286 | 287 | if (portalColor === "blue") { 288 | portals.blue = undefined; 289 | } else { 290 | portals.orange = undefined; 291 | } 292 | } 293 | 294 | clearAllPortals(playerRef?: string): void { 295 | if (playerRef) { 296 | this.removePortal("blue", playerRef); 297 | this.removePortal("orange", playerRef); 298 | } else { 299 | for (const [pRef] of this.playerPortals.entries()) { 300 | this.removePortal("blue", pRef); 301 | this.removePortal("orange", pRef); 302 | } 303 | } 304 | } 305 | 306 | private handlePlayerMovement(event: PlayerMoved): void { 307 | if (!this.tilemap) return; 308 | 309 | const playerPos = event.position; 310 | 311 | for (const [, portals] of this.playerPortals.entries()) { 312 | if (!portals.blue || !portals.orange) continue; 313 | 314 | if ( 315 | playerPos.x === portals.blue.position.x && 316 | playerPos.y === portals.blue.position.y 317 | ) { 318 | event.teleport = portals.orange.position.clone(); 319 | return; 320 | } 321 | 322 | if ( 323 | playerPos.x === portals.orange.position.x && 324 | playerPos.y === portals.orange.position.y 325 | ) { 326 | event.teleport = portals.blue.position.clone(); 327 | return; 328 | } 329 | } 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /environments/openmonsters/scene-description.md: -------------------------------------------------------------------------------- 1 | The following is a description of the current scene in a compact format. (posX, posY, scaleX, scaleY). All child positions and scale are relative to parent. 2 | 3 | world: 4 | - TileActionManager (Empty) (-1.7, 0.04, 1, 1) 5 | - src/tiles/action-manager.ts 6 | - Tilemap (Tilemap) (0, 0, 1, 1) 7 | - DEBUG_Win (Empty) (0.03, 2.07, 1, 1) 8 | - src/tiles/dialogue.ts 9 | - src/tiles/teleport.ts 10 | - RoomOffset (Empty) (-3, 4, 1, 1) 11 | - Sokoban (Empty) (40.1, -41.53, 1, 1) 12 | - Level1 (Empty) (-66.48, -46.42, 1, 1) 13 | - Level2 (Empty) (-58.02, -46.58, 1, 1) 14 | - Level3 (Empty) (-45.11, -50.95, 1, 1) 15 | - Level4 (Empty) (-35.79, -47.93, 1, 1) 16 | - Level5 (Empty) (-63.95, -61.73, 1, 1) 17 | - Level6 (Empty) (-56.16, -62.14, 1, 1) 18 | - Level7 (Empty) (-43.21, -62.44, 1, 1) 19 | - Level8 (Empty) (-36.71, -57.43, 1, 1) 20 | - PortalManager (Empty) (0, 0, 1, 1) 21 | - src/mechanics/portal-manager.ts 22 | - KeysAndDoors (Empty) (0, 0, 1, 1) 23 | - Level1 (Empty) (-11.47, -2.19, 1, 1) 24 | - Win (Empty) (-15.79, 1.92, 1, 1) 25 | - src/tiles/dialogue.ts 26 | - src/tiles/teleport.ts 27 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 28 | - DoorHandler (Empty) (-10.33, 1.76, 1, 1) 29 | - src/mechanics/door-handler.ts 30 | - ColorActions (Empty) (-10.35, 1.67, 1, 1) 31 | - src/mechanics/color-action.ts 32 | - LevelStart (Empty) (-15.55, -1.36, 1, 1) 33 | - GreenKey.1 (Empty) (-15.45, -3.89, 1, 1) 34 | - src/collectibles/key.ts 35 | - src/tiles/dialogue.ts 36 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 37 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 38 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 39 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 40 | - BlueKey.1 (Empty) (-9.52, -4.87, 1, 1) 41 | - src/collectibles/key.ts 42 | - src/tiles/dialogue.ts 43 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 44 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 45 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 46 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 47 | - GreenKey.2 (Empty) (-4.51, -4.88, 1, 1) 48 | - src/collectibles/key.ts 49 | - src/tiles/dialogue.ts 50 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 51 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 52 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 53 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 54 | - Level2 (Empty) (-11.47, -2.19, 1, 1) 55 | - Win (Empty) (15.48, -1.83, 1, 1) 56 | - src/tiles/dialogue.ts 57 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 58 | - LevelStart (Empty) (9.5, 2.26, 1, 1) 59 | - GreenKey.1 (Empty) (9.35, 7.14, 1, 1) 60 | - src/collectibles/key.ts 61 | - src/tiles/dialogue.ts 62 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 63 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 64 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 65 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 66 | - BlueKey.1 (Empty) (13.79, 1.47, 1, 1) 67 | - src/collectibles/key.ts 68 | - src/tiles/dialogue.ts 69 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 70 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 71 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 72 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 73 | - GreenKey.2 (Empty) (3.48, 1.31, 1, 1) 74 | - src/collectibles/key.ts 75 | - src/tiles/dialogue.ts 76 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 77 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 78 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 79 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 80 | - BlueKey.2 (Empty) (6.44, 1.24, 1, 1) 81 | - src/collectibles/key.ts 82 | - src/tiles/dialogue.ts 83 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 84 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 85 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 86 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 87 | - RichText (RichText) (-11.82, 9.07, 5, 5) 88 | - Bombs (Empty) (0, 0, 1, 1) 89 | - Level1 (Empty) (-7.01, -25.91, 1, 1) 90 | - Win (Empty) (-13.91, 7.81, 1, 1) 91 | - src/tiles/dialogue.ts 92 | - src/tiles/teleport.ts 93 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 94 | - LevelStart (Empty) (-14.94, -4.06, 1, 1) 95 | - BombPickup (Empty) (-20.83, -4.01, 1, 1) 96 | - src/collectibles/bomb.ts 97 | - src/tiles/dialogue.ts 98 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 99 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 100 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 101 | - BombPickup.1 (Empty) (-9, 1.01, 1, 1) 102 | - src/collectibles/bomb.ts 103 | - src/tiles/dialogue.ts 104 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 105 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 106 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 107 | - BombPickup.2 (Empty) (-21.06, 1, 1, 1) 108 | - src/collectibles/bomb.ts 109 | - src/tiles/dialogue.ts 110 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 111 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 112 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 113 | - RichText (RichText) (-11.75, -14.12, 5, 5) 114 | - Level2 (Empty) (-7.01, -25.91, 1, 1) 115 | - Win (Empty) (-0.01, 5.08, 1, 1) 116 | - src/tiles/dialogue.ts 117 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 118 | - LevelStart (Empty) (2.06, -2.83, 1, 1) 119 | - BombPickup (Empty) (-0.73, -4.04, 1, 1) 120 | - src/collectibles/bomb.ts 121 | - src/tiles/dialogue.ts 122 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 123 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 124 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 125 | - BombPickup.1 (Empty) (2.9, 5.88, 1, 1) 126 | - src/collectibles/bomb.ts 127 | - src/tiles/dialogue.ts 128 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 129 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 130 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 131 | - BombPickup.2 (Empty) (0.03, 2.97, 1, 1) 132 | - src/collectibles/bomb.ts 133 | - src/tiles/dialogue.ts 134 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 135 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 136 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 137 | - PushableBlocks (Empty) (0, 0, 1, 1) 138 | - Level1 (Empty) (-7.01, -25.91, 1, 1) 139 | - src/mechanics/pushable-block-manager.ts 140 | - Win (Empty) (-9.03, -27.12, 1, 1) 141 | - src/tiles/dialogue.ts 142 | - src/tiles/teleport.ts 143 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 144 | - LevelStart (Empty) (-20.01, -26.55, 1, 1) 145 | - Level2 (Empty) (-7.01, -25.91, 1, 1) 146 | - src/mechanics/pushable-block-manager.ts 147 | - Win (Empty) (-0.01, -22.08, 1, 1) 148 | - src/tiles/dialogue.ts 149 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 150 | - LevelStart (Empty) (-0.76, -26.84, 1, 1) 151 | - RichText (RichText) (-11.75, -37.33, 5, 5) 152 | - Portals (Empty) (0, 0, 1, 1) 153 | - Level1 (Empty) (-7.01, -25.91, 1, 1) 154 | - src/mechanics/pushable-block-manager.ts 155 | - Win (Empty) (-10.11, -40.39, 1, 1) 156 | - src/tiles/dialogue.ts 157 | - src/tiles/teleport.ts 158 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 159 | - LevelStart (Empty) (-17.89, -44.29, 1, 1) 160 | - Level2 (Empty) (-7.01, -25.91, 1, 1) 161 | - src/mechanics/pushable-block-manager.ts 162 | - Win (Empty) (10.01, -40.21, 1, 1) 163 | - src/tiles/dialogue.ts 164 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 165 | - LevelStart (Empty) (1.92, -40.39, 1, 1) 166 | - RichText (RichText) (-11.75, -59.93, 5, 5) 167 | - Level3 (Empty) (-7.01, -25.91, 1, 1) 168 | - src/mechanics/pushable-block-manager.ts 169 | - Win (Empty) (29.86, -49, 1, 1) 170 | - src/tiles/dialogue.ts 171 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 172 | - LevelStart (Empty) (20.8, -39.79, 1, 1) 173 | - RichText.1 (RichText) (17.99, -59.93, 5, 5) 174 | - RichText.2 (RichText) (17.99, -61.13, 5, 5) 175 | - GameSelection (UILayer) (-13.92, -14.89, 1, 1) 176 | - src/ui/game-selection.tsx 177 | - MetricsUI (UILayer) (-1.56, -6.68, 1, 1) 178 | - src/ui/player-metrics.tsx 179 | - LevelNavigation (UILayer) (-3.65, -67.89, 1, 1) 180 | - src/ui/level-navigation.tsx 181 | 182 | local: 183 | - Camera (Camera) (0, 0, 1, 1) 184 | - src/camera/pan-zoom.ts 185 | - EditorText (Empty) (11.27, -11.33, 1, 1) 186 | - Sokoban (RichText) (-22.43, -71.01, 5, 5) 187 | 188 | server: 189 | - PlayerSpawner (Empty) (0, 0, 1, 1) 190 | - src/player/spawner.ts 191 | 192 | prefabs: 193 | - DialogueText (UIPanel) (0, 0, 1, 1) 194 | - src/ui/dialogue-text.tsx 195 | - Player (Empty) (36.76, -70.27, 1, 1) 196 | - src/player/movement.ts 197 | - src/camera/follow.ts 198 | - src/player/inventory.ts 199 | - src/player/metrics.ts 200 | - src/mechanics/portal-placement.ts 201 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 202 | - LightOverlay (RawPixi) (0, 0, 1, 1) 203 | - src/effects/light-overlay.ts 204 | - Name (RichText) (0, 0, 1, 1) 205 | - GoldPile (Empty) (0, 0, 1, 1) 206 | - src/collectibles/gold.ts 207 | - Gold.1 (ColoredSquare) (-0.16, 0.19, 0.3, 0.32) 208 | - Gold.2 (ColoredSquare) (0.24, -0.26, 0.31, 0.32) 209 | - Gold.3 (ColoredSquare) (-0.21, -0.26, 0.31, 0.32) 210 | - BlueKey (Empty) (0, 0, 1, 1) 211 | - src/collectibles/key.ts 212 | - src/tiles/dialogue.ts 213 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 214 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 215 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 216 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 217 | - GreenKey (Empty) (0, 0, 1, 1) 218 | - src/collectibles/key.ts 219 | - src/tiles/dialogue.ts 220 | - Key (ColoredPolygon) (-0.21, 0, 1, 1) 221 | - Key.1 (ColoredSquare) (0.1, 0, 0.5, 0.1) 222 | - Key.2 (ColoredSquare) (0.26, -0.05, 0.2, 0.07) 223 | - Key.3 (ColoredSquare) (0.08, -0.05, 0.1, 0.07) 224 | - Enemy (Empty) (0, 0, 1, 1) 225 | - src/enemies/movement.ts 226 | - ColoredSquare (ColoredSquare) (0, 0, 1, 1) 227 | - BombPickup (Empty) (2, 1, 1, 1) 228 | - src/collectibles/bomb.ts 229 | - src/tiles/dialogue.ts 230 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 231 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 232 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 233 | - BombPlaced (Empty) (1, 1, 1, 1) 234 | - src/mechanics/placed-bomb.ts 235 | - ColoredPolygon (ColoredPolygon) (0, -0.1, 0.5, 0.5) 236 | - ColoredSquare (ColoredSquare) (0, 0.1, 0.2, 0.2) 237 | - ColoredSquare.1 (ColoredSquare) (0.02, 0.21, 0.03, 0.15) 238 | -------------------------------------------------------------------------------- /environments/portalbench/src/ui/game-selection.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Camera, 3 | Entity, 4 | EntityRef, 5 | RichText, 6 | rpc, 7 | UIBehavior, 8 | value, 9 | } from "@dreamlab/engine"; 10 | import CameraPanZoom from "../camera/pan-zoom.ts"; 11 | import PlayerMetrics from "../player/metrics.ts"; 12 | import PlayerMovement from "../player/movement.ts"; 13 | import PlayerSpawner from "../player/spawner.ts"; 14 | import GameLevelNavigationUI from "./level-navigation.tsx"; 15 | 16 | interface GameOption { 17 | name: string; 18 | displayName: string; 19 | path: string; 20 | } 21 | 22 | export default class GameSelectionUI extends UIBehavior { 23 | @value({ type: EntityRef }) 24 | player: Entity | undefined; 25 | 26 | @value() 27 | visible = true; 28 | 29 | @value() 30 | isSpectating = false; 31 | 32 | @value() 33 | connectionId = ""; 34 | 35 | @value() 36 | nickname = ""; 37 | 38 | private games: GameOption[] = [ 39 | { 40 | name: "KeysAndDoors", 41 | displayName: "🔑 Keys & Doors", 42 | path: "world/KeysAndDoors/Level1/LevelStart", 43 | }, 44 | { 45 | name: "Portals", 46 | displayName: "🌀 Portals", 47 | path: "world/Portals/Level1/LevelStart", 48 | }, 49 | { 50 | name: "PushableBlocks", 51 | displayName: "📦 Pushable Blocks", 52 | path: "world/PushableBlocks/Level1/LevelStart", 53 | }, 54 | { 55 | name: "Sokoban", 56 | displayName: "🎯 Sokoban", 57 | path: "world/Sokoban/Level1", 58 | }, 59 | { 60 | name: "Bombs", 61 | displayName: "💣 Bombs", 62 | path: "world/Bombs/Level1/LevelStart", 63 | }, 64 | ]; 65 | 66 | override onInitialize() { 67 | super.onInitialize(); 68 | if (!this.game.isClient()) return; 69 | 70 | this.values.get("visible")?.onChanged(() => { 71 | this.rerender(); 72 | }); 73 | 74 | this.values.get("player")?.onChanged(() => { 75 | this.rerender(); 76 | }); 77 | } 78 | 79 | private selectGame = async (game: GameOption) => { 80 | if (this.isSpectating) { 81 | const camera = this.game.local!._.Camera; 82 | if (camera) { 83 | const cameraBehavior = camera.getBehaviorIfExists(CameraPanZoom); 84 | if (cameraBehavior) { 85 | cameraBehavior.active = false; 86 | } 87 | camera.cast(Camera).zoom = 1; 88 | } 89 | 90 | await this.respawnPlayer(); 91 | 92 | await new Promise((resolve) => setTimeout(resolve, 100)); 93 | } 94 | 95 | if (!this.player) return; 96 | 97 | const playerMovement = this.player.getBehaviorIfExists(PlayerMovement); 98 | if (!playerMovement) return; 99 | 100 | const pathParts = game.path.split("/"); 101 | let targetEntity: Entity | undefined = this.game.world; 102 | 103 | for (const part of pathParts) { 104 | if (part === "world") continue; 105 | targetEntity = targetEntity?._[part]; 106 | if (!targetEntity) { 107 | console.error(`Could not find entity at path: ${game.path}`); 108 | return; 109 | } 110 | } 111 | 112 | if (targetEntity) { 113 | playerMovement.requestTeleport(targetEntity.pos.x, targetEntity.pos.y); 114 | 115 | const metrics = this.player.getBehaviorIfExists(PlayerMetrics); 116 | if (metrics) { 117 | metrics.reset(); 118 | } 119 | 120 | const navEntity = this.game.world._.LevelNavigation; 121 | if (navEntity) { 122 | const navUI = navEntity.getBehaviorIfExists(GameLevelNavigationUI); 123 | if (navUI) { 124 | navUI.player = this.player; 125 | navUI.rerender(); 126 | } 127 | } 128 | 129 | this.visible = false; 130 | this.rerender(); 131 | } 132 | }; 133 | 134 | private selectSpectate = () => { 135 | if (!this.player) return; 136 | 137 | const camera = this.game.local!._.Camera; 138 | if (camera) { 139 | const cameraBehavior = camera.getBehaviorIfExists(CameraPanZoom); 140 | if (cameraBehavior) { 141 | cameraBehavior.active = true; 142 | } 143 | } 144 | 145 | this.player.destroy(); 146 | this.player = undefined; 147 | 148 | this.isSpectating = true; 149 | 150 | this.visible = false; 151 | this.rerender(); 152 | }; 153 | 154 | private reopenMenu = () => { 155 | this.visible = true; 156 | this.rerender(); 157 | }; 158 | 159 | private closeMenu = () => { 160 | this.visible = false; 161 | this.rerender(); 162 | }; 163 | 164 | @rpc.server() 165 | respawnPlayer() { 166 | if (!this.connectionId) { 167 | console.error("No connection ID stored"); 168 | return; 169 | } 170 | 171 | const spawnerEntity = this.game.server!._.PlayerSpawner; 172 | if (!spawnerEntity) { 173 | console.error("PlayerSpawner entity not found"); 174 | return; 175 | } 176 | 177 | const spawnerBehavior = spawnerEntity.getBehaviorIfExists(PlayerSpawner); 178 | if (!spawnerBehavior) { 179 | console.error("PlayerSpawner behavior not found"); 180 | return; 181 | } 182 | 183 | const playerPrefab = spawnerBehavior.playerPrefab; 184 | if (!playerPrefab) { 185 | console.error("Player prefab not found"); 186 | return; 187 | } 188 | 189 | const player = playerPrefab.cloneInto(this.game.world, { 190 | authority: this.connectionId, 191 | name: "Player." + this.nickname, 192 | }); 193 | 194 | player._.Name.cast(RichText).text = this.nickname; 195 | 196 | this.player = player; 197 | this.isSpectating = false; 198 | } 199 | 200 | override render() { 201 | if (!this.visible) { 202 | return ( 203 |
213 | 244 | 245 | {this.isSpectating && ( 246 |
261 | Spectate • Middle-click pan • Scroll zoom 262 |
263 | )} 264 |
265 | ); 266 | } 267 | 268 | if (!this.visible) { 269 | return
; 270 | } 271 | 272 | return ( 273 |
287 |
298 | 327 | 328 |
331 | 354 |
355 |
356 |
357 | ); 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /environments/openmonsters/src/ui/game-selection.tsx: -------------------------------------------------------------------------------- 1 | import { Camera, Entity, EntityRef, RichText, rpc, UIBehavior, value } from "@dreamlab/engine"; 2 | import CameraPanZoom from "../camera/pan-zoom.ts"; 3 | import PlayerMetrics from "../player/metrics.ts"; 4 | import PlayerMovement from "../player/movement.ts"; 5 | import PlayerSpawner from "../player/spawner.ts"; 6 | import GameLevelNavigationUI from "./level-navigation.tsx"; 7 | 8 | interface GameOption { 9 | name: string; 10 | displayName: string; 11 | path: string; 12 | } 13 | 14 | export default class GameSelectionUI extends UIBehavior { 15 | @value({ type: EntityRef }) 16 | player: Entity | undefined; 17 | 18 | @value() 19 | visible = true; 20 | 21 | @value() 22 | isSpectating = false; 23 | 24 | @value() 25 | connectionId = ""; 26 | 27 | @value() 28 | nickname = ""; 29 | 30 | private games: GameOption[] = [ 31 | { 32 | name: "KeysAndDoors", 33 | displayName: "🔑 Keys & Doors", 34 | path: "world/KeysAndDoors/Level1/LevelStart", 35 | }, 36 | { name: "Portals", displayName: "🌀 Portals", path: "world/Portals/Level1/LevelStart" }, 37 | { 38 | name: "PushableBlocks", 39 | displayName: "📦 Pushable Blocks", 40 | path: "world/PushableBlocks/Level1/LevelStart", 41 | }, 42 | { name: "Sokoban", displayName: "🎯 Sokoban", path: "world/Sokoban/Level1" }, 43 | { name: "Bombs", displayName: "💣 Bombs", path: "world/Bombs/Level1/LevelStart" }, 44 | ]; 45 | 46 | override onInitialize() { 47 | super.onInitialize(); 48 | if (!this.game.isClient()) return; 49 | 50 | this.values.get("visible")?.onChanged(() => { 51 | this.rerender(); 52 | }); 53 | 54 | this.values.get("player")?.onChanged(() => { 55 | this.rerender(); 56 | }); 57 | } 58 | 59 | private selectGame = async (game: GameOption) => { 60 | if (this.isSpectating) { 61 | const camera = this.game.local!._.Camera; 62 | if (camera) { 63 | const cameraBehavior = camera.getBehaviorIfExists(CameraPanZoom); 64 | if (cameraBehavior) { 65 | cameraBehavior.active = false; 66 | } 67 | camera.cast(Camera).zoom = 1; 68 | } 69 | 70 | await this.respawnPlayer(); 71 | 72 | await new Promise(resolve => setTimeout(resolve, 100)); 73 | } 74 | 75 | if (!this.player) return; 76 | 77 | const playerMovement = this.player.getBehaviorIfExists(PlayerMovement); 78 | if (!playerMovement) return; 79 | 80 | const pathParts = game.path.split("/"); 81 | let targetEntity: Entity | undefined = this.game.world; 82 | 83 | for (const part of pathParts) { 84 | if (part === "world") continue; 85 | targetEntity = targetEntity?._[part]; 86 | if (!targetEntity) { 87 | console.error(`Could not find entity at path: ${game.path}`); 88 | return; 89 | } 90 | } 91 | 92 | if (targetEntity) { 93 | playerMovement.requestTeleport(targetEntity.pos.x, targetEntity.pos.y); 94 | 95 | const metrics = this.player.getBehaviorIfExists(PlayerMetrics); 96 | if (metrics) { 97 | metrics.reset(); 98 | } 99 | 100 | const navEntity = this.game.world._.LevelNavigation; 101 | if (navEntity) { 102 | const navUI = navEntity.getBehaviorIfExists(GameLevelNavigationUI); 103 | if (navUI) { 104 | navUI.player = this.player; 105 | navUI.rerender(); 106 | } 107 | } 108 | 109 | this.visible = false; 110 | this.rerender(); 111 | } 112 | }; 113 | 114 | private selectSpectate = () => { 115 | if (!this.player) return; 116 | 117 | const camera = this.game.local!._.Camera; 118 | if (camera) { 119 | const cameraBehavior = camera.getBehaviorIfExists(CameraPanZoom); 120 | if (cameraBehavior) { 121 | cameraBehavior.active = true; 122 | } 123 | } 124 | 125 | const navEntity = this.game.world._.LevelNavigation; 126 | if (navEntity) { 127 | const navUI = navEntity.getBehaviorIfExists(GameLevelNavigationUI); 128 | if (navUI) { 129 | navUI.player = undefined; 130 | navUI.rerender(); 131 | } 132 | } 133 | 134 | this.player.destroy(); 135 | this.player = undefined; 136 | 137 | this.isSpectating = true; 138 | 139 | this.visible = false; 140 | this.rerender(); 141 | }; 142 | 143 | private reopenMenu = () => { 144 | this.visible = true; 145 | this.rerender(); 146 | }; 147 | 148 | private closeMenu = () => { 149 | this.visible = false; 150 | this.rerender(); 151 | }; 152 | 153 | @rpc.server() 154 | respawnPlayer() { 155 | if (!this.connectionId) { 156 | console.error("No connection ID stored"); 157 | return; 158 | } 159 | 160 | const spawnerEntity = this.game.server!._.PlayerSpawner; 161 | if (!spawnerEntity) { 162 | console.error("PlayerSpawner entity not found"); 163 | return; 164 | } 165 | 166 | const spawnerBehavior = spawnerEntity.getBehaviorIfExists(PlayerSpawner); 167 | if (!spawnerBehavior) { 168 | console.error("PlayerSpawner behavior not found"); 169 | return; 170 | } 171 | 172 | const playerPrefab = spawnerBehavior.playerPrefab; 173 | if (!playerPrefab) { 174 | console.error("Player prefab not found"); 175 | return; 176 | } 177 | 178 | const player = playerPrefab.cloneInto(this.game.world, { 179 | authority: this.connectionId, 180 | name: "Player." + this.nickname, 181 | }); 182 | 183 | player._.Name.cast(RichText).text = this.nickname; 184 | 185 | this.player = player; 186 | this.isSpectating = false; 187 | } 188 | 189 | override render() { 190 | if (!this.visible) { 191 | return ( 192 |
202 | 233 | 234 | {this.isSpectating && ( 235 |
250 | Spectate • Middle-click pan • Scroll zoom 251 |
252 | )} 253 |
254 | ); 255 | } 256 | 257 | if (!this.visible) { 258 | return
; 259 | } 260 | 261 | return ( 262 |
276 |
287 | 316 | 317 |

327 | Select Game 328 |

329 | 330 |
331 | {this.games.map((game) => ( 332 | 355 | ))} 356 | 357 |
364 | 365 | 388 |
389 |
390 |
391 | ); 392 | } 393 | } 394 | --------------------------------------------------------------------------------