diff --git a/Content/Browser/build-browser.bat b/Content/Browser/build-browser.bat index d60d7bf..c1642ad 100644 --- a/Content/Browser/build-browser.bat +++ b/Content/Browser/build-browser.bat @@ -21,5 +21,5 @@ if errorlevel 1 ( ) echo Browser runtime built successfully. -echo Output: dist\hypertwist-browser-runtime.umd.js -echo Copy to Content\Browser\ for UE embedded WebBrowser widget loading. +echo Output: dist\browser-spatial-runtime.js +echo Authoritative shell: index.html ^(loads dist bundle when present, plain-JS fallback when absent^). diff --git a/Content/Browser/index.html b/Content/Browser/index.html index 21eea4c..b434313 100644 --- a/Content/Browser/index.html +++ b/Content/Browser/index.html @@ -327,9 +327,6 @@
- + diff --git a/Content/Browser/package.json b/Content/Browser/package.json index b2f2dec..a8484a1 100644 --- a/Content/Browser/package.json +++ b/Content/Browser/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "vite build", "dev": "vite", - "preview": "vite preview" + "preview": "vite preview", + "verify:shell": "node ./scripts/verify-browser-shell.mjs" }, "dependencies": { "@google/model-viewer": "^4.3.1", diff --git a/Content/Browser/scripts/verify-browser-shell.mjs b/Content/Browser/scripts/verify-browser-shell.mjs new file mode 100644 index 0000000..c77b90b --- /dev/null +++ b/Content/Browser/scripts/verify-browser-shell.mjs @@ -0,0 +1,46 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const browserRoot = path.resolve(import.meta.dirname, '..'); +const shellPath = path.join(browserRoot, 'index.html'); +const bootstrapPath = path.join(browserRoot, 'src', 'browser-runtime-bootstrap.js'); +const fallbackPath = path.join(browserRoot, 'src', 'browser-spatial-runtime-fallback.js'); + +const failures = []; + +function assert(condition, message) +{ + if (!condition) + { + failures.push(message); + } +} + +assert(existsSync(shellPath), `Missing authoritative browser shell: ${shellPath}`); +assert(existsSync(bootstrapPath), `Missing bootstrap runtime script: ${bootstrapPath}`); +assert(existsSync(fallbackPath), `Missing clean-checkout fallback runtime: ${fallbackPath}`); + +if (existsSync(shellPath)) +{ + const shellHtml = readFileSync(shellPath, 'utf8'); + assert( + shellHtml.includes('./src/browser-runtime-bootstrap.js'), + 'The authoritative browser shell must load the bootstrap runtime script.' + ); + assert( + !shellHtml.includes('browser-spatial-runtime.ts'), + 'The authoritative browser shell must not point directly at a TypeScript source entrypoint.' + ); +} + +if (failures.length > 0) +{ + for (const failure of failures) + { + console.error(failure); + } + process.exit(1); +} + +console.log('HyperTwist browser shell verification passed.'); diff --git a/Content/Browser/src/browser-runtime-bootstrap.js b/Content/Browser/src/browser-runtime-bootstrap.js new file mode 100644 index 0000000..aa1ef1e --- /dev/null +++ b/Content/Browser/src/browser-runtime-bootstrap.js @@ -0,0 +1,120 @@ +(function bootstrapHyperTwistBrowserRuntime() +{ + const GlobalWindow = window; + const ExistingBootState = GlobalWindow.HyperTwistBrowserBootState; + const BootState = ExistingBootState && typeof ExistingBootState === 'object' + ? ExistingBootState + : {}; + + if (!Array.isArray(BootState.pendingCommands)) + { + BootState.pendingCommands = []; + } + if (!Array.isArray(BootState.pendingShellStates)) + { + BootState.pendingShellStates = []; + } + + BootState.bundleEntryUrl = new URL('./dist/browser-spatial-runtime.js', GlobalWindow.location.href).toString(); + BootState.fallbackEntryUrl = new URL('./src/browser-spatial-runtime-fallback.js', GlobalWindow.location.href).toString(); + BootState.runtimeMode = 'bootstrapping'; + BootState.bootstrapStartedAtUtc = new Date().toISOString(); + GlobalWindow.HyperTwistBrowserBootState = BootState; + + if (!GlobalWindow.HyperTwistBrowserRuntime || typeof GlobalWindow.HyperTwistBrowserRuntime !== 'object') + { + GlobalWindow.HyperTwistBrowserRuntime = { + adapters: [], + listAdapters() + { + return []; + }, + loadAdapter() + { + return Promise.resolve({ + id: 'bootstrap-stub', + note: 'Runtime bundle still bootstrapping.', + status: 'deferred' + }); + }, + receiveCommand(Payload) + { + BootState.pendingCommands.push(Payload); + }, + setShellState(Payload) + { + BootState.pendingShellStates.push(Payload); + }, + renderShell() + { + }, + sendState() + { + } + }; + } + + let bFallbackRequested = false; + + function loadFallbackRuntime(Reason) + { + if (bFallbackRequested) + { + return; + } + + bFallbackRequested = true; + BootState.runtimeMode = 'fallback-js'; + BootState.fallbackReason = Reason; + + const FallbackScript = document.createElement('script'); + FallbackScript.src = BootState.fallbackEntryUrl; + FallbackScript.defer = true; + FallbackScript.dataset.hypertwistRuntime = 'fallback-js'; + document.head.appendChild(FallbackScript); + } + + const BundledRuntimeScript = document.createElement('script'); + BundledRuntimeScript.type = 'module'; + BundledRuntimeScript.src = BootState.bundleEntryUrl; + BundledRuntimeScript.dataset.hypertwistRuntime = 'bundled-module'; + BundledRuntimeScript.onload = function onBundledRuntimeLoaded() + { + GlobalWindow.setTimeout(function verifyBundledRuntime() + { + if (GlobalWindow.HyperTwistBrowserBootState?.runtimeMode === 'bundled-module') + { + return; + } + + if (GlobalWindow.HyperTwistBrowserRuntime && GlobalWindow.HyperTwistBrowserRuntime.adapters?.length) + { + BootState.runtimeMode = 'bundled-module'; + return; + } + + loadFallbackRuntime('bundle-loaded-without-runtime'); + }, 200); + }; + BundledRuntimeScript.onerror = function onBundledRuntimeFailed() + { + loadFallbackRuntime('bundle-missing-or-failed'); + }; + document.head.appendChild(BundledRuntimeScript); + + GlobalWindow.setTimeout(function enforceRuntimeAvailability() + { + if (GlobalWindow.HyperTwistBrowserBootState?.runtimeMode === 'bundled-module') + { + return; + } + + if (GlobalWindow.HyperTwistBrowserRuntime && GlobalWindow.HyperTwistBrowserRuntime.adapters?.length) + { + BootState.runtimeMode = 'bundled-module'; + return; + } + + loadFallbackRuntime('bundle-timeout'); + }, 2000); +})(); diff --git a/Content/Browser/src/browser-spatial-runtime-fallback.js b/Content/Browser/src/browser-spatial-runtime-fallback.js new file mode 100644 index 0000000..f5fc39d --- /dev/null +++ b/Content/Browser/src/browser-spatial-runtime-fallback.js @@ -0,0 +1,649 @@ +(function bootstrapHyperTwistBrowserFallbackRuntime() +{ + const BootState = window.HyperTwistBrowserBootState && typeof window.HyperTwistBrowserBootState === 'object' + ? window.HyperTwistBrowserBootState + : {}; + window.HyperTwistBrowserBootState = BootState; + BootState.runtimeMode = 'fallback-js'; + BootState.runtimeReadyAtUtc = new Date().toISOString(); + + const BrowserSupportAdapters = [ + { + activation: 'bundled-module', + description: 'Reference glTF renderer and viewer module wired into the first-party browser runtime.', + id: '1e-gltf-sample-renderer', + phase: '1E', + repo: 'KhronosGroup/glTF-Sample-Renderer', + sourcePathHint: '.external/glTF-Sample-Renderer' + }, + { + activation: 'native-sidecar', + description: 'Native/OpenCV solver adjunct retained as a browser-shell fallback lane rather than a direct npm package.', + id: '1g-tentone-native-solver', + phase: '1G', + repo: 'tentone/rubix-solver', + sourcePathHint: '.external/rubix-solver' + }, + { + activation: 'source-sidecar', + description: 'Rust donor retained as a source-sidecar surface for follow-up browser spatial composition.', + id: '1h-code-vr', + phase: '1H', + repo: 'NuiLab/code-vr', + sourcePathHint: '.external/code-vr' + }, + { + activation: 'prebuilt-script', + description: 'Prebuilt ClayGL runtime script bridged into the browser shell for viewer and QA surfaces.', + id: '1h-claygl', + phase: '1H', + repo: 'pissang/claygl', + sourcePathHint: '.external/claygl/dist/claygl.js' + }, + { + activation: 'prebuilt-script', + description: 'Prebuilt Clay Viewer shell loaded beside ClayGL for bounded browser inspection.', + id: '1h-clay-viewer', + phase: '1H', + repo: 'pissang/clay-viewer', + sourcePathHint: '.external/clay-viewer/dist/clay-viewer.js' + }, + { + activation: 'bundled-module', + description: 'Three.js post-processing core bundled into the runtime module graph.', + id: '1h-postprocessing', + phase: '1H', + repo: 'pmndrs/postprocessing', + sourcePathHint: '.external/postprocessing' + }, + { + activation: 'bundled-module', + description: 'React bridge for post-processing loaded into the browser spatial stack.', + id: '1h-react-postprocessing', + phase: '1H', + repo: 'pmndrs/react-postprocessing', + sourcePathHint: '.external/react-postprocessing' + }, + { + activation: 'bundled-module', + description: 'Drei helper surface bundled for browser-spatial utility composition.', + id: '1h-drei', + phase: '1H', + repo: 'pmndrs/drei', + sourcePathHint: '.external/drei' + }, + { + activation: 'bundled-module', + description: 'World-anchored UI kit bundled for browser-spatial HUD and control surfaces.', + id: '1h-uikit', + phase: '1H', + repo: 'pmndrs/uikit', + sourcePathHint: '.external/uikit' + }, + { + activation: 'bundled-module', + description: 'Three.js helper library bundled for browser runtime support.', + id: '1h-three-stdlib', + phase: '1H', + repo: 'pmndrs/three-stdlib', + sourcePathHint: '.external/three-stdlib' + }, + { + activation: 'bundled-module', + description: 'Animation and math helper pack bundled into the runtime.', + id: '1h-maath', + phase: '1H', + repo: 'pmndrs/maath', + sourcePathHint: '.external/maath' + }, + { + activation: 'bundled-module', + description: 'State-store runtime bundled for browser shell state and derived panel demos.', + id: '1h-zustand', + phase: '1H', + repo: 'pmndrs/zustand', + sourcePathHint: '.external/zustand' + }, + { + activation: 'bundled-module', + description: 'Parameter-control runtime bundled for future tuning overlays.', + id: '1h-leva', + phase: '1H', + repo: 'pmndrs/leva', + sourcePathHint: '.external/leva' + }, + { + activation: 'bundled-module', + description: 'Gesture capture hooks bundled for browser interaction surfaces.', + id: '1h-use-gesture', + phase: '1H', + repo: 'pmndrs/use-gesture', + sourcePathHint: '.external/use-gesture' + }, + { + activation: 'bundled-module', + description: 'Motion and interpolation runtime bundled for browser spatial transitions.', + id: '1h-react-spring', + phase: '1H', + repo: 'pmndrs/react-spring', + sourcePathHint: '.external/react-spring' + }, + { + activation: 'bundled-module', + description: '2D render engine bundled beneath the analytics surface.', + id: '1i-zrender', + phase: '1I', + repo: 'ecomfe/zrender', + sourcePathHint: '.external/zrender' + }, + { + activation: 'bundled-module', + description: '3D analytics extension bundled beneath the ECharts runtime.', + id: '1i-echarts-gl', + phase: '1I', + repo: 'ecomfe/echarts-gl', + sourcePathHint: '.external/echarts-gl' + }, + { + activation: 'bundled-module', + description: 'Model Viewer web component bundled into the browser shell.', + id: '1j-model-viewer', + phase: '1J', + repo: 'google/model-viewer/packages/model-viewer', + sourcePathHint: '.external/model-viewer/packages/model-viewer' + }, + { + activation: 'bundled-module', + description: 'Effects package bundled beside model-viewer for decorated previews.', + id: '1j-model-viewer-effects', + phase: '1J', + repo: 'google/model-viewer/packages/model-viewer-effects', + sourcePathHint: '.external/model-viewer/packages/model-viewer-effects' + }, + { + activation: 'editor-sidecar', + description: 'Editor-oriented inspection UI retained as a sidecar instead of a default runtime export.', + id: '1j-space-opera', + phase: '1J', + repo: 'google/model-viewer/packages/space-opera', + sourcePathHint: '.external/model-viewer/packages/space-opera' + }, + { + activation: 'qa-sidecar', + description: 'Renderer comparison and fidelity tooling kept in the QA lane rather than loaded by default.', + id: '1j-render-fidelity-tools', + phase: '1J', + repo: 'google/model-viewer/packages/render-fidelity-tools', + sourcePathHint: '.external/model-viewer/packages/render-fidelity-tools' + } + ]; + + function getUnrealBridgeHandle() + { + const Candidate = window.ue; + if (!Candidate || typeof Candidate !== 'object') + { + return null; + } + + const HyperTwistBridge = Candidate.hypertwist; + return HyperTwistBridge && typeof HyperTwistBridge === 'object' + ? HyperTwistBridge + : null; + } + + function tryParseJsonPayload(Value) + { + if (typeof Value !== 'string') + { + return Value; + } + + try + { + return JSON.parse(Value); + } + catch + { + return Value; + } + } + + const CommandListeners = new Set(); + const ShellStateListeners = new Set(); + let LastCommandPayload; + let LastShellStatePayload; + let HasLastCommandPayload = false; + let HasLastShellStatePayload = false; + + function dispatchToListeners(Listeners, Payload) + { + Listeners.forEach((Listener) => Listener(Payload)); + } + + function postEnvelope(Type, Payload) + { + const Envelope = { + type: Type, + payload: Payload, + emittedAtUtc: new Date().toISOString() + }; + + const SerializedEnvelope = JSON.stringify(Envelope); + const UnrealBridgeHandle = getUnrealBridgeHandle(); + let DeliveredToUnreal = false; + + if (UnrealBridgeHandle && typeof UnrealBridgeHandle.notifyEnvelope === 'function') + { + UnrealBridgeHandle.notifyEnvelope(SerializedEnvelope); + DeliveredToUnreal = true; + } + + if (Type === 'hypertwist-state' + && UnrealBridgeHandle + && typeof UnrealBridgeHandle.notifyState === 'function') + { + UnrealBridgeHandle.notifyState(JSON.stringify(Payload)); + DeliveredToUnreal = true; + } + + if (DeliveredToUnreal) + { + return; + } + + if (window.parent && typeof window.parent.postMessage === 'function') + { + window.parent.postMessage(Envelope, '*'); + } + } + + window.addEventListener('message', function onBridgeMessage(Event) + { + const Envelope = Event.data; + if (!Envelope || typeof Envelope !== 'object') + { + return; + } + + if (Envelope.type === 'hypertwist-command') + { + runtimeApi.receiveCommand(Envelope.payload); + } + else if (Envelope.type === 'hypertwist-shell-state') + { + runtimeApi.setShellState(Envelope.payload); + } + }); + + const UEBridge = { + onCommand(Listener) + { + CommandListeners.add(Listener); + if (HasLastCommandPayload) + { + Listener(LastCommandPayload); + } + return function unsubscribeCommand() + { + CommandListeners.delete(Listener); + }; + }, + onShellState(Listener) + { + ShellStateListeners.add(Listener); + if (HasLastShellStatePayload) + { + Listener(LastShellStatePayload); + } + return function unsubscribeShellState() + { + ShellStateListeners.delete(Listener); + }; + }, + receiveCommand(Payload) + { + LastCommandPayload = tryParseJsonPayload(Payload); + HasLastCommandPayload = true; + dispatchToListeners(CommandListeners, LastCommandPayload); + }, + setShellState(Payload) + { + LastShellStatePayload = tryParseJsonPayload(Payload); + HasLastShellStatePayload = true; + dispatchToListeners(ShellStateListeners, LastShellStatePayload); + }, + sendState(Payload) + { + postEnvelope('hypertwist-state', Payload); + }, + notifyRuntimeReady(Payload) + { + const UnrealBridgeHandle = getUnrealBridgeHandle(); + if (UnrealBridgeHandle && typeof UnrealBridgeHandle.notifyRuntimeReady === 'function') + { + UnrealBridgeHandle.notifyRuntimeReady(JSON.stringify(Payload)); + return; + } + + postEnvelope('hypertwist-runtime-ready', Payload); + } + }; + + function createElement(TagName, ClassName, TextContent) + { + const Element = document.createElement(TagName); + if (ClassName) + { + Element.className = ClassName; + } + if (typeof TextContent === 'string') + { + Element.textContent = TextContent; + } + return Element; + } + + function serializePretty(Payload) + { + if (typeof Payload === 'string') + { + return Payload; + } + + try + { + return JSON.stringify(Payload, null, 2); + } + catch + { + return String(Payload); + } + } + + function appendLog(LogList, Title, Message) + { + const Entry = createElement('div', 'log-entry'); + Entry.append( + createElement('strong', undefined, Title), + createElement('div', undefined, Message) + ); + LogList.prepend(Entry); + } + + async function loadAdapter(AdapterId) + { + const Adapter = BrowserSupportAdapters.find((Candidate) => Candidate.id === AdapterId); + if (!Adapter) + { + throw new Error('Unknown browser support adapter: ' + AdapterId); + } + + const BundledActivations = new Set(['bundled-module', 'prebuilt-script']); + if (BundledActivations.has(Adapter.activation)) + { + return { + details: ['Clean checkout fallback is active; build the browser bundle to exercise this adapter in-browser.'], + id: AdapterId, + note: 'Fallback runtime is active, so bundled browser modules are advertised but not loaded from source checkout state.', + status: 'deferred' + }; + } + + return { + details: [Adapter.sourcePathHint], + id: AdapterId, + note: 'Adapter is intentionally represented as a sidecar in the plain-JS fallback shell.', + status: 'loaded' + }; + } + + function createBrowserShell(RootElement) + { + RootElement.innerHTML = ''; + + const ShellElement = createElement('div', 'shell'); + const HeroElement = createElement('section', 'hero'); + const HeroTitle = createElement('h1', undefined, 'HyperTwist Browser Runtime'); + const HeroParagraph = createElement( + 'p', + undefined, + 'This clean-checkout fallback keeps the embedded browser lane truthful and runnable. When the built runtime bundle is present the authoritative shell upgrades automatically; when it is absent, the shell still renders, bridges Unreal state, and surfaces which adapters remain bundled or sidecar-only.' + ); + const HeroMetrics = createElement('div', 'hero-grid'); + + [ + { label: 'Runtime mode', value: 'plain-js fallback' }, + { label: 'Fallback reason', value: BootState.fallbackReason || 'bundle unavailable' }, + { label: 'Adapters surfaced', value: String(BrowserSupportAdapters.length) } + ].forEach(function appendMetric(Metric) + { + const MetricElement = createElement('div', 'metric'); + MetricElement.append( + createElement('strong', undefined, Metric.label), + createElement('span', undefined, Metric.value) + ); + HeroMetrics.appendChild(MetricElement); + }); + + HeroElement.append(HeroTitle, HeroParagraph, HeroMetrics); + + const AdapterSection = createElement('section', 'section'); + const AdapterHeader = createElement('div', 'section-header'); + AdapterHeader.append( + createElement('h2', 'section-title', 'Adapter Surface'), + createElement( + 'p', + 'section-note', + 'Adapters remain visible in clean checkout state. Bundled browser modules are marked deferred until `npm run build` produces the browser runtime bundle.' + ) + ); + + const AdapterGrid = createElement('div', 'stack-grid'); + BrowserSupportAdapters.forEach(function appendAdapter(Adapter) + { + const CardElement = createElement('article', 'card'); + const CardHeader = createElement('div', 'card-header'); + const CardTitleBlock = createElement('div'); + CardTitleBlock.append( + createElement('span', 'phase-chip', Adapter.phase), + createElement('h2', undefined, Adapter.repo) + ); + + const StatusChip = createElement('span', 'status-chip', 'idle'); + StatusChip.dataset.status = 'idle'; + CardHeader.append(CardTitleBlock, StatusChip); + + const ActivationChip = createElement('span', 'activation-chip', Adapter.activation); + const Description = createElement('p', undefined, Adapter.description); + const SourceCode = createElement('code', undefined, Adapter.sourcePathHint); + const Actions = createElement('div', 'card-actions'); + const LoadButton = createElement('button', undefined, 'Resolve Adapter'); + const EnvelopeButton = createElement('button', 'secondary', 'Send Envelope'); + + LoadButton.addEventListener('click', async function onLoadAdapter() + { + StatusChip.dataset.status = 'loading'; + StatusChip.textContent = 'loading'; + + try + { + const Result = await loadAdapter(Adapter.id); + StatusChip.dataset.status = Result.status === 'loaded' ? 'ready' : 'idle'; + StatusChip.textContent = Result.status; + appendLog( + LogList, + Adapter.repo, + Result.note || ('Resolved ' + Adapter.id + '.') + ); + } + catch (Error) + { + StatusChip.dataset.status = 'error'; + StatusChip.textContent = 'error'; + appendLog(LogList, Adapter.repo, Error instanceof Error ? Error.message : 'Unknown load failure.'); + } + }); + + EnvelopeButton.addEventListener('click', function onSendEnvelope() + { + UEBridge.sendState({ + source: 'browser-runtime-fallback', + adapterId: Adapter.id, + repo: Adapter.repo, + activation: Adapter.activation, + runtimeMode: 'fallback-js' + }); + appendLog(LogList, Adapter.repo, 'Sent adapter envelope to Unreal.'); + }); + + Actions.append(LoadButton, EnvelopeButton); + CardElement.append(CardHeader, ActivationChip, Description, SourceCode, Actions); + AdapterGrid.appendChild(CardElement); + }); + + AdapterSection.append(AdapterHeader, AdapterGrid); + + const UtilitySection = createElement('section', 'section'); + const UtilityHeader = createElement('div', 'section-header'); + UtilityHeader.append( + createElement('h2', 'section-title', 'Fallback Utilities'), + createElement( + 'p', + 'section-note', + 'The plain-JS runtime keeps state bridging and native-sidecar requests live even when bundled browser modules have been cleaned after validation.' + ) + ); + + const UtilityGrid = createElement('div', 'panel-grid'); + + const SolverPanel = createElement('section', 'panel'); + SolverPanel.append( + createElement('h2', undefined, 'Tentone Native Solver Request'), + createElement( + 'p', + 'runtime-note', + 'The OpenCV donor remains a native-sidecar lane. This fallback shell sends a first-party solver request envelope instead of pretending to import the donor in-browser.' + ) + ); + + const SolverField = createElement('label', 'field'); + SolverField.appendChild(createElement('span', undefined, 'Classic facelet string')); + const SolverInput = createElement('textarea'); + SolverInput.value = 'UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB'; + SolverField.appendChild(SolverInput); + const SolverActions = createElement('div', 'solver-actions'); + const SolverButton = createElement('button', undefined, 'Send Solver Request'); + SolverButton.addEventListener('click', function onSolve() + { + UEBridge.sendState({ + type: 'tentone-native-solver-request', + source: 'browser-runtime-fallback', + faceletString: SolverInput.value.trim() + }); + appendLog(LogList, 'Tentone Native Solver', 'Sent a solver request envelope to Unreal.'); + }); + SolverActions.appendChild(SolverButton); + SolverPanel.append(SolverField, SolverActions); + + const StatePanel = createElement('section', 'panel'); + StatePanel.append( + createElement('h2', undefined, 'Browser Shell State'), + createElement( + 'p', + 'runtime-note', + 'Unreal can push shell-state and command envelopes into the fallback runtime exactly like the bundled runtime.' + ) + ); + const StateData = createElement('div', 'data-block'); + const StatePre = createElement('pre'); + StatePre.textContent = '{\n "status": "waiting-for-unreal-shell-state"\n}'; + StateData.appendChild(StatePre); + StatePanel.appendChild(StateData); + + const LogPanel = createElement('section', 'panel'); + LogPanel.append( + createElement('h2', undefined, 'Runtime Log'), + createElement( + 'p', + 'runtime-note', + 'Bridge and adapter events stay inspectable here during clean-checkout fallback mode.' + ) + ); + const LogList = createElement('div', 'log-list'); + LogPanel.appendChild(LogList); + + UtilityGrid.append(SolverPanel, StatePanel, LogPanel); + UtilitySection.append(UtilityHeader, UtilityGrid); + + ShellElement.append(HeroElement, AdapterSection, UtilitySection); + RootElement.appendChild(ShellElement); + + UEBridge.onCommand(function onCommand(Payload) + { + appendLog(LogList, 'UE Command', serializePretty(Payload)); + }); + + UEBridge.onShellState(function onShellState(Payload) + { + StatePre.textContent = serializePretty(Payload); + appendLog(LogList, 'Browser Shell State', 'Received fresh shell state from Unreal.'); + }); + } + + const runtimeApi = { + adapters: BrowserSupportAdapters, + listAdapters() + { + return BrowserSupportAdapters.slice(); + }, + loadAdapter, + receiveCommand(Payload) + { + UEBridge.receiveCommand(Payload); + }, + setShellState(Payload) + { + UEBridge.setShellState(Payload); + }, + renderShell(RootElement) + { + createBrowserShell(RootElement); + }, + sendState(Payload) + { + UEBridge.sendState(Payload); + } + }; + + window.HyperTwistBrowserRuntime = runtimeApi; + + const RootElement = document.getElementById('app'); + if (RootElement instanceof HTMLElement) + { + runtimeApi.renderShell(RootElement); + } + + const PendingCommands = Array.isArray(BootState.pendingCommands) ? BootState.pendingCommands.slice() : []; + BootState.pendingCommands = []; + PendingCommands.forEach(function replayPendingCommand(Payload) + { + runtimeApi.receiveCommand(Payload); + }); + + const PendingShellStates = Array.isArray(BootState.pendingShellStates) ? BootState.pendingShellStates.slice() : []; + BootState.pendingShellStates = []; + PendingShellStates.forEach(function replayPendingShellState(Payload) + { + runtimeApi.setShellState(Payload); + }); + + UEBridge.notifyRuntimeReady({ + status: 'ready', + runtime: 'hypertwist-browser-runtime-fallback', + mode: 'fallback-js', + fallbackReason: BootState.fallbackReason || 'bundle unavailable', + adapterCount: BrowserSupportAdapters.length, + shellAuthority: 'Content/Browser/index.html' + }); +})(); diff --git a/Content/Browser/src/browser-spatial-runtime.ts b/Content/Browser/src/browser-spatial-runtime.ts index 467960a..87bc61c 100644 --- a/Content/Browser/src/browser-spatial-runtime.ts +++ b/Content/Browser/src/browser-spatial-runtime.ts @@ -2,8 +2,18 @@ import { UEBridge } from './runtime/bridge'; import { BrowserSupportAdapters, listAdapters, loadAdapter } from './runtime/registry'; import { createBrowserShell } from './runtime/shell'; +interface BrowserBootState +{ + fallbackReason?: string; + pendingCommands?: unknown[]; + pendingShellStates?: unknown[]; + runtimeMode?: string; + runtimeReadyAtUtc?: string; +} + declare global { interface Window { + HyperTwistBrowserBootState?: BrowserBootState; HyperTwistBrowserRuntime?: { adapters: typeof BrowserSupportAdapters; listAdapters: typeof listAdapters; @@ -16,6 +26,23 @@ declare global { } } +function resolveBootState(): BrowserBootState +{ + const ExistingBootState = window.HyperTwistBrowserBootState; + if (ExistingBootState && typeof ExistingBootState === 'object') + { + return ExistingBootState; + } + + const BootState: BrowserBootState = {}; + window.HyperTwistBrowserBootState = BootState; + return BootState; +} + +const BootState = resolveBootState(); +BootState.runtimeMode = 'bundled-module'; +BootState.runtimeReadyAtUtc = new Date().toISOString(); + const runtimeApi = { adapters: BrowserSupportAdapters, listAdapters, @@ -42,8 +69,18 @@ if (RootElement instanceof HTMLElement) runtimeApi.renderShell(RootElement); } +const PendingCommands = [...(BootState.pendingCommands ?? [])]; +BootState.pendingCommands = []; +PendingCommands.forEach((Payload) => runtimeApi.receiveCommand(Payload)); + +const PendingShellStates = [...(BootState.pendingShellStates ?? [])]; +BootState.pendingShellStates = []; +PendingShellStates.forEach((Payload) => runtimeApi.setShellState(Payload)); + UEBridge.notifyRuntimeReady({ status: 'ready', runtime: 'hypertwist-browser-runtime', - adapterCount: listAdapters().length + mode: BootState.runtimeMode, + adapterCount: listAdapters().length, + shellAuthority: 'Content/Browser/index.html' }); diff --git a/Content/Browser/src/runtime/bridge.ts b/Content/Browser/src/runtime/bridge.ts index 23e7d61..caa3083 100644 --- a/Content/Browser/src/runtime/bridge.ts +++ b/Content/Browser/src/runtime/bridge.ts @@ -1,4 +1,5 @@ type RuntimeListener = (payload: unknown) => void; +type PendingPayload = unknown; function tryParseJsonPayload(value: unknown): unknown { @@ -33,6 +34,10 @@ function getUnrealBridgeHandle(): Record | null const CommandListeners = new Set(); const ShellStateListeners = new Set(); +let LastCommandPayload: PendingPayload | undefined; +let LastShellStatePayload: PendingPayload | undefined; +let bHasLastCommandPayload = false; +let bHasLastShellStatePayload = false; function dispatchToListeners(Listeners: Set, Payload: unknown): void { @@ -49,11 +54,12 @@ function postEnvelope(Type: string, Payload: unknown): void const SerializedEnvelope = JSON.stringify(Envelope); const UnrealBridgeHandle = getUnrealBridgeHandle(); + let bDeliveredToUnreal = false; if (UnrealBridgeHandle && typeof UnrealBridgeHandle.notifyEnvelope === 'function') { (UnrealBridgeHandle.notifyEnvelope as (EnvelopeJson: string) => void)(SerializedEnvelope); - return; + bDeliveredToUnreal = true; } if (Type === 'hypertwist-state' @@ -61,6 +67,11 @@ function postEnvelope(Type: string, Payload: unknown): void && typeof UnrealBridgeHandle.notifyState === 'function') { (UnrealBridgeHandle.notifyState as (StateJson: string) => void)(JSON.stringify(Payload)); + bDeliveredToUnreal = true; + } + + if (bDeliveredToUnreal) + { return; } @@ -90,24 +101,35 @@ export const UEBridge = { onCommand(Listener: RuntimeListener): () => void { CommandListeners.add(Listener); + if (bHasLastCommandPayload) + { + Listener(LastCommandPayload); + } return () => CommandListeners.delete(Listener); }, onShellState(Listener: RuntimeListener): () => void { ShellStateListeners.add(Listener); + if (bHasLastShellStatePayload) + { + Listener(LastShellStatePayload); + } return () => ShellStateListeners.delete(Listener); }, receiveCommand(Payload: unknown): void { - dispatchToListeners(CommandListeners, tryParseJsonPayload(Payload)); + LastCommandPayload = tryParseJsonPayload(Payload); + bHasLastCommandPayload = true; + dispatchToListeners(CommandListeners, LastCommandPayload); }, setShellState(Payload: unknown): void { - const ParsedPayload = tryParseJsonPayload(Payload); - dispatchToListeners(ShellStateListeners, ParsedPayload); + LastShellStatePayload = tryParseJsonPayload(Payload); + bHasLastShellStatePayload = true; + dispatchToListeners(ShellStateListeners, LastShellStatePayload); }, sendState(Payload: unknown): void diff --git a/Content/Browser/src/runtime/shell.ts b/Content/Browser/src/runtime/shell.ts index d4cc102..b729c4e 100644 --- a/Content/Browser/src/runtime/shell.ts +++ b/Content/Browser/src/runtime/shell.ts @@ -53,7 +53,7 @@ export function createBrowserShell(RootElement: HTMLElement): void const HeroParagraph = createElement( 'p', undefined, - 'Phase 1 browser-capable donors now resolve through a first-party runtime shell instead of broken workspace-root npm links. Native and editor-only donors stay visible as bounded sidecars instead of being misrepresented as direct runtime packages.' + 'The authoritative shell now lives in Content/Browser/index.html. When the bundled runtime is present the shell upgrades into the full browser stack; when build artifacts have been cleaned, the same shell still remains truthful and runnable through the plain-JS fallback lane.' ); const HeroMetrics = createElement('div', 'hero-grid'); @@ -69,6 +69,10 @@ export function createBrowserShell(RootElement: HTMLElement): void { label: 'Bridge route', value: 'UE BindUObject + postMessage fallback' + }, + { + label: 'Shell authority', + value: 'index.html + runtime bootstrap' } ]; @@ -175,7 +179,7 @@ export function createBrowserShell(RootElement: HTMLElement): void createElement( 'p', 'section-note', - 'The browser shell can render analytics, preview bundled viewer assets, and fall back into native solver routing without pretending native donors are browser packages.' + 'The browser shell can render analytics, preview bundled viewer assets, and fall back into native solver routing without pretending native donors are browser packages or requiring retained build artifacts.' ) ); diff --git a/Content/Browser/vite.config.ts b/Content/Browser/vite.config.ts index 17c5f2e..f4b81da 100644 --- a/Content/Browser/vite.config.ts +++ b/Content/Browser/vite.config.ts @@ -6,14 +6,15 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, sourcemap: true, + lib: { + entry: resolve(__dirname, 'src/browser-spatial-runtime.ts'), + formats: ['es'], + fileName: () => 'browser-spatial-runtime.js' + }, rollupOptions: { - input: { - shell: resolve(__dirname, 'index.html') - }, output: { assetFileNames: 'assets/[name][extname]', - chunkFileNames: 'assets/[name].js', - entryFileNames: 'assets/[name].js' + chunkFileNames: 'assets/[name].js' } } }, diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBrowser/HyperTwistBrowserWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBrowser/HyperTwistBrowserWidget.cpp index 9074407..53a1eb5 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBrowser/HyperTwistBrowserWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistBrowser/HyperTwistBrowserWidget.cpp @@ -16,20 +16,7 @@ namespace HyperTwistBrowserWidgetInternal FString ResolveBundledBrowserShellAbsolutePath() { - const TArray CandidatePaths = { - FPaths::Combine(FPaths::ProjectDir(), TEXT(".."), TEXT("Content"), TEXT("Browser"), TEXT("dist"), TEXT("index.html")), - FPaths::Combine(FPaths::ProjectDir(), TEXT(".."), TEXT("Content"), TEXT("Browser"), TEXT("index.html")) - }; - - for (const FString& CandidatePath : CandidatePaths) - { - if (FPaths::FileExists(CandidatePath)) - { - return CandidatePath; - } - } - - return CandidatePaths.Last(); + return FPaths::Combine(FPaths::ProjectDir(), TEXT(".."), TEXT("Content"), TEXT("Browser"), TEXT("index.html")); } } @@ -103,6 +90,11 @@ FString UHyperTwistBrowserWidget::ResolveBundledBrowserShellUrl() ); } +FString UHyperTwistBrowserWidget::ResolveBundledBrowserShellSourcePath() +{ + return HyperTwistBrowserWidgetInternal::ResolveBundledBrowserShellAbsolutePath(); +} + TSharedRef UHyperTwistBrowserWidget::RebuildWidget() { if (BrowserBridgeObject == nullptr) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp index 0efde80..f7b2ca2 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeActor.cpp @@ -84,26 +84,8 @@ bool AHyperTwistClassicCubeActor::ProcessClick(const FVector& RayOrigin, const F return false; } - // Map the dominant hit-normal axis to a cube face so tiny floating-point - // deviations on procedural sections do not make clicks unresponsive. - const FVector Normal = Hit.ImpactNormal.GetSafeNormal(); - const FVector AbsNormal = Normal.GetAbs(); EHyperTwistClassicCubeFace Face = EHyperTwistClassicCubeFace::Up; - - if (AbsNormal.Z >= AbsNormal.X && AbsNormal.Z >= AbsNormal.Y) - { - Face = Normal.Z >= 0.0f ? EHyperTwistClassicCubeFace::Up : EHyperTwistClassicCubeFace::Down; - } - else if (AbsNormal.Y >= AbsNormal.X) - { - Face = Normal.Y >= 0.0f ? EHyperTwistClassicCubeFace::Front : EHyperTwistClassicCubeFace::Back; - } - else - { - Face = Normal.X >= 0.0f ? EHyperTwistClassicCubeFace::Right : EHyperTwistClassicCubeFace::Left; - } - - if (Normal.IsNearlyZero()) + if (!TryResolveFaceFromImpactNormal(Hit.ImpactNormal, Face)) { return false; } @@ -116,6 +98,36 @@ bool AHyperTwistClassicCubeActor::ProcessClick(const FVector& RayOrigin, const F return true; } +bool AHyperTwistClassicCubeActor::TryResolveFaceFromImpactNormal( + const FVector& ImpactNormal, + EHyperTwistClassicCubeFace& OutFace +) +{ + const FVector Normal = ImpactNormal.GetSafeNormal(); + if (Normal.IsNearlyZero()) + { + return false; + } + + // Use the dominant axis so small floating-point drift on procedural geometry + // does not make face selection unstable. + const FVector AbsNormal = Normal.GetAbs(); + if (AbsNormal.Z >= AbsNormal.X && AbsNormal.Z >= AbsNormal.Y) + { + OutFace = Normal.Z >= 0.0f ? EHyperTwistClassicCubeFace::Up : EHyperTwistClassicCubeFace::Down; + } + else if (AbsNormal.Y >= AbsNormal.X) + { + OutFace = Normal.Y >= 0.0f ? EHyperTwistClassicCubeFace::Front : EHyperTwistClassicCubeFace::Back; + } + else + { + OutFace = Normal.X >= 0.0f ? EHyperTwistClassicCubeFace::Right : EHyperTwistClassicCubeFace::Left; + } + + return true; +} + TArray AHyperTwistClassicCubeActor::GenerateScramble(int32 Length) { TArray Result; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp index 6fe3f5a..f2358af 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp @@ -5,6 +5,8 @@ #include "GameFramework/PlayerController.h" #include "HyperTwistSimulation/HyperTwistClassicCubeActor.h" #include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h" +#include "HyperTwistSimulation/HyperTwistClassicCubePlayerController.h" #include "HyperTwistTraining/HyperTwistTrainingSubsystem.h" namespace HyperTwistClassicCubeGameModeInternal @@ -52,6 +54,8 @@ namespace HyperTwistClassicCubeGameModeInternal AHyperTwistClassicCubeGameMode::AHyperTwistClassicCubeGameMode() { PrimaryActorTick.bCanEverTick = true; + PlayerControllerClass = AHyperTwistClassicCubePlayerController::StaticClass(); + DefaultPawnClass = AHyperTwistClassicCubeOrbitPawn::StaticClass(); } void AHyperTwistClassicCubeGameMode::BeginPlay() @@ -270,6 +274,7 @@ void AHyperTwistClassicCubeGameMode::RefreshHud() FString TimerLine = TEXT("timer: idle"); FString InspectionLine = TEXT("inspection: pending"); FString ResultLine = TEXT("result: waiting for first attempt"); + FString ControlsLine = TEXT("controls: LMB clockwise, RMB counter-clockwise, touch clockwise, MMB drag orbit, wheel zoom, R new scramble"); UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem(); FHyperTwistTrainingLiveTimerState LiveTimerState; @@ -339,7 +344,7 @@ void AHyperTwistClassicCubeGameMode::RefreshHud() } } - ActiveHudWidget->SetHudLines(StatusLine, ScrambleLine, TimerLine, InspectionLine, ResultLine); + ActiveHudWidget->SetHudLines(StatusLine, ScrambleLine, TimerLine, InspectionLine, ResultLine, ControlsLine); } FString AHyperTwistClassicCubeGameMode::BuildSessionId() const diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp index 9059804..0f0c494 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.cpp @@ -1,9 +1,12 @@ #include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h" +#include "Components/Button.h" +#include "Components/ButtonSlot.h" #include "Components/TextBlock.h" #include "Components/VerticalBox.h" #include "Components/VerticalBoxSlot.h" #include "Blueprint/WidgetTree.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeGameMode.h" void UHyperTwistClassicCubeHUDWidget::NativeConstruct() { @@ -16,7 +19,8 @@ void UHyperTwistClassicCubeHUDWidget::SetHudLines( const FString& InScrambleLine, const FString& InTimerLine, const FString& InInspectionLine, - const FString& InResultLine + const FString& InResultLine, + const FString& InControlsLine ) { EnsureWidgetTreeBuilt(); @@ -41,6 +45,15 @@ void UHyperTwistClassicCubeHUDWidget::SetHudLines( { ResultTextBlock->SetText(FText::FromString(InResultLine)); } + if (ControlsTextBlock != nullptr) + { + ControlsTextBlock->SetText(FText::FromString(InControlsLine)); + } +} + +bool UHyperTwistClassicCubeHUDWidget::IsNewScrambleButtonHovered() const +{ + return bNewScrambleButtonHovered; } void UHyperTwistClassicCubeHUDWidget::EnsureWidgetTreeBuilt() @@ -59,13 +72,16 @@ void UHyperTwistClassicCubeHUDWidget::EnsureWidgetTreeBuilt() TimerTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudTimer")); InspectionTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudInspection")); ResultTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudResult")); + ControlsTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudControls")); + AddActionButton(RootLayout); SetHudLines( TEXT("status: preparing classic cube lane"), TEXT("scramble: pending"), TEXT("timer: idle"), TEXT("inspection: pending"), - TEXT("result: waiting for first attempt") + TEXT("result: waiting for first attempt"), + TEXT("controls: LMB clockwise, RMB counter-clockwise, touch clockwise, MMB drag orbit, wheel zoom, R new scramble") ); } @@ -90,3 +106,69 @@ UTextBlock* UHyperTwistClassicCubeHUDWidget::AddLine(UVerticalBox* Parent, const return TextBlock; } + +void UHyperTwistClassicCubeHUDWidget::AddActionButton(UVerticalBox* Parent) +{ + if (WidgetTree == nullptr || Parent == nullptr || NewScrambleButton != nullptr) + { + return; + } + + NewScrambleButton = WidgetTree->ConstructWidget( + UButton::StaticClass(), + TEXT("ClassicCubeHudNewScrambleButton") + ); + if (NewScrambleButton == nullptr) + { + return; + } + + NewScrambleButtonLabel = WidgetTree->ConstructWidget( + UTextBlock::StaticClass(), + TEXT("ClassicCubeHudNewScrambleButtonLabel") + ); + if (NewScrambleButtonLabel != nullptr) + { + NewScrambleButtonLabel->SetText(FText::FromString(TEXT("New Scramble"))); + NewScrambleButton->AddChild(NewScrambleButtonLabel); + } + + NewScrambleButton->OnClicked.AddDynamic(this, &UHyperTwistClassicCubeHUDWidget::HandleNewScrambleClicked); + NewScrambleButton->OnHovered.AddDynamic(this, &UHyperTwistClassicCubeHUDWidget::HandleNewScrambleHovered); + NewScrambleButton->OnUnhovered.AddDynamic(this, &UHyperTwistClassicCubeHUDWidget::HandleNewScrambleUnhovered); + + if (UVerticalBoxSlot* VerticalBoxSlot = Parent->AddChildToVerticalBox(NewScrambleButton)) + { + VerticalBoxSlot->SetPadding(FMargin(0.0f, 12.0f, 0.0f, 0.0f)); + } + + if (UButtonSlot* ButtonSlot = Cast(NewScrambleButtonLabel != nullptr ? NewScrambleButtonLabel->Slot : nullptr)) + { + ButtonSlot->SetPadding(FMargin(12.0f, 6.0f, 12.0f, 6.0f)); + ButtonSlot->SetHorizontalAlignment(HAlign_Center); + ButtonSlot->SetVerticalAlignment(VAlign_Center); + } +} + +void UHyperTwistClassicCubeHUDWidget::HandleNewScrambleClicked() +{ + if (GetWorld() == nullptr) + { + return; + } + + if (AHyperTwistClassicCubeGameMode* GameMode = Cast(GetWorld()->GetAuthGameMode())) + { + GameMode->StartFreshAttempt(); + } +} + +void UHyperTwistClassicCubeHUDWidget::HandleNewScrambleHovered() +{ + bNewScrambleButtonHovered = true; +} + +void UHyperTwistClassicCubeHUDWidget::HandleNewScrambleUnhovered() +{ + bNewScrambleButtonHovered = false; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.cpp new file mode 100644 index 0000000..d949615 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.cpp @@ -0,0 +1,110 @@ +#include "HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h" + +#include "Camera/CameraComponent.h" +#include "GameFramework/PlayerController.h" +#include "GameFramework/SpringArmComponent.h" +#include "InputCoreTypes.h" +#include "Components/SceneComponent.h" + +AHyperTwistClassicCubeOrbitPawn::AHyperTwistClassicCubeOrbitPawn() +{ + PrimaryActorTick.bCanEverTick = true; + + SceneRoot = CreateDefaultSubobject(TEXT("SceneRoot")); + RootComponent = SceneRoot; + + SpringArm = CreateDefaultSubobject(TEXT("SpringArm")); + SpringArm->SetupAttachment(SceneRoot); + SpringArm->bDoCollisionTest = false; + SpringArm->bEnableCameraLag = false; + SpringArm->bUsePawnControlRotation = false; + SpringArm->TargetArmLength = InitialArmLength; + + CameraComponent = CreateDefaultSubobject(TEXT("Camera")); + CameraComponent->SetupAttachment(SpringArm, USpringArmComponent::SocketName); + CameraComponent->bUsePawnControlRotation = false; +} + +void AHyperTwistClassicCubeOrbitPawn::BeginPlay() +{ + Super::BeginPlay(); + + CurrentYawDegrees = InitialYawDegrees; + CurrentPitchDegrees = InitialPitchDegrees; + if (SpringArm != nullptr) + { + SpringArm->TargetArmLength = FMath::Clamp(InitialArmLength, MinimumArmLength, MaximumArmLength); + } + ApplyOrbitTransform(); +} + +void AHyperTwistClassicCubeOrbitPawn::Tick(const float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + static_cast(DeltaSeconds); + + APlayerController* PlayerController = Cast(GetController()); + if (PlayerController == nullptr) + { + return; + } + + if (SpringArm != nullptr) + { + const float MouseWheelDelta = PlayerController->GetInputAnalogKeyState(EKeys::MouseWheelAxis); + if (!FMath::IsNearlyZero(MouseWheelDelta)) + { + SpringArm->TargetArmLength = FMath::Clamp( + SpringArm->TargetArmLength - (MouseWheelDelta * ZoomStep), + MinimumArmLength, + MaximumArmLength + ); + } + } + + if (ShouldOrbitFromMouseInput()) + { + float MouseDeltaX = 0.0f; + float MouseDeltaY = 0.0f; + PlayerController->GetInputMouseDelta(MouseDeltaX, MouseDeltaY); + if (!FMath::IsNearlyZero(MouseDeltaX) || !FMath::IsNearlyZero(MouseDeltaY)) + { + CurrentYawDegrees += MouseDeltaX * OrbitYawDegreesPerPixel; + CurrentPitchDegrees = FMath::Clamp( + CurrentPitchDegrees - (MouseDeltaY * OrbitPitchDegreesPerPixel), + MinimumPitchDegrees, + MaximumPitchDegrees + ); + ApplyOrbitTransform(); + } + } +} + +void AHyperTwistClassicCubeOrbitPawn::ApplyOrbitTransform() +{ + SetActorLocation(OrbitFocusPoint); + if (SpringArm != nullptr) + { + SpringArm->SetRelativeRotation(FRotator(CurrentPitchDegrees, CurrentYawDegrees, 0.0f)); + } +} + +bool AHyperTwistClassicCubeOrbitPawn::ShouldOrbitFromMouseInput() const +{ + const APlayerController* PlayerController = Cast(GetController()); + if (PlayerController == nullptr) + { + return false; + } + + const bool bMiddleMouseOrbit = bUseMiddleMouseOrbit && PlayerController->IsInputKeyDown(EKeys::MiddleMouseButton); + const bool bShiftRightMouseOrbit = + bUseRightMouseOrbitWithShift + && PlayerController->IsInputKeyDown(EKeys::RightMouseButton) + && ( + PlayerController->IsInputKeyDown(EKeys::LeftShift) + || PlayerController->IsInputKeyDown(EKeys::RightShift) + ); + + return bMiddleMouseOrbit || bShiftRightMouseOrbit; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubePlayerController.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubePlayerController.cpp new file mode 100644 index 0000000..02c79bc --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubePlayerController.cpp @@ -0,0 +1,188 @@ +#include "HyperTwistSimulation/HyperTwistClassicCubePlayerController.h" + +#include "EngineUtils.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeActor.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeGameMode.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h" +#include "InputCoreTypes.h" + +AHyperTwistClassicCubePlayerController::AHyperTwistClassicCubePlayerController() +{ + bShowMouseCursor = true; + bEnableClickEvents = true; + bEnableMouseOverEvents = true; +} + +void AHyperTwistClassicCubePlayerController::BeginPlay() +{ + Super::BeginPlay(); + ApplyClassicCubeInputMode(); +} + +void AHyperTwistClassicCubePlayerController::SetupInputComponent() +{ + Super::SetupInputComponent(); + if (InputComponent == nullptr) + { + return; + } + + InputComponent->BindKey( + EKeys::LeftMouseButton, + IE_Pressed, + this, + &AHyperTwistClassicCubePlayerController::HandlePrimaryClick + ); + + if (bEnableCounterClockwiseRightClick) + { + InputComponent->BindKey( + EKeys::RightMouseButton, + IE_Pressed, + this, + &AHyperTwistClassicCubePlayerController::HandleSecondaryClick + ); + } + + if (bEnableTouchTurnInput) + { + InputComponent->BindTouch( + IE_Pressed, + this, + &AHyperTwistClassicCubePlayerController::HandleTouchPressed + ); + } + + if (bBindFreshAttemptShortcut) + { + InputComponent->BindKey( + EKeys::R, + IE_Pressed, + this, + &AHyperTwistClassicCubePlayerController::HandleFreshAttemptShortcut + ); + } +} + +bool AHyperTwistClassicCubePlayerController::TryProcessCubeClickFromCursor(const bool bCounterClockwise) +{ + float ScreenX = 0.0f; + float ScreenY = 0.0f; + if (!GetMousePosition(ScreenX, ScreenY)) + { + return false; + } + + return TryProcessCubeClickFromScreenPosition(FVector2D(ScreenX, ScreenY), bCounterClockwise); +} + +bool AHyperTwistClassicCubePlayerController::TryProcessCubeClickFromScreenPosition( + const FVector2D& ScreenPosition, + const bool bCounterClockwise +) +{ + FVector RayOrigin = FVector::ZeroVector; + FVector RayDirection = FVector::ZeroVector; + if (!DeprojectScreenPositionToWorld(ScreenPosition.X, ScreenPosition.Y, RayOrigin, RayDirection)) + { + return false; + } + + if (AHyperTwistClassicCubeActor* CubeActor = ResolveClassicCubeActor()) + { + return CubeActor->ProcessClick(RayOrigin, RayDirection, bCounterClockwise); + } + + return false; +} + +void AHyperTwistClassicCubePlayerController::RequestFreshAttempt() +{ + if (GetWorld() == nullptr) + { + return; + } + + if (AHyperTwistClassicCubeGameMode* GameMode = + Cast(GetWorld()->GetAuthGameMode())) + { + GameMode->StartFreshAttempt(); + } +} + +void AHyperTwistClassicCubePlayerController::ApplyClassicCubeInputMode() +{ + bShowMouseCursor = true; + bEnableClickEvents = true; + bEnableMouseOverEvents = true; + + if (bUseGameAndUiInputMode) + { + FInputModeGameAndUI InputMode; + InputMode.SetHideCursorDuringCapture(false); + InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); + SetInputMode(InputMode); + } +} + +AHyperTwistClassicCubeActor* AHyperTwistClassicCubePlayerController::ResolveClassicCubeActor() const +{ + if (GetWorld() == nullptr) + { + return nullptr; + } + + if (const AHyperTwistClassicCubeGameMode* GameMode = + Cast(GetWorld()->GetAuthGameMode())) + { + if (GameMode->ActiveCubeActor != nullptr) + { + return GameMode->ActiveCubeActor; + } + } + + for (TActorIterator ActorIt(GetWorld()); ActorIt; ++ActorIt) + { + return *ActorIt; + } + + return nullptr; +} + +void AHyperTwistClassicCubePlayerController::HandlePrimaryClick() +{ + if (const AHyperTwistClassicCubeGameMode* GameMode = + Cast(GetWorld() != nullptr ? GetWorld()->GetAuthGameMode() : nullptr)) + { + if (GameMode->ActiveHudWidget != nullptr && GameMode->ActiveHudWidget->IsNewScrambleButtonHovered()) + { + return; + } + } + + TryProcessCubeClickFromCursor(false); +} + +void AHyperTwistClassicCubePlayerController::HandleSecondaryClick() +{ + if (IsInputKeyDown(EKeys::LeftShift) || IsInputKeyDown(EKeys::RightShift)) + { + return; + } + + TryProcessCubeClickFromCursor(true); +} + +void AHyperTwistClassicCubePlayerController::HandleFreshAttemptShortcut() +{ + RequestFreshAttempt(); +} + +void AHyperTwistClassicCubePlayerController::HandleTouchPressed( + const ETouchIndex::Type FingerIndex, + const FVector Location +) +{ + static_cast(FingerIndex); + TryProcessCubeClickFromScreenPosition(FVector2D(Location.X, Location.Y), false); +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBrowser/HyperTwistBrowserWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBrowser/HyperTwistBrowserWidget.h index c3e1df9..528dfdf 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBrowser/HyperTwistBrowserWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistBrowser/HyperTwistBrowserWidget.h @@ -46,6 +46,9 @@ public: UFUNCTION(BlueprintPure, Category = "HyperTwist|Browser") static FString ResolveBundledBrowserShellUrl(); + UFUNCTION(BlueprintPure, Category = "HyperTwist|Browser") + static FString ResolveBundledBrowserShellSourcePath(); + protected: virtual TSharedRef RebuildWidget() override; virtual void ReleaseSlateResources(bool bReleaseChildren) override; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h index 696234e..a87e2ed 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeActor.h @@ -68,6 +68,11 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube") bool ProcessClick(const FVector& RayOrigin, const FVector& RayDirection, bool bCounterClockwise = false); + static bool TryResolveFaceFromImpactNormal( + const FVector& ImpactNormal, + EHyperTwistClassicCubeFace& OutFace + ); + /** Generate a random scramble of the given length (WCA-style notation). */ UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube") TArray GenerateScramble(int32 Length = 20); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h index 567bdd7..2a81c24 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h @@ -6,6 +6,8 @@ #include "HyperTwistClassicCubeGameMode.generated.h" class AHyperTwistClassicCubeActor; +class AHyperTwistClassicCubeOrbitPawn; +class AHyperTwistClassicCubePlayerController; class UHyperTwistClassicCubeHUDWidget; class UHyperTwistTrainingSubsystem; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h index 7e4c3d4..f4f03d7 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h @@ -4,6 +4,7 @@ #include "Blueprint/UserWidget.h" #include "HyperTwistClassicCubeHUDWidget.generated.h" +class UButton; class UTextBlock; class UVerticalBox; @@ -21,12 +22,26 @@ public: const FString& InScrambleLine, const FString& InTimerLine, const FString& InInspectionLine, - const FString& InResultLine + const FString& InResultLine, + const FString& InControlsLine ); + UFUNCTION(BlueprintPure, Category = "HyperTwist|ClassicCube|HUD") + bool IsNewScrambleButtonHovered() const; + private: void EnsureWidgetTreeBuilt(); UTextBlock* AddLine(UVerticalBox* Parent, const TCHAR* WidgetName); + void AddActionButton(UVerticalBox* Parent); + + UFUNCTION() + void HandleNewScrambleClicked(); + + UFUNCTION() + void HandleNewScrambleHovered(); + + UFUNCTION() + void HandleNewScrambleUnhovered(); UPROPERTY(Transient) TObjectPtr StatusTextBlock = nullptr; @@ -42,4 +57,15 @@ private: UPROPERTY(Transient) TObjectPtr ResultTextBlock = nullptr; + + UPROPERTY(Transient) + TObjectPtr ControlsTextBlock = nullptr; + + UPROPERTY(Transient) + TObjectPtr NewScrambleButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr NewScrambleButtonLabel = nullptr; + + bool bNewScrambleButtonHovered = false; }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h new file mode 100644 index 0000000..a4fdf2e --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h @@ -0,0 +1,76 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Pawn.h" +#include "HyperTwistClassicCubeOrbitPawn.generated.h" + +class UCameraComponent; +class USceneComponent; +class USpringArmComponent; + +UCLASS(BlueprintType, Blueprintable) +class UNREALHYPERTWIST_API AHyperTwistClassicCubeOrbitPawn : public APawn +{ + GENERATED_BODY() + +public: + AHyperTwistClassicCubeOrbitPawn(); + + virtual void BeginPlay() override; + virtual void Tick(float DeltaSeconds) override; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + FVector OrbitFocusPoint = FVector::ZeroVector; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float InitialArmLength = 360.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float MinimumArmLength = 180.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float MaximumArmLength = 720.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float ZoomStep = 42.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float OrbitYawDegreesPerPixel = 0.25f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float OrbitPitchDegreesPerPixel = 0.2f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float MinimumPitchDegrees = -75.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float MaximumPitchDegrees = -10.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float InitialYawDegrees = 45.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + float InitialPitchDegrees = -28.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + bool bUseMiddleMouseOrbit = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Camera") + bool bUseRightMouseOrbitWithShift = false; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube|Camera") + TObjectPtr SceneRoot = nullptr; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube|Camera") + TObjectPtr SpringArm = nullptr; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube|Camera") + TObjectPtr CameraComponent = nullptr; + +protected: + void ApplyOrbitTransform(); + bool ShouldOrbitFromMouseInput() const; + + float CurrentYawDegrees = 0.0f; + float CurrentPitchDegrees = 0.0f; +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubePlayerController.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubePlayerController.h new file mode 100644 index 0000000..ccf3bc9 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubePlayerController.h @@ -0,0 +1,48 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/PlayerController.h" +#include "HyperTwistClassicCubePlayerController.generated.h" + +class AHyperTwistClassicCubeActor; + +UCLASS(BlueprintType, Blueprintable) +class UNREALHYPERTWIST_API AHyperTwistClassicCubePlayerController : public APlayerController +{ + GENERATED_BODY() + +public: + AHyperTwistClassicCubePlayerController(); + + virtual void BeginPlay() override; + virtual void SetupInputComponent() override; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Input") + bool bUseGameAndUiInputMode = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Input") + bool bEnableCounterClockwiseRightClick = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Input") + bool bEnableTouchTurnInput = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|Input") + bool bBindFreshAttemptShortcut = true; + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Input") + bool TryProcessCubeClickFromCursor(bool bCounterClockwise = false); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Input") + bool TryProcessCubeClickFromScreenPosition(const FVector2D& ScreenPosition, bool bCounterClockwise = false); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|Input") + void RequestFreshAttempt(); + +protected: + void ApplyClassicCubeInputMode(); + AHyperTwistClassicCubeActor* ResolveClassicCubeActor() const; + void HandlePrimaryClick(); + void HandleSecondaryClick(); + void HandleFreshAttemptShortcut(); + void HandleTouchPressed(ETouchIndex::Type FingerIndex, FVector Location); +}; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistBrowserBridgeObjectTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistBrowserBridgeObjectTest.cpp index 0ed9f16..534a4b0 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistBrowserBridgeObjectTest.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistBrowserBridgeObjectTest.cpp @@ -1,6 +1,8 @@ // Copyright HyperTwist, Inc. All Rights Reserved. #include "Misc/AutomationTest.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" #include "HyperTwistBrowser/HyperTwistBrowserBridgeObject.h" #include "HyperTwistBrowser/HyperTwistBrowserWidget.h" @@ -69,8 +71,55 @@ bool FHyperTwistBrowserWidgetBundledShellUrlTest::RunTest(const FString& Paramet BundledShellUrl.StartsWith(TEXT("file:///")) ); TestTrue( - TEXT("The bundled browser shell URL must point at the Browser shell."), - BundledShellUrl.Contains(TEXT("Content/Browser")) + TEXT("The bundled browser shell URL must point at the authoritative Browser shell source."), + BundledShellUrl.Contains(TEXT("Content/Browser/index.html")) + ); + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistBrowserWidgetAuthoritativeShellArtifactsTest, + "HyperTwist.Browser.Widget.AuthoritativeShellArtifacts", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistBrowserWidgetAuthoritativeShellArtifactsTest::RunTest(const FString& Parameters) +{ + const FString ShellSourcePath = UHyperTwistBrowserWidget::ResolveBundledBrowserShellSourcePath(); + const FString BootstrapScriptPath = FPaths::Combine( + FPaths::ProjectDir(), + TEXT(".."), + TEXT("Content"), + TEXT("Browser"), + TEXT("src"), + TEXT("browser-runtime-bootstrap.js") + ); + const FString FallbackRuntimePath = FPaths::Combine( + FPaths::ProjectDir(), + TEXT(".."), + TEXT("Content"), + TEXT("Browser"), + TEXT("src"), + TEXT("browser-spatial-runtime-fallback.js") + ); + + TestTrue(TEXT("The authoritative browser shell source file must exist."), FPaths::FileExists(ShellSourcePath)); + TestTrue(TEXT("The browser bootstrap script must exist."), FPaths::FileExists(BootstrapScriptPath)); + TestTrue(TEXT("The clean-checkout fallback runtime must exist."), FPaths::FileExists(FallbackRuntimePath)); + + FString ShellSourceHtml; + TestTrue( + TEXT("The authoritative browser shell HTML must be readable."), + FFileHelper::LoadFileToString(ShellSourceHtml, *ShellSourcePath) + ); + TestTrue( + TEXT("The authoritative browser shell must bootstrap through the plain-JS loader."), + ShellSourceHtml.Contains(TEXT("browser-runtime-bootstrap.js")) + ); + TestFalse( + TEXT("The authoritative browser shell must not point directly at a TypeScript source entrypoint."), + ShellSourceHtml.Contains(TEXT("browser-spatial-runtime.ts")) ); return true; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeActorTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeActorTest.cpp index 44d4131..6401dd1 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeActorTest.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeActorTest.cpp @@ -25,6 +25,53 @@ namespace HyperTwistClassicCubeActorTestInternal } } +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeImpactNormalMappingTest, + "HyperTwist.Simulation.ClassicCube.ImpactNormalMapping", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeImpactNormalMappingTest::RunTest(const FString& Parameters) +{ + EHyperTwistClassicCubeFace ResolvedFace = EHyperTwistClassicCubeFace::Up; + TestTrue( + TEXT("Positive Z normals must resolve to the Up face."), + AHyperTwistClassicCubeActor::TryResolveFaceFromImpactNormal(FVector(0.01f, 0.02f, 1.0f), ResolvedFace) + && ResolvedFace == EHyperTwistClassicCubeFace::Up + ); + TestTrue( + TEXT("Negative Z normals must resolve to the Down face."), + AHyperTwistClassicCubeActor::TryResolveFaceFromImpactNormal(FVector(0.0f, -0.02f, -1.0f), ResolvedFace) + && ResolvedFace == EHyperTwistClassicCubeFace::Down + ); + TestTrue( + TEXT("Positive Y normals must resolve to the Front face."), + AHyperTwistClassicCubeActor::TryResolveFaceFromImpactNormal(FVector(0.01f, 1.0f, 0.2f), ResolvedFace) + && ResolvedFace == EHyperTwistClassicCubeFace::Front + ); + TestTrue( + TEXT("Negative Y normals must resolve to the Back face."), + AHyperTwistClassicCubeActor::TryResolveFaceFromImpactNormal(FVector(0.01f, -1.0f, 0.2f), ResolvedFace) + && ResolvedFace == EHyperTwistClassicCubeFace::Back + ); + TestTrue( + TEXT("Positive X normals must resolve to the Right face."), + AHyperTwistClassicCubeActor::TryResolveFaceFromImpactNormal(FVector(1.0f, 0.1f, 0.1f), ResolvedFace) + && ResolvedFace == EHyperTwistClassicCubeFace::Right + ); + TestTrue( + TEXT("Negative X normals must resolve to the Left face."), + AHyperTwistClassicCubeActor::TryResolveFaceFromImpactNormal(FVector(-1.0f, 0.1f, 0.1f), ResolvedFace) + && ResolvedFace == EHyperTwistClassicCubeFace::Left + ); + TestFalse( + TEXT("A zero impact normal must not resolve to a face."), + AHyperTwistClassicCubeActor::TryResolveFaceFromImpactNormal(FVector::ZeroVector, ResolvedFace) + ); + + return true; +} + IMPLEMENT_SIMPLE_AUTOMATION_TEST( FHyperTwistClassicCubeScrambleQualityTest, "HyperTwist.Simulation.ClassicCube.ScrambleQuality", diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeGameModeTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeGameModeTest.cpp new file mode 100644 index 0000000..61e5d93 --- /dev/null +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeGameModeTest.cpp @@ -0,0 +1,95 @@ +// Copyright HyperTwist, Inc. All Rights Reserved. + +#include "Misc/AutomationTest.h" + +#include "HyperTwistSimulation/HyperTwistClassicCubeGameMode.h" +#include "HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h" +#include "HyperTwistSimulation/HyperTwistClassicCubePlayerController.h" + +#if WITH_AUTOMATION_TESTS + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeGameModeDefaultsTest, + "HyperTwist.Simulation.ClassicCube.GameModeDefaults", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeGameModeDefaultsTest::RunTest(const FString& Parameters) +{ + AHyperTwistClassicCubeGameMode* GameMode = NewObject(); + TestNotNull(TEXT("The classic cube game mode must be constructible."), GameMode); + if (GameMode == nullptr) + { + return false; + } + + TestEqual( + TEXT("The classic cube game mode must default to the first-party classic cube player controller."), + GameMode->PlayerControllerClass, + AHyperTwistClassicCubePlayerController::StaticClass() + ); + TestEqual( + TEXT("The classic cube game mode must default to the first-party orbit camera pawn."), + GameMode->DefaultPawnClass, + AHyperTwistClassicCubeOrbitPawn::StaticClass() + ); + TestTrue(TEXT("The classic cube game mode must still auto-create its HUD by default."), GameMode->bAutoCreateHud); + TestTrue(TEXT("The classic cube game mode must still auto-spawn the cube actor by default."), GameMode->bAutoSpawnCubeActor); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubePlayerControllerDefaultsTest, + "HyperTwist.Simulation.ClassicCube.PlayerControllerDefaults", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubePlayerControllerDefaultsTest::RunTest(const FString& Parameters) +{ + AHyperTwistClassicCubePlayerController* PlayerController = + NewObject(); + TestNotNull(TEXT("The classic cube player controller must be constructible."), PlayerController); + if (PlayerController == nullptr) + { + return false; + } + + TestTrue(TEXT("The classic cube player controller must show the mouse cursor by default."), PlayerController->bShowMouseCursor); + TestTrue(TEXT("The classic cube player controller must keep touch input enabled by default."), PlayerController->bEnableTouchTurnInput); + TestTrue( + TEXT("The classic cube player controller must bind a fresh-attempt shortcut by default."), + PlayerController->bBindFreshAttemptShortcut + ); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FHyperTwistClassicCubeOrbitPawnDefaultsTest, + "HyperTwist.Simulation.ClassicCube.OrbitPawnDefaults", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter +) + +bool FHyperTwistClassicCubeOrbitPawnDefaultsTest::RunTest(const FString& Parameters) +{ + AHyperTwistClassicCubeOrbitPawn* OrbitPawn = NewObject(); + TestNotNull(TEXT("The classic cube orbit pawn must be constructible."), OrbitPawn); + if (OrbitPawn == nullptr) + { + return false; + } + + TestNotNull(TEXT("The orbit pawn must allocate a spring arm component."), OrbitPawn->SpringArm); + TestNotNull(TEXT("The orbit pawn must allocate a camera component."), OrbitPawn->CameraComponent); + TestTrue( + TEXT("The orbit pawn must expose a sane zoom range."), + OrbitPawn->MinimumArmLength < OrbitPawn->InitialArmLength + && OrbitPawn->InitialArmLength < OrbitPawn->MaximumArmLength + ); + TestTrue( + TEXT("The orbit pawn must support at least one mouse-orbit gesture by default."), + OrbitPawn->bUseMiddleMouseOrbit || OrbitPawn->bUseRightMouseOrbitWithShift + ); + return true; +} + +#endif diff --git a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md index 8899433..5800480 100644 --- a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md +++ b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md @@ -128,8 +128,9 @@ for the full 29-repo queue and per-repo wiring posture. ### 1H — Browser Spatial Stack (13 JS repos) ✅ COMPLETE - [x] Add the browser-capable repos as npm dependencies or prebuilt runtime scripts in `Content/Browser/` - [x] Repos: `NuiLab/code-vr`, `pissang/claygl`, `pissang/clay-viewer`, `pmndrs/postprocessing`, `pmndrs/react-postprocessing`, `pmndrs/drei`, `pmndrs/uikit`, `pmndrs/three-stdlib`, `pmndrs/maath`, `pmndrs/zustand`, `pmndrs/leva`, `pmndrs/use-gesture`, `pmndrs/react-spring` + sub-packages -- [x] Build once via npm → browser runtime shell and bundle under `Content/Browser/` -- [x] Load the runtime in `UHyperTwistBrowserWidget` +- [x] Build the browser runtime bundle via npm into `Content/Browser/dist/browser-spatial-runtime.js` +- [x] Keep `Content/Browser/index.html` as the authoritative browser shell; it upgrades into the bundled runtime when present and remains runnable through the committed plain-JS fallback when build artifacts have been cleaned +- [x] Load the authoritative shell in `UHyperTwistBrowserWidget` - [x] State bridge: embedded Unreal bridge object plus browser `postMessage` fallback for shell-state and command envelopes ### 1I — Analytics Stack (2 JS repos) ✅ COMPLETE @@ -142,6 +143,12 @@ for the full 29-repo queue and per-repo wiring posture. - [x] Bundle the runtime-capable packages into `Content/Browser/` alongside already-landed `google/model-viewer` - [x] Keep `space-opera` visible as an editor-sidecar and `render-fidelity-tools` visible as a QA-sidecar instead of pretending either is default gameplay runtime +### 1K — Authoritative Browser Shell & Clean-Checkout Runtime ✅ COMPLETE +- [x] Make `Content/Browser/index.html` the runtime authority instead of treating transient `dist/index.html` output as the shell +- [x] Add a bootstrap loader that prefers `dist/browser-spatial-runtime.js` and falls back to a committed plain-JS runtime when the bundle is absent +- [x] Queue early Unreal command and shell-state pushes during bootstrap so the browser lane remains deterministic even if the JS runtime finishes loading slightly later than the CEF widget +- [x] Add shell-verification coverage proving the authoritative shell, bootstrap script, and fallback runtime exist and no longer point directly at a TypeScript source entrypoint + **Estimated actions:** 40–60 per repo (UHT regen on first include) **Estimated time:** 1–2 days per repo (build-debug cycles) @@ -189,6 +196,17 @@ for the full 29-repo queue and per-repo wiring posture. - [x] Scramble notation panel - [x] Bind timer start to first face turn, stop to solved-state detection +### 2F — First-Party Mouse/Touch Ownership Lane ✅ COMPLETE +- [x] Add `AHyperTwistClassicCubePlayerController` as the first-party click/touch owner instead of leaving `ProcessClick()` behind implied Blueprint glue +- [x] Bind left-click to clockwise turns, right-click to counter-clockwise turns, and touch press to clockwise turns +- [x] Default `AHyperTwistClassicCubeGameMode` to the first-party player controller so the classic cube loop is owned in code, not only in roadmap prose +- [x] Add focused coverage for face-resolution mapping and controller ownership defaults + +### 2G — Loop Readiness Hardening ✅ COMPLETE +- [x] Add a visible control-hint line to the first-party HUD so the runtime communicates how to interact with the cube without requiring extra Blueprint notes +- [x] Bind `R` as a first-party fresh-attempt shortcut so the training loop can restart without waiting on a future content-only HUD button +- [x] Keep the packaged-loop next step narrowed to actual packaging/level/camera/material work instead of unfinished click ownership + **Estimated actions:** 80–120 (geometry + input + HUD) **Estimated time:** 3–5 days @@ -202,13 +220,14 @@ for the full 29-repo queue and per-repo wiring posture. - [ ] Create `L_HyperTwist_ClassicTraining` (`.umap`) - [ ] Place `AHyperTwistClassicCubeActor` at world origin - [ ] Add directional light + ambient environment (basic, not immersive yet) -- [ ] Add `WBP_HyperTwistGameHUD` to viewport -- [ ] Add camera pawn with orbit controls (mouse drag orbits, scroll zooms) +- [ ] Add `WBP_HyperTwistGameHUD` to viewport (optional polish replacement for the first-party runtime HUD that already auto-adds to viewport) +- [x] Add first-party orbit camera pawn with mouse-drag orbit and scroll-wheel zoom (`AHyperTwistClassicCubeOrbitPawn`) so camera interaction is owned in code before the `.umap` polish pass exists ### 3B — Game Mode -- [ ] Create `AHyperTwistClassicGameMode` : `AGameModeBase` -- [ ] Handle game state: `Idle`, `Inspecting`, `Solving`, `Solved` -- [ ] On `Solved`: stop timer, show solve time, offer "New Scramble" +- [x] Create first-party `AHyperTwistClassicCubeGameMode` : `AGameModeBase` +- [x] Handle game state across preparation, scramble playback, inspection, solving, and solved transitions +- [x] Default to the first-party `AHyperTwistClassicCubePlayerController` so click/touch interaction is owned in code +- [x] Add an in-HUD button for "New Scramble" alongside the keyboard shortcut so restart does not depend on remembering `R` ### 3C — Build & Package Validation - [ ] Package `UnrealHyperTwist` for Windows (not just Editor build) @@ -323,13 +342,13 @@ for the full 29-repo queue and per-repo wiring posture. **Prerequisite:** Phase 2 (classic renderer) ### 8A — Embedded Browser Runtime -- [ ] Add `WebBrowser` plugin to `.uproject` -- [ ] Create `UHyperTwistBrowserWidget` wrapping `SWebBrowser` -- [ ] Load local `three.js` + `react-three-fiber` build +- [x] Add `WebBrowserWidget` plugin to `.uproject` +- [x] Create `UHyperTwistBrowserWidget` wrapping `SWebBrowser` +- [x] Load the authoritative local browser shell in `Content/Browser/index.html`, with bundled-runtime upgrade plus clean-checkout plain-JS fallback ### 8B — State Synchronization -- [ ] Bridge UE cube state to browser via `FJsonObject` → JavaScript `postMessage` -- [ ] Browser renders cube using Three.js; UE handles input and logic +- [x] Bridge UE cube state to browser via embedded Unreal bridge object plus JavaScript `postMessage` fallback +- [x] Browser shell surfaces adapter, analytics, viewer, and native-sidecar request flows while UE retains native input, game state, and puzzle logic ownership - [ ] Alternative: full browser client with HTTP API to UE backend **Estimated actions:** 50–80 @@ -414,24 +433,30 @@ for the full 29-repo queue and per-repo wiring posture. | Add struct pairs (J-HG → J-HM) | Wire repos (freestyle, piper, rob-twophase) | | Tests verify contract IDs | Tests verify cube rotation, solve accuracy, speech | | Docs claim features are "landed" | Features are playable before documented | -| 318 test files, 0 gameplay | 30 test files, 1 playable level | +| 318 test files, 0 gameplay | 30+ targeted tests, 1 controller-owned classic-cube runtime loop | | 1,142 docs, 20 content assets | Docs trimmed to functional truth | | No donor repos linked | C++ repos compiled and linked | -| No .uasset, .umap | Procedural geometry + playable level | +| No .uasset, .umap | Procedural geometry + first-party runtime loop, with level packaging still pending | --- ## Immediate Next Step -**Phase 2E + 3:** Timer/HUD and First Playable Level. These require Unreal Editor -content creation (UMG widgets, materials, levels, game mode blueprints) that cannot -be created from text/C++ alone. The C++ backend is ready: +**Phase 3A + 3C:** first playable level packaging and presentation proof. The repaired +code lane now owns browser-shell authority, click/touch interaction, scramble playback, +HUD timing, fresh-attempt restart, and orbit-camera ownership in first-party code. The remaining next move is +content/presentation/package proof that cannot be fully closed from the current text-only lane: - `AHyperTwistClassicCubeActor` with geometry, rotation, click input, scramble +- `AHyperTwistClassicCubePlayerController` with left/right click, touch, and `R`-to-restart ownership +- `AHyperTwistClassicCubeOrbitPawn` with middle-mouse drag orbit and scroll-wheel zoom +- `AHyperTwistClassicCubeGameMode` with scramble settlement, inspection timing, solve timing, and solved-state submission - `UHyperTwistSolverLibrary` with rob-twophase two-phase solver +- `UHyperTwistBrowserWidget` with authoritative shell loading plus bundled-runtime/fallback browser bootstrapping Editor-side work needed: - Create 6 simple colored materials (White, Yellow, Green, Blue, Orange, Red) -- Create `WBP_HyperTwistGameHUD` UMG widget with timer and scramble display -- Create `L_HyperTwist_ClassicTraining` level with cube actor, lights, camera -- Create Blueprint GameMode binding click input to `ProcessClick()` +- Optional polish-only replacement `WBP_HyperTwistGameHUD` if the first-party runtime widget should be visually upgraded beyond the code-built HUD +- Create `L_HyperTwist_ClassicTraining` level with cube actor, lights, and the orbit camera pawn placed or configured as the preferred presentation lane +- Add orbit-camera controls and any packaged-build specific presentation polish +- Package and launch the Windows build to prove the repaired runtime loop in a packaged environment diff --git a/docs/ops/HYPERTWIST_UNWIRED_REPO_INVENTORY_AND_WIRING_SCHEDULE_2026-06-10.md b/docs/ops/HYPERTWIST_UNWIRED_REPO_INVENTORY_AND_WIRING_SCHEDULE_2026-06-10.md index 14680b9..2c76998 100644 --- a/docs/ops/HYPERTWIST_UNWIRED_REPO_INVENTORY_AND_WIRING_SCHEDULE_2026-06-10.md +++ b/docs/ops/HYPERTWIST_UNWIRED_REPO_INVENTORY_AND_WIRING_SCHEDULE_2026-06-10.md @@ -3,6 +3,7 @@ **Date:** 2026-06-10 **Source authority:** `HYPERTWIST_REPO_STATE_BOARD_2026-05-11.md`, `HYPERTWIST_PHASE_0R_PACKET_0R_B_EVALUATION_2026-05-12.md`, `HYPERTWIST_REPO_EVALUATION_RESET_AND_IMPLEMENTATION_SCHEDULE_2026-05-11.md` **Status:** Reconstructed from canonical docs; counts verified against board totals. +**2026-06-11 repair update:** this inventory now preserves the original queue history while also reflecting the repaired current posture for the browser-runtime and speech rows that were previously left stale after newer Phase 1 implementation work. --- @@ -16,13 +17,13 @@ | Speech rows outside 71-row set (attribution-only) | 4 | | **Total non-live** | **46** | -The **29 in the wiring queue** are the implementation targets. The 13 reference/discard rows and the 4 speech rows are accounted for but not scheduled for direct wiring unless a later decision changes their posture. +The **29 in the wiring queue** are the original implementation targets from the reconstructed board snapshot. The 13 reference/discard rows and the 4 speech rows are accounted for separately. Some of the original queue items have now landed through the repaired Phase 1 browser-runtime work; their current posture is called out below rather than erased from the historical count view. --- ## The 29 — Permissive Wiring Queue (0R-B minus live promotions) -These repos were evaluated in `Packet 0R-B` (36 repos). Seven later graduated to live status via Phase 3R and 6R packets. The remaining 29 are retained but **not yet wired** into the Unreal build. +These repos were evaluated in `Packet 0R-B` (36 repos). Seven later graduated to live status via Phase 3R and 6R packets. This section preserves the original retained queue, but the current repaired posture now includes live/browser-backed closure for the `1E`, `1G`, `1H`, `1I`, and `1J` browser-runtime families through the authoritative `Content/Browser` shell plus the embedded `UHyperTwistBrowserWidget` bridge. ### Wave A — Solver & Recognition Adjuncts (2 repos) @@ -70,7 +71,7 @@ These repos were evaluated in `Packet 0R-B` (36 repos). Seven later graduated to | # | Repo | License | Lang | What It Does | Wiring Approach | Target Phase | |---|------|---------|------|--------------|-----------------|--------------| | 22 | `KhronosGroup/glTF-Sample-Viewer` | Apache-2.0 | JS | Reference glTF viewer | Embedded browser runtime; standard compliance check | 8 (Browser renderer) | -| 23 | `KhronosGroup/glTF-Sample-Renderer` | Apache-2.0 | C++ | Reference glTF renderer | **Direct C++ link** via `ThirdParty/`; evaluate for asset import pipeline | 2A (Geometry) | +| 23 | `KhronosGroup/glTF-Sample-Renderer` | Apache-2.0 | C++/JS mix | Reference glTF renderer | Browser/runtime reference viewer posture through the first-party `Content/Browser` shell; do not over-claim a direct Unreal static-link lane for this packet | 1E (Browser runtime) | ### Wave F — React-Spring Sub-Packages (6 repos) @@ -89,9 +90,12 @@ These repos were evaluated in `Packet 0R-B` (36 repos). Seven later graduated to ### Direct C++ Link (Only for true native-link repos) - `efrantar/rob-twophase` -- `rhasspy/piper` - `freestyle-voice/freestyle` (if embedding; otherwise IPC) +### First-Party External Engine / Sidecar Seam +- `rhasspy/piper` — first-party synthesis boundary with retained donor source and local/offline voice-model posture, without overstating direct Unreal static-link closure in the current packet +- `freestyle-voice/freestyle` — sidecar/session boundary is still the primary accepted posture unless a later packet explicitly reopens plugin-hosting work + ### Embedded Browser Runtime (For JS/TS repos) - Requires `WebBrowser` plugin in `.uproject` - Requires Node.js build pipeline or prebuilt bundles in `Content/Browser/` @@ -116,12 +120,12 @@ These repos were evaluated in `Packet 0R-B` (36 repos). Seven later graduated to | Repo | License | Status | New Wiring Plan | |------|---------|--------|-----------------| | `ggml-org/whisper.cpp` | MIT | **Replaced** | No longer primary STT; retain as benchmark/reference | -| `rhasspy/piper` | MIT | **Queued** | Phase 1C — compile as static lib, link directly | +| `rhasspy/piper` | MIT | **Live in first-party external-engine posture** | Phase 1C now lands `UHyperTwistSpeechLibrary::Synthesize(...)` plus bounded local/offline narration seams without pretending the donor is already a direct Unreal static-link lane | | `SYSTRAN/faster-whisper` | MIT | **Queued** | Retain as alternative STT benchmark; no active wiring | | `coqui-ai/TTS` | MPL-2.0 | **Queued** | Retain as TTS benchmark; piper is primary | **STT primary:** `freestyle-voice/freestyle` (MIT, cloud-API client) -**TTS primary:** `rhasspy/piper` (MIT, local C++ engine) +**TTS primary:** `rhasspy/piper` (MIT, retained local/offline engine with first-party synthesis boundary) --- @@ -150,14 +154,18 @@ Not scheduled for wiring. Listed for completeness: |-----------|------|-------------|------------| | 1A | `efrantar/rob-twophase` | Static library, direct link | C++ | | 1B | `freestyle-voice/freestyle` | Sidecar HTTP or embedded | Rust/TS | -| 1C | `rhasspy/piper` | Static library, direct link | C++ | +| 1C | `rhasspy/piper` | First-party synthesis boundary over retained local/offline donor posture | C++/sidecar | | 1D | `brownan/Rubiks-Cube-Solver` | IPC process (GPL) | Python | | 1E | `KhronosGroup/glTF-Sample-Renderer` | Embedded browser runtime reference viewer | JS/browser | | 1F | `cahidenes/rubiks-cube-solver` | IPC process | Python | | 1G | `tentone/rubix-solver` | Browser fallback shell + native-sidecar bridge | Native/browser | +Current repaired note: +- `1E`, `1G`, `1H`, `1I`, and `1J` now close through the authoritative `Content/Browser/index.html` shell, bundled-runtime upgrade path, committed clean-checkout fallback runtime, and embedded `UHyperTwistBrowserWidget` / bridge-object lane. +- The browser queue is no longer blocked on retaining `dist/index.html`; build artifacts can be cleaned after validation because the authoritative shell remains runnable without them. + --- ## Next Step -**Phase 0:** Create `.external/`, `ThirdParty/`, `.gitmodules`, and `Build.cs` rules so that Phase 1 repos can actually compile and link. +**Current next step after repair:** package-proof and presentation-proof work, not Phase 0. The repaired code lane already owns the authoritative browser shell, native-sidecar browser bridge, first-party classic-cube player controller, first-party orbit camera pawn, and first-party timer/HUD loop including GUI or keyboard scramble restart. The remaining best move is Windows packaged-build validation plus the level/material polish that still requires Unreal Editor content work. diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md index d59463d..9a367dc 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -142,7 +142,7 @@ Do not collapse those three tiers into one undifferentiated "features" voice. | First-party provider-neutral custom-endpoint runtime routing | Implemented now | landed first-party `Phase 6R-AI` | First-party per-session provider-profile endpoint selection, runtime routing, fallback-health reflection, and bounded provider-backed transport-failure posture are now live above the landed provider-profile/BYOK and provider-routing seams. Provider-specific overlays, payment execution, provider-portal ownership, and payload shipping remain deferred. | | Analytics/reporting surfaces | Implemented now | landed analytics/reporting packets | Reporting is real, but bounded to accepted retained slices. | | Rewritten training analytics and report reference grounding | Implemented now | `apache/echarts` retained permissive lane + first-party current code | Current live `Training Analytics` reference side includes four rewritten first-party targets grounded in retained `apache/echarts`: session outcome and progress reporting, analytics data-view and export, timing-trend history and overview interaction, and the optional richer explainer or sidecar boundary. This does not displace the landed `Phase 3R-C` first-party analytics/reporting owner or elevate `ecomfe/echarts-gl` and `ecomfe/zrender` beyond support-only sidecars. | -| Browser/spatial/media adjunct surfaces | Implemented now | landed `three.js`, `react-three-fiber`, `xr`, `model-viewer`, `remotion` packets | These are implemented bounded families, and the current Phase 1 browser-runtime landing now includes a first-party `Content/Browser` shell plus embedded `UHyperTwistBrowserWidget` bridge. This still is not proof of unlimited browser-shell parity. | +| Browser/spatial/media adjunct surfaces | Implemented now | landed `three.js`, `react-three-fiber`, `xr`, `model-viewer`, `remotion` packets | These are implemented bounded families, and the current Phase 1 browser-runtime landing now includes a first-party authoritative `Content/Browser/index.html` shell, bundled-runtime upgrade path, committed clean-checkout plain-JS fallback runtime, and embedded `UHyperTwistBrowserWidget` bridge. This still is not proof of unlimited browser-shell parity. | | Rewritten browser spatial scene and renderer reference grounding | Implemented now | `mrdoob/three.js` retained permissive lane + first-party current code | Current live `Browser 3D and XR Support` reference side includes one rewritten first-party target grounded in retained `mrdoob/three.js`: the browser spatial scene and renderer contract. This does not displace the landed `Phase 3R-F` first-party browser spatial owner trio or absorb the adjacent `react-three-fiber` renderer-bridge and `xr` session slices. | | Rewritten React-side browser renderer and event-bridge grounding | Implemented now | `pmndrs/react-three-fiber` retained permissive lane + first-party current code | Current live `Browser 3D and XR Support` reference side includes one rewritten first-party target grounded in retained `react-three-fiber`: the React scene renderer and event bridge. This does not displace the landed `Phase 3R-F` first-party browser spatial owner trio or absorb the adjacent `three.js` scene substrate and `xr` session slices. | | Rewritten browser XR session and immersive-interaction grounding | Implemented now | `pmndrs/xr` retained permissive lane + first-party current code | Current live `Browser 3D and XR Support` reference side includes one rewritten first-party target grounded in retained `pmndrs/xr`: the browser XR session and immersive interaction contract. This does not displace the landed `Phase 3R-F` first-party browser spatial owner trio or absorb the adjacent `three.js` scene substrate and `react-three-fiber` renderer-bridge slices. | @@ -161,6 +161,7 @@ repo. | Feature | Status | Primary authority | Notes | |---|---|---|---| | Native puzzle-state runtime | Implemented now | first-party runtime + landed donor packets | Core product identity. | +| Classic-cube playable runtime loop | Implemented now | first-party current code + landed timer/training substrate | First-party `AHyperTwistClassicCubeActor`, `AHyperTwistClassicCubeGameMode`, `AHyperTwistClassicCubePlayerController`, `AHyperTwistClassicCubeOrbitPawn`, and `UHyperTwistClassicCubeHUDWidget` now own the bounded classic-cube scramble/play/timer/solve loop with left-click, right-click, touch, middle-mouse drag orbit, scroll-wheel zoom, GUI or keyboard fresh-attempt restart, and solved-state submission in code. Packaged-build proof plus art/material/level polish remain separate next-step work. | | Classic-cubing semantic/runtime adapter | Implemented now | landed `cubing/cubing.js` packet | Live adapter family. | | Classic-cubing semantic, bridge, and `MPL`-boundary reference grounding | Implemented now | `cubing/cubing.js` retained boundary-sensitive lane + first-party current code | Current live `Classic Cubing Semantics and Runtime` reference side includes seven rewritten first-party contract/reference targets grounded in `cubing/cubing.js`: semantics, geometry, viewer adapter, device boundary, search contract, Melinda bridge, and explicit `MPL` compliance-boundary notes. This does not displace the landed `Phase 4R-A` first-party owner lane, the separate `cubing/twisty.js` replay shell lane, the separate `cubing/alg.js` parser/AST lane, or the explicit practical `MPL` path and notice-retention boundary. | | Seeded competition scramble workflow and lightweight scramble-operator shell adjuncts | Deep-source grounded retained | `cubing/cubing.js` retained lane + `cubing/mark3` / `cubing/scramble.cubing.net` successor evaluation | Source-backed successor surfaces sharpen competition-spec workflow and operator-shell expectations above the retained scramble and visualization seams, but they do not displace `cubing/cubing.js` or `cubing/twisty.js`; `scramble-display` remains comparison-only. |