diff --git a/astro.config.mjs b/astro.config.mjs index 4db42eb596..36259e4377 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -33,6 +33,9 @@ export default defineConfig({ ], host: true, // Allow access from non-localhost domains cors: true, // Allow CORS (optional) + headers: { + "Access-Control-Allow-Origin": "*" + } }, optimizeDeps: { exclude: ['https'] diff --git a/engine/src/web/tune.ts b/engine/src/web/tune.ts index c656e66b02..2ca0f054fe 100644 --- a/engine/src/web/tune.ts +++ b/engine/src/web/tune.ts @@ -51,6 +51,7 @@ export function playTune(tune: Tune, number = 1): PlayTuneRes { const playingRef = { playing: true } if (audioCtx === null) audioCtx = new AudioContext() + if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {}) playTuneHelper(tune, number, playingRef, audioCtx, audioCtx.destination) return { diff --git a/package.json b/package.json index 5716434a18..56504c1ede 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "astro build", + "build": "astro build && node scripts/patch-vercel-config.mjs", "test": "bun run test:unit", "test:unit": "cross-env PUBLIC_MAX_ITERATIONS=2000 PUBLIC_MAX_LOOP_TIME_MS=1500 vitest run" }, diff --git a/scripts/patch-vercel-config.mjs b/scripts/patch-vercel-config.mjs new file mode 100644 index 0000000000..c0a90090f5 --- /dev/null +++ b/scripts/patch-vercel-config.mjs @@ -0,0 +1,29 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' + +const configPath = new URL('../.vercel/output/config.json', import.meta.url) + +if (!existsSync(configPath)) process.exit(0) + +const config = JSON.parse(readFileSync(configPath, 'utf8')) +const routes = Array.isArray(config.routes) ? config.routes : [] +const astroAssetsRouteIndex = routes.findIndex((route) => route.src === '^/_astro/(.*)$') +const astroAssetsRoute = astroAssetsRouteIndex >= 0 ? routes[astroAssetsRouteIndex] : null + +if (astroAssetsRoute) { + astroAssetsRoute.headers = { + ...astroAssetsRoute.headers, + 'Access-Control-Allow-Origin': '*', + 'Cross-Origin-Resource-Policy': 'cross-origin', + } + + routes.splice(astroAssetsRouteIndex, 1) + + const filesystemIndex = routes.findIndex((route) => route.handle === 'filesystem') + if (filesystemIndex >= 0) { + routes.splice(filesystemIndex, 0, astroAssetsRoute) + } else { + routes.unshift(astroAssetsRoute) + } +} + +writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) diff --git a/src/components/big-interactive-pages/desktop-player.tsx b/src/components/big-interactive-pages/desktop-player.tsx index 3136e53fca..0dfc2f1aaa 100644 --- a/src/components/big-interactive-pages/desktop-player.tsx +++ b/src/components/big-interactive-pages/desktop-player.tsx @@ -1,5 +1,4 @@ import {useEffect, useRef} from 'preact/hooks' -import { runGame } from '../../lib/engine' import styles from './desktop-player.module.css' import { IoExpandOutline, @@ -22,6 +21,9 @@ import {exitFullscreen, fullscreenElement, requestFullscreen} from "../../lib/ut import Button from "../design-system/button"; import {VscLoading} from "react-icons/vsc"; import {upload, uploadState} from "../../lib/upload"; +import SandboxedGameFrame from "./sandboxed-game-frame"; +import type { SandboxedGameControllerHandle } from "../../lib/engine/sandbox-controller"; +import { logInfo } from "../popups-etc/help"; interface DesktopPlayerProps { code: string @@ -33,17 +35,17 @@ interface DesktopPlayerProps { } export default function DesktopPlayer(props: DesktopPlayerProps) { - const screen = useRef(null) + const player = useRef(null) const outputArea = useRef(null); const screenContainer = useRef(null); const screenControls = useRef(null); + const playerVersion = useSignal(0); // acts a bit like a timer; if >0, shake game canvas // activated when a game is run to show it's being run const screenShake = useSignal(0); const onStop = async () => { - if (!screen.current) return; if (cleanupRef.value) cleanupRef.value?.(); }; @@ -53,14 +55,13 @@ export default function DesktopPlayer(props: DesktopPlayerProps) { }); const onRun = async () => { - if (!screen.current) return; + if (!player.current) return; if (cleanupRef.value) cleanupRef.value?.(); errorLog.value = []; - const res = runGame(props.code, screen.current!, (error) => { - errorLog.value = [...errorLog.value, error]; - }); + logInfo.value = []; + player.current.run(props.code); - screen.current!.focus(); + player.current.focus(); if (screenShake) { screenShake.value++; } @@ -70,16 +71,12 @@ export default function DesktopPlayer(props: DesktopPlayerProps) { } }, 200); - cleanupRef.value = res?.cleanup; - if (res && res.error) { - console.error(res.error.raw); - errorLog.value = [...errorLog.value, res.error]; - } + cleanupRef.value = () => player.current?.stop(); }; useEffect(() => { onRun() - }, [props.code]) + }, [props.code, playerVersion.value]) // mouse move timeout for auto-hiding fullscreen controls const mouseMoveTimeout = useSignal(0); @@ -166,8 +163,8 @@ export default function DesktopPlayer(props: DesktopPlayerProps) { maxHeight: canvasScreenSize.value.maxHeight, } : {}} className={styles.screenContainer}>
- 0 ? "shake" : "" }`} style={outputArea.current ? { @@ -176,10 +173,14 @@ export default function DesktopPlayer(props: DesktopPlayerProps) { width: (1.25 * canvasScreenSize.value.height), maxWidth: "100%", } : {}} - ref={screen} - tabIndex={0} - width="1000" - height="800" + onController={(controller) => { + player.current = controller; + playerVersion.value++; + }} + onError={(error) => { + console.error(error.raw); + errorLog.value = [...errorLog.value, error]; + }} />
diff --git a/src/components/big-interactive-pages/editor.tsx b/src/components/big-interactive-pages/editor.tsx index 4b3fdea24c..5234aef74d 100644 --- a/src/components/big-interactive-pages/editor.tsx +++ b/src/components/big-interactive-pages/editor.tsx @@ -14,12 +14,12 @@ import { useSignalEffect, } from "@preact/signals"; import { useEffect, useRef, useState} from "preact/hooks"; -import { codeMirror, errorLog, isNewSaveStrat, muted, type PersistenceState, type RoomState, screenRef, cleanupRef, reviewState } from "../../lib/state"; +import { codeMirror, errorLog, gamePlayerRef, isNewSaveStrat, muted, type PersistenceState, type RoomState, cleanupRef, reviewState } from "../../lib/state"; import EditorModal from "../popups-etc/editor-modal"; -import { runGame, _performSyntaxCheck } from "../../lib/engine"; +import { _performSyntaxCheck } from "../../lib/engine"; import DraftWarningModal from "../popups-etc/draft-warning"; import { debounce } from "throttle-debounce"; -import Help from "../popups-etc/help"; +import Help, { logInfo } from "../popups-etc/help"; import { collapseRanges } from "../../lib/codemirror/util"; import { defaultExampleCode } from "../../lib/examples"; import MigrateToast from "../popups-etc/migrate-toast"; @@ -33,6 +33,7 @@ import OutOfSpaceModal from "../popups-etc/out-of-space"; import RoomPasswordPopup from "../popups-etc/room-password"; import KeyBindingsModal from '../popups-etc/KeyBindingsModal' import { PersistenceStateKind } from "../../lib/state"; +import SandboxedGameFrame from "./sandboxed-game-frame"; let screenShakeSignal: Signal | null = null; @@ -47,16 +48,15 @@ const performSyntaxCheck = () => { export const onRun = async () => { foldAllTemplateLiterals(); - if (!screenRef.value) return; + if (!gamePlayerRef.value) return; if (cleanupRef.value) cleanupRef.value(); errorLog.value = []; + logInfo.value = []; const code = codeMirror.value?.state.doc.toString() ?? ""; - const res = runGame(code, screenRef.value, (error) => { - errorLog.value = [...errorLog.value, error]; - }); + gamePlayerRef.value.run(code); - screenRef.value.focus(); + gamePlayerRef.value.focus(); if (screenShakeSignal) { screenShakeSignal.value++; } @@ -66,11 +66,7 @@ export const onRun = async () => { } }, 200); - cleanupRef.value = res?.cleanup; - if (res && res.error) { - console.error(res.error.raw); - errorLog.value = [...errorLog.value, res.error]; - } + cleanupRef.value = () => gamePlayerRef.value?.stop(); }; interface EditorProps { @@ -449,17 +445,24 @@ export default function Editor({ persistenceState, cookies, roomState, review }: }, []); useEffect(() => { - screenRef.value = screen.current; - screenShakeSignal = screenShake; }); useEffect(() => () => cleanupRef.value?.(), []); + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + onRun(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); // We like running games! - const screen = useRef(null); const screenShake = useSignal(0); const onStop = async () => { - if (!screen.current) return; if (cleanupRef.value) cleanupRef.value?.(); }; @@ -661,7 +664,7 @@ export default function Editor({ persistenceState, cookies, roomState, review }: maxHeight: canvasScreenSize.value.maxHeight, } : {} }>
- 0 ? "shake" : "" }`} @@ -671,10 +674,13 @@ export default function Editor({ persistenceState, cookies, roomState, review }: width: (1.25 * canvasScreenSize.value.height), maxWidth: "100%", }: { } } - ref={screen} - tabIndex={0} - width="1000" - height="800" + onController={(controller) => { + gamePlayerRef.value = controller; + }} + onError={(error) => { + console.error(error.raw); + errorLog.value = [...errorLog.value, error]; + }} />
diff --git a/src/components/big-interactive-pages/mobile-player.tsx b/src/components/big-interactive-pages/mobile-player.tsx index d01db865f1..f6922d6e87 100644 --- a/src/components/big-interactive-pages/mobile-player.tsx +++ b/src/components/big-interactive-pages/mobile-player.tsx @@ -1,8 +1,9 @@ import { useSignal } from '@preact/signals' import { useEffect, useRef } from 'preact/hooks' -import { runGame } from '../../lib/engine' import styles from './mobile-player.module.css' import {exitFullscreen, fullscreenElement, requestFullscreen} from "../../lib/utils/fullscreen"; +import SandboxedGameFrame from './sandboxed-game-frame' +import type { SandboxedGameControllerHandle } from '../../lib/engine/sandbox-controller' interface MobilePlayerProps { code: string @@ -11,23 +12,23 @@ interface MobilePlayerProps { } export default function MobilePlayer(props: MobilePlayerProps) { - const screen = useRef(null) + const player = useRef(null) const cleanup = useRef<(() => void) | null>(null); + const playerVersion = useSignal(0); const run = () => { if (cleanup.current) cleanup.current(); - const res = runGame(props.code, screen.current!, (_) => { }) - if (res?.error) console.error(res.error.raw) - cleanup.current = res!.cleanup; - return res?.cleanup + player.current?.run(props.code) + cleanup.current = () => player.current?.stop(); + return cleanup.current } useEffect(() => { return run() - }, [props.code]) + }, [props.code, playerVersion.value]) const pressKey = (key: string) => { - screen.current!.dispatchEvent(new KeyboardEvent('keydown', { key })) + player.current?.sendKey(key) } const keyTouches = useSignal>({}) @@ -79,7 +80,14 @@ export default function MobilePlayer(props: MobilePlayerProps) {
- + { + player.current = controller; + playerVersion.value++; + }} + onError={(error) => console.error(error.raw)} + />
diff --git a/src/components/big-interactive-pages/sandboxed-game-frame.tsx b/src/components/big-interactive-pages/sandboxed-game-frame.tsx new file mode 100644 index 0000000000..211a9b6b82 --- /dev/null +++ b/src/components/big-interactive-pages/sandboxed-game-frame.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'preact/hooks' +import type { JSX } from 'preact' +import { bitmaps, muted, type NormalizedError } from '../../lib/state' +import { SandboxedGameController, type SandboxedGameControllerHandle } from '../../lib/engine/sandbox-controller' +import { logInfo } from '../popups-etc/help' + +interface SandboxedGameFrameProps { + class?: string + style?: JSX.CSSProperties + title?: string + onController?: (controller: SandboxedGameControllerHandle | null) => void + onError?: (error: NormalizedError) => void +} + +export default function SandboxedGameFrame(props: SandboxedGameFrameProps) { + const iframe = useRef(null) + const controller = useRef(null) + // ponytail: Vite HMR in dev requires allow-same-origin for websocket/client storage access; prod enforces strict opaque-origin sandbox. + const sandboxFlags = import.meta.env.DEV ? 'allow-scripts allow-same-origin' : 'allow-scripts' + + useEffect(() => { + if (!iframe.current) return + + controller.current = new SandboxedGameController(iframe.current, { + onError: props.onError, + onConsole: ({ args, nums, isErr }) => { + logInfo.value = [...logInfo.value, { args, nums, isErr }] + }, + onBitmaps: (_bitmaps) => { + bitmaps.value = _bitmaps + }, + }) + props.onController?.(controller.current) + + return () => { + controller.current?.destroy() + controller.current = null + props.onController?.(null) + } + }, []) + + useEffect(() => { + controller.current?.setMuted(muted.value) + }, [muted.value]) + + return ( +