Repair browser shell and close more phase 3 runtime

This commit is contained in:
axiomlogicnexus 2026-06-11 17:45:34 +00:00
parent 7b5e56fe4e
commit f529e8c9e9
28 changed files with 1738 additions and 87 deletions

View file

@ -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^).

View file

@ -327,9 +327,6 @@
</head>
<body>
<div id="app"></div>
<script
type="module"
src="/src/browser-spatial-runtime.ts"
></script>
<script src="./src/browser-runtime-bootstrap.js"></script>
</body>
</html>

View file

@ -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",

View file

@ -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.');

View file

@ -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);
})();

View file

@ -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'
});
})();

View file

@ -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'
});

View file

@ -1,4 +1,5 @@
type RuntimeListener = (payload: unknown) => void;
type PendingPayload = unknown;
function tryParseJsonPayload(value: unknown): unknown
{
@ -33,6 +34,10 @@ function getUnrealBridgeHandle(): Record<string, unknown> | null
const CommandListeners = new Set<RuntimeListener>();
const ShellStateListeners = new Set<RuntimeListener>();
let LastCommandPayload: PendingPayload | undefined;
let LastShellStatePayload: PendingPayload | undefined;
let bHasLastCommandPayload = false;
let bHasLastShellStatePayload = false;
function dispatchToListeners(Listeners: Set<RuntimeListener>, 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

View file

@ -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.'
)
);

View file

@ -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'
}
}
},

View file

@ -16,20 +16,7 @@ namespace HyperTwistBrowserWidgetInternal
FString ResolveBundledBrowserShellAbsolutePath()
{
const TArray<FString> 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<SWidget> UHyperTwistBrowserWidget::RebuildWidget()
{
if (BrowserBridgeObject == nullptr)

View file

@ -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<FString> AHyperTwistClassicCubeActor::GenerateScramble(int32 Length)
{
TArray<FString> Result;

View file

@ -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

View file

@ -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>(
UButton::StaticClass(),
TEXT("ClassicCubeHudNewScrambleButton")
);
if (NewScrambleButton == nullptr)
{
return;
}
NewScrambleButtonLabel = WidgetTree->ConstructWidget<UTextBlock>(
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<UButtonSlot>(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<AHyperTwistClassicCubeGameMode>(GetWorld()->GetAuthGameMode()))
{
GameMode->StartFreshAttempt();
}
}
void UHyperTwistClassicCubeHUDWidget::HandleNewScrambleHovered()
{
bNewScrambleButtonHovered = true;
}
void UHyperTwistClassicCubeHUDWidget::HandleNewScrambleUnhovered()
{
bNewScrambleButtonHovered = false;
}

View file

@ -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<USceneComponent>(TEXT("SceneRoot"));
RootComponent = SceneRoot;
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
SpringArm->SetupAttachment(SceneRoot);
SpringArm->bDoCollisionTest = false;
SpringArm->bEnableCameraLag = false;
SpringArm->bUsePawnControlRotation = false;
SpringArm->TargetArmLength = InitialArmLength;
CameraComponent = CreateDefaultSubobject<UCameraComponent>(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<void>(DeltaSeconds);
APlayerController* PlayerController = Cast<APlayerController>(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<APlayerController>(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;
}

View file

@ -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<AHyperTwistClassicCubeGameMode>(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<AHyperTwistClassicCubeGameMode>(GetWorld()->GetAuthGameMode()))
{
if (GameMode->ActiveCubeActor != nullptr)
{
return GameMode->ActiveCubeActor;
}
}
for (TActorIterator<AHyperTwistClassicCubeActor> ActorIt(GetWorld()); ActorIt; ++ActorIt)
{
return *ActorIt;
}
return nullptr;
}
void AHyperTwistClassicCubePlayerController::HandlePrimaryClick()
{
if (const AHyperTwistClassicCubeGameMode* GameMode =
Cast<AHyperTwistClassicCubeGameMode>(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<void>(FingerIndex);
TryProcessCubeClickFromScreenPosition(FVector2D(Location.X, Location.Y), false);
}

View file

@ -46,6 +46,9 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|Browser")
static FString ResolveBundledBrowserShellUrl();
UFUNCTION(BlueprintPure, Category = "HyperTwist|Browser")
static FString ResolveBundledBrowserShellSourcePath();
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
virtual void ReleaseSlateResources(bool bReleaseChildren) override;

View file

@ -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<FString> GenerateScramble(int32 Length = 20);

View file

@ -6,6 +6,8 @@
#include "HyperTwistClassicCubeGameMode.generated.h"
class AHyperTwistClassicCubeActor;
class AHyperTwistClassicCubeOrbitPawn;
class AHyperTwistClassicCubePlayerController;
class UHyperTwistClassicCubeHUDWidget;
class UHyperTwistTrainingSubsystem;

View file

@ -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<UTextBlock> StatusTextBlock = nullptr;
@ -42,4 +57,15 @@ private:
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ResultTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ControlsTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UButton> NewScrambleButton = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> NewScrambleButtonLabel = nullptr;
bool bNewScrambleButtonHovered = false;
};

View file

@ -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<USceneComponent> SceneRoot = nullptr;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube|Camera")
TObjectPtr<USpringArmComponent> SpringArm = nullptr;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube|Camera")
TObjectPtr<UCameraComponent> CameraComponent = nullptr;
protected:
void ApplyOrbitTransform();
bool ShouldOrbitFromMouseInput() const;
float CurrentYawDegrees = 0.0f;
float CurrentPitchDegrees = 0.0f;
};

View file

@ -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);
};

View file

@ -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;

View file

@ -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",

View file

@ -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<AHyperTwistClassicCubeGameMode>();
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<AHyperTwistClassicCubePlayerController>();
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<AHyperTwistClassicCubeOrbitPawn>();
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

View file

@ -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:** 4060 per repo (UHT regen on first include)
**Estimated time:** 12 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:** 80120 (geometry + input + HUD)
**Estimated time:** 35 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:** 5080
@ -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

View file

@ -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.

View file

@ -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. |