Spaces:
Runtime error
Runtime error
| import { createCharacter, preloadCharacter, updateCharacter } from "./character.js?v=20260615-2"; | |
| const WORLD_WIDTH = 960; | |
| const WORLD_HEIGHT = 640; | |
| const PLAYER_SPEED = 1.8; | |
| const TOUCH_MOVE_STEP = 15; | |
| function isEditableTarget(target) { | |
| return target instanceof HTMLElement && ( | |
| target.matches("input, textarea, select, [contenteditable='true']") || | |
| Boolean(target.closest("input, textarea, select, [contenteditable='true']")) | |
| ); | |
| } | |
| export function createWorldController({ | |
| mountId, | |
| getPlayerName, | |
| getPlayerAppearance, | |
| onNearbyChange, | |
| onEnterBuilding, | |
| initialPlayerPosition = null, | |
| }) { | |
| let active = false; | |
| let player = null; | |
| let playerRig = null; | |
| let nearbyBuilding = null; | |
| let sceneRef = null; | |
| let savedPlayerPosition = initialPlayerPosition; | |
| const pressedKeys = new Set(); | |
| const heldDirections = new Set(); | |
| // Phaser's keyboard plugin captures arrow/WASD keys at the browser level. | |
| // World-scoped DOM listeners avoid that global capture entirely, so normal | |
| // form fields keep every typing key when the player enters a building. | |
| function handleKeyDown(event) { | |
| if (!active || isEditableTarget(event.target) || isEditableTarget(document.activeElement)) return; | |
| const key = event.key.toLowerCase(); | |
| if (["arrowleft", "arrowright", "arrowup", "arrowdown", "w", "a", "s", "d"].includes(key)) { | |
| pressedKeys.add(key); | |
| event.preventDefault(); | |
| } | |
| if ((key === "enter" || key === " ") && nearbyBuilding) { | |
| event.preventDefault(); | |
| enterNearby(); | |
| } | |
| } | |
| function handleKeyUp(event) { | |
| pressedKeys.delete(event.key.toLowerCase()); | |
| } | |
| window.addEventListener("keydown", handleKeyDown); | |
| window.addEventListener("keyup", handleKeyUp); | |
| function clearMovement() { | |
| pressedKeys.clear(); | |
| heldDirections.clear(); | |
| } | |
| window.addEventListener("blur", clearMovement); | |
| class WorldScene extends Phaser.Scene { | |
| constructor() { super("WorldScene"); } | |
| preload() { | |
| this.load.image("world-map-agent", "/game-static/assets/world-map-agent.png?v=20260615-2"); | |
| preloadCharacter(this, getPlayerAppearance?.() || {}); | |
| } | |
| create() { | |
| sceneRef = this; | |
| drawMap(this); | |
| if (!active) this.scene.pause(); | |
| } | |
| update() { | |
| if (!active || !player || isEditableTarget(document.activeElement)) return; | |
| let dx = 0; | |
| let dy = 0; | |
| if (pressedKeys.has("arrowleft") || pressedKeys.has("a") || heldDirections.has("left")) dx -= PLAYER_SPEED; | |
| if (pressedKeys.has("arrowright") || pressedKeys.has("d") || heldDirections.has("right")) dx += PLAYER_SPEED; | |
| if (pressedKeys.has("arrowup") || pressedKeys.has("w") || heldDirections.has("up")) dy -= PLAYER_SPEED; | |
| if (pressedKeys.has("arrowdown") || pressedKeys.has("s") || heldDirections.has("down")) dy += PLAYER_SPEED; | |
| updateCharacter(playerRig, dx, dy, this.time.now); | |
| if (dx || dy) move(dx, dy); | |
| } | |
| } | |
| const game = new Phaser.Game({ | |
| // Canvas is more reliable than a WebGL context when the Gradio iframe or | |
| // game screen is repeatedly hidden. | |
| type: Phaser.CANVAS, | |
| parent: mountId, | |
| width: WORLD_WIDTH, | |
| height: WORLD_HEIGHT, | |
| backgroundColor: "#75bd79", | |
| scene: WorldScene, | |
| pixelArt: true, | |
| // FIT preserves the internal coordinate system while the screen is hidden. | |
| // RESIZE can observe a 0x0 hidden parent and leave WebGL rendering black. | |
| scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }, | |
| }); | |
| function drawMap(scene) { | |
| if (player) savedPlayerPosition = { x: player.x, y: player.y }; | |
| scene.children.removeAll(); | |
| const width = scene.scale.width || WORLD_WIDTH; | |
| const height = scene.scale.height || WORLD_HEIGHT; | |
| scene.add.image(width / 2, height / 2, "world-map-agent").setDisplaySize(width, height); | |
| const buildings = positions(width, height); | |
| drawBuildingHotspot(scene, buildings.school, "SCHOOL", "school"); | |
| drawBuildingHotspot(scene, buildings.home, "HOME", "home"); | |
| drawBuildingHotspot(scene, buildings.hq, "GRANDMA", "hq"); | |
| playerRig = createCharacter( | |
| scene, | |
| savedPlayerPosition?.x ?? width / 2, | |
| savedPlayerPosition?.y ?? height * .5, | |
| getPlayerAppearance?.() || {}, | |
| ); | |
| player = playerRig.container; | |
| updateCharacter(playerRig, 0, 0, 0); | |
| updateNearby(); | |
| } | |
| function positions(width, height) { | |
| return { | |
| school: { x: width * .24, y: height * .23, width: width * .36, height: height * .35, entranceX: width * .25, entranceY: height * .4 }, | |
| home: { x: width * .74, y: height * .23, width: width * .25, height: height * .34, entranceX: width * .75, entranceY: height * .4 }, | |
| hq: { x: width * .5, y: height * .79, width: width * .25, height: height * .28, entranceX: width * .52, entranceY: height * .91 }, | |
| }; | |
| } | |
| function drawBuildingHotspot(scene, item, label, kind) { | |
| const { x, y, width, height, entranceX, entranceY } = item; | |
| scene.add.text(entranceX, entranceY - 20, label, { | |
| fontFamily: "monospace", fontSize: "13px", fontStyle: "bold", color: "#17202a", | |
| backgroundColor: "#fff2c7", padding: { x: 7, y: 4 }, | |
| }).setOrigin(.5).setStroke("#fff2c7", 2); | |
| scene.add.zone(x, y, width, height + 70).setInteractive({ useHandCursor: true }).on("pointerdown", () => { | |
| if (active) onEnterBuilding(kind); | |
| }); | |
| } | |
| function move(dx, dy) { | |
| if (!sceneRef || !player) return; | |
| player.x = Phaser.Math.Clamp(player.x + dx, 28, sceneRef.scale.width - 28); | |
| player.y = Phaser.Math.Clamp(player.y + dy, 50, sceneRef.scale.height - 30); | |
| updateNearby(); | |
| } | |
| function updateNearby() { | |
| if (!sceneRef || !player) return; | |
| const list = positions(sceneRef.scale.width, sceneRef.scale.height); | |
| nearbyBuilding = Object.entries(list).find(([, item]) => | |
| Phaser.Math.Distance.Between(player.x, player.y, item.entranceX, item.entranceY) < 105, | |
| )?.[0] || null; | |
| onNearbyChange(nearbyBuilding); | |
| } | |
| function enterNearby() { | |
| if (nearbyBuilding) onEnterBuilding(nearbyBuilding); | |
| } | |
| return { | |
| activate() { | |
| active = true; | |
| if (sceneRef) { | |
| sceneRef.scene.resume(); | |
| // A hidden Canvas can be cleared by the browser. Wait until the world | |
| // screen has completed layout, then refresh and repaint the scene. | |
| requestAnimationFrame(() => requestAnimationFrame(() => { | |
| if (!active || !sceneRef) return; | |
| sceneRef.scale.refresh(); | |
| drawMap(sceneRef); | |
| sceneRef.game.loop.wake(); | |
| })); | |
| } | |
| }, | |
| deactivate() { | |
| active = false; | |
| pressedKeys.clear(); | |
| heldDirections.clear(); | |
| nearbyBuilding = null; | |
| if (sceneRef) { | |
| sceneRef.scene.pause(); | |
| } | |
| onNearbyChange(null); | |
| }, | |
| moveDirection(direction) { | |
| if (!active) return; | |
| const amount = TOUCH_MOVE_STEP; | |
| const vectors = { up: [0, -amount], down: [0, amount], left: [-amount, 0], right: [amount, 0] }; | |
| const [dx, dy] = vectors[direction] || [0, 0]; | |
| move(dx, dy); | |
| }, | |
| startMoving(direction) { | |
| if (!active || !["up", "down", "left", "right"].includes(direction)) return; | |
| heldDirections.add(direction); | |
| this.moveDirection(direction); | |
| }, | |
| stopMoving(direction) { | |
| heldDirections.delete(direction); | |
| }, | |
| stopAllMovement() { | |
| heldDirections.clear(); | |
| }, | |
| enterNearby, | |
| getPlayerPosition() { | |
| if (player) return { x: player.x, y: player.y }; | |
| return savedPlayerPosition; | |
| }, | |
| redraw() { if (sceneRef) drawMap(sceneRef); }, | |
| destroy() { | |
| window.removeEventListener("keydown", handleKeyDown); | |
| window.removeEventListener("keyup", handleKeyUp); | |
| window.removeEventListener("blur", clearMovement); | |
| game.destroy(true); | |
| }, | |
| }; | |
| } | |