import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { audio, bgm, ensureAudioReady } from './audio'; // ══════════════════════════════════════════════ // Type definitions // ══════════════════════════════════════════════ export interface EditableNumberField { type: 'number'; label: string; default: number; min?: number; max?: number; step?: number; } export interface EditableColorField { type: 'color'; label: string; default: string; /** Synced to a CSS variable such as '--bg-color' */ cssVar?: string; } export interface EditableTextField { type: 'text'; label: string; default: string; } export interface EditableArrayField { type: 'array'; label: string; default: Record[]; schema: Record; } export type EditableField = | EditableNumberField | EditableColorField | EditableTextField | EditableArrayField; export type EditableSchema = Record; // ══════════════════════════════════════════════ // Media state // ══════════════════════════════════════════════ export interface MediaEntry { value: string; state: 'active' | 'removed'; type?: 'image' | 'audio' | 'video'; label?: string; } // ══════════════════════════════════════════════ // Global Store (module singleton) // ══════════════════════════════════════════════ const store = { values: {} as Record, schema: null as EditableSchema | null, listeners: new Set<() => void>(), snapshot: {} as Record, notify: () => { store.snapshot = { ...store.values }; for (const fn of store.listeners) fn(); }, subscribe: (fn: () => void) => { store.listeners.add(fn); return () => { store.listeners.delete(fn); }; }, getSnapshot: () => { return store.snapshot; }, }; // ══════════════════════════════════════════════ // Media state Store // ══════════════════════════════════════════════ const mediaState: Record = {}; const muteConfig = { global: false }; type ExternalConfigState = 'pending' | 'success' | 'failed'; let externalConfigState: ExternalConfigState = 'pending'; let externalConfigPromise: Promise | null = null; const externalConfigListeners = new Set<() => void>(); function notifyExternalConfigListeners(): void { for (const fn of externalConfigListeners) fn(); } function setExternalConfigState(state: ExternalConfigState): void { if (externalConfigState === state) return; externalConfigState = state; notifyExternalConfigListeners(); } function subscribeExternalConfig(fn: () => void): () => void { externalConfigListeners.add(fn); return () => { externalConfigListeners.delete(fn); }; } function getExternalConfigSnapshot(): ExternalConfigState { return externalConfigState; } type InlineMediaEntry = { type?: string; label?: string; value?: string }; type InlineConfigData = { config?: Record; media?: Record; }; function readInlineConfig(): InlineConfigData | null { if (typeof document === 'undefined') return null; const el = document.querySelector('script[type="application/x-game-config"]'); if (!el?.textContent) return null; try { const json = JSON.parse(el.textContent) as Record; const config = (json.config ?? json) as Record; const media = json.media as Record | undefined; return { config, media }; } catch { return null; } } function applyConfigEntries(config: Record | undefined): void { if (!config) return; for (const [key, entry] of Object.entries(config)) { if (!(key in store.values)) { console.warn(`[editable] unknown config key in JSON: ${key}`); continue; } const val = (entry as any)?.value ?? entry; store.values[key] = val; const field = store.schema?.[key]; if (field?.type === 'color' && field.cssVar) { document.documentElement.style.setProperty(field.cssVar, String(val)); } } } function applyMediaEntries( media: Record | undefined, dispatchChange: boolean, ): void { if (!media) return; for (const [id, entry] of Object.entries(media)) { const url = entry?.value; if (!url) continue; const mediaType = entry?.type as MediaEntry['type']; mediaState[id] = { value: url, state: 'active', ...(mediaType && { type: mediaType }), ...(entry?.label && { label: entry.label }), }; if (dispatchChange) { window.dispatchEvent(new CustomEvent('mediachange', { detail: { id, src: url } })); } } } function buildConfigPayload(): Record { const payload: Record = {}; const schema = store.schema; if (!schema) return payload; for (const [key, field] of Object.entries(schema)) { payload[key] = { type: field.type, label: field.label, value: store.values[key], ...(field.type === 'number' && { min: field.min, max: field.max, step: field.step, }), ...(field.type === 'color' && { cssVar: field.cssVar }), ...(field.type === 'array' && { schema: field.schema }), }; } return payload; } function safeJSONStringify(value: unknown): string { return JSON.stringify(value).replace(/('script[type="application/x-game-config"]'); if (!script) return; writeInlineConfigScript(script); } // ══════════════════════════════════════════════ // Config global API // ══════════════════════════════════════════════ function registerConfigAPI() { // External editor call: live-update a single config value (window as any).updateGameConfig = (key: string, value: unknown) => { if (!store.schema || !(key in store.schema)) { console.warn(`[editable] unknown config key: ${key}`); return; } store.values[key] = value; // Sync CSS variables const field = store.schema[key]; if (field.type === 'color' && field.cssVar) { document.documentElement.style.setProperty(field.cssVar, value as string); } store.notify(); refreshInlineConfigScript(); }; // Read-only snapshot Object.defineProperty(window, 'gameConfig', { get: () => ({ ...store.values }), configurable: true, }); } // ══════════════════════════════════════════════ // Media global API // ══════════════════════════════════════════════ function registerMediaAPI() { (window as any).mediaState = mediaState; (window as any).setMute = (scope: string, value: boolean) => { if (scope === 'global') { muteConfig.global = value; if (value) { audio.mute(); } else { audio.unmute(); } // Sync HTML media elements document.querySelectorAll('audio, video').forEach(el => { el.muted = value; }); } }; (window as any).muteConfig = muteConfig; (window as any).isMuted = (_id: string): boolean => muteConfig.global; (window as any).setMediaVolume = (id: string, v: number) => { if (id === 'bgm') { bgm.setVolume(v); return; } const el = document.getElementById(id) as HTMLMediaElement | null; if (el && 'volume' in el) el.volume = v; }; (window as any).removeMedia = (id: string) => { const entry = mediaState[id]; if (!entry) return; entry.state = 'removed'; if (id === 'bgm') { bgm.stop(); } else { const el = document.getElementById(id); if (el) { if ('pause' in el) { (el as HTMLMediaElement).pause(); el.removeAttribute('src'); (el as HTMLMediaElement).load(); } else if ('src' in el) { (el as HTMLImageElement).src = ''; } } } refreshInlineConfigScript(); window.dispatchEvent(new CustomEvent('mediachange', { detail: { id, src: '', action: 'remove' } })); }; (window as any).restoreMedia = (id: string) => { const entry = mediaState[id]; if (!entry) return; entry.state = 'active'; if (id === 'bgm') { void bgm.play(entry.value); } else { const el = document.getElementById(id) as HTMLMediaElement | null; if (el && 'src' in el) el.src = entry.value; } refreshInlineConfigScript(); window.dispatchEvent(new CustomEvent('mediachange', { detail: { id, src: entry.value, action: 'restore' } })); }; // Called when the external editor replaces a media src (window as any).updateMediaSrc = (id: string, src: string) => { const entry = mediaState[id]; if (entry) { entry.value = src; entry.state = 'active'; } else { mediaState[id] = { value: src, state: 'active' }; } if (id === 'bgm') { bgm.stop(); if (src) void bgm.play(src, { loop: true }); } else { const el = document.getElementById(id) as HTMLMediaElement | null; if (el && 'src' in el) { el.src = src; if (el instanceof HTMLVideoElement) el.load(); } } refreshInlineConfigScript(); window.dispatchEvent(new CustomEvent('mediachange', { detail: { id, src, action: 'update' } })); }; } // ══════════════════════════════════════════════ // DOM injection —