Complete phase 1 browser runtime and phase 2 cube loop

This commit is contained in:
axiomlogicnexus 2026-06-11 06:32:20 +00:00
parent c2efdcaa86
commit 7b5e56fe4e
34 changed files with 3889 additions and 305 deletions

335
Content/Browser/index.html Normal file
View file

@ -0,0 +1,335 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>HyperTwist Browser Runtime</title>
<style>
:root {
color-scheme: dark;
--paper: #09111c;
--paper-raised: rgba(13, 24, 38, 0.92);
--paper-soft: rgba(20, 36, 56, 0.84);
--line: rgba(153, 195, 255, 0.18);
--ink: #eff6ff;
--ink-soft: #afc0d6;
--accent: #8ee3ff;
--accent-strong: #52c7ff;
--accent-warm: #ffd38a;
--ok: #9bf6bf;
--warn: #ffd38a;
--bg-bloom: radial-gradient(circle at top left, rgba(82, 199, 255, 0.22), transparent 35%),
radial-gradient(circle at top right, rgba(255, 211, 138, 0.14), transparent 30%),
linear-gradient(160deg, #050b14 0%, #0d1624 45%, #101c2c 100%);
--shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
--font-ui: "IBM Plex Sans", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", "Consolas", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg-bloom);
color: var(--ink);
font-family: var(--font-ui);
}
body::before {
content: "";
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
opacity: 0.35;
}
#app {
position: relative;
z-index: 1;
padding: 28px;
}
.shell {
display: grid;
gap: 20px;
}
.hero {
display: grid;
gap: 12px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 24px;
background: linear-gradient(140deg, rgba(14, 27, 42, 0.94), rgba(11, 20, 31, 0.78));
box-shadow: var(--shadow);
overflow: hidden;
}
.hero h1,
.section-title,
.card h2 {
margin: 0;
letter-spacing: 0.02em;
}
.hero p,
.section-note,
.card p,
.card li,
.runtime-note {
margin: 0;
color: var(--ink-soft);
line-height: 1.5;
}
.hero-grid,
.stack-grid,
.utility-grid {
display: grid;
gap: 16px;
}
.hero-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.stack-grid,
.utility-grid {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.metric,
.card,
.panel {
border: 1px solid var(--line);
border-radius: 18px;
background: var(--paper-raised);
box-shadow: var(--shadow);
}
.metric {
padding: 16px 18px;
}
.metric strong {
display: block;
margin-bottom: 4px;
color: var(--accent);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.metric span {
font-size: 1.35rem;
font-weight: 600;
}
.section {
display: grid;
gap: 12px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.card {
padding: 18px;
display: grid;
gap: 12px;
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
}
.card:hover {
transform: translateY(-2px);
border-color: rgba(142, 227, 255, 0.42);
background: linear-gradient(180deg, rgba(17, 32, 51, 0.96), rgba(12, 22, 35, 0.9));
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.phase-chip,
.status-chip,
.activation-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
border: 1px solid rgba(142, 227, 255, 0.16);
background: rgba(18, 36, 57, 0.78);
}
.phase-chip {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status-chip[data-status="ready"] {
color: var(--ok);
}
.status-chip[data-status="idle"] {
color: var(--ink-soft);
}
.status-chip[data-status="loading"] {
color: var(--warn);
}
.status-chip[data-status="error"] {
color: #ffb0b0;
}
.card-actions,
.solver-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 1px solid rgba(82, 199, 255, 0.28);
background: linear-gradient(180deg, rgba(82, 199, 255, 0.18), rgba(55, 120, 184, 0.16));
color: var(--ink);
border-radius: 12px;
padding: 10px 14px;
cursor: pointer;
}
button.secondary {
background: rgba(24, 39, 58, 0.8);
}
button:hover {
border-color: rgba(142, 227, 255, 0.52);
}
.panel {
padding: 18px;
display: grid;
gap: 14px;
}
.panel-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.data-block {
border: 1px solid rgba(255, 255, 255, 0.06);
background: var(--paper-soft);
border-radius: 14px;
padding: 14px;
}
.data-block pre,
.card code,
.panel code {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #d4e8ff;
font-family: var(--font-mono);
font-size: 0.88rem;
}
#analytics-demo {
min-height: 240px;
}
#viewer-demo {
min-height: 320px;
display: grid;
place-items: center;
text-align: center;
}
#viewer-demo model-viewer {
width: 100%;
height: 280px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(17, 28, 43, 0.92), rgba(9, 16, 27, 0.92));
}
.field {
display: grid;
gap: 8px;
}
textarea {
min-height: 108px;
resize: vertical;
}
input,
textarea {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(142, 227, 255, 0.16);
background: rgba(11, 19, 31, 0.82);
color: var(--ink);
padding: 12px;
}
.log-list {
display: grid;
gap: 8px;
max-height: 240px;
overflow: auto;
}
.log-entry {
border-left: 3px solid rgba(82, 199, 255, 0.32);
padding: 10px 12px;
background: rgba(10, 18, 28, 0.88);
border-radius: 0 12px 12px 0;
}
.log-entry strong {
color: var(--accent-warm);
}
@media (max-width: 720px) {
#app {
padding: 18px;
}
}
</style>
</head>
<body>
<div id="app"></div>
<script
type="module"
src="/src/browser-spatial-runtime.ts"
></script>
</body>
</html>

View file

@ -1,34 +1,39 @@
{
"name": "hypertwist-browser-runtime",
"version": "1.0.0",
"description": "Bundled browser runtime for HyperTwist embedded WebBrowser widget",
"version": "1.1.0",
"private": true,
"description": "Buildable browser runtime and shell for HyperTwist donor-stack validation",
"type": "module",
"scripts": {
"build": "vite build",
"build-deps": "node scripts/build-dependencies.js",
"dev": "vite"
"dev": "vite",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.160.0",
"@pmndrs/postprocessing": "file:../../.external/postprocessing",
"@react-three/drei": "file:../../.external/drei",
"@react-three/fiber": "^8.15.0",
"leva": "file:../../.external/leva",
"maath": "file:../../.external/maath",
"three-stdlib": "file:../../.external/three-stdlib",
"zustand": "file:../../.external/zustand",
"@use-gesture/react": "file:../../.external/use-gesture",
"@react-spring/three": "file:../../.external/react-spring",
"@react-spring/web": "file:../../.external/react-spring",
"echarts": "^5.4.0",
"zrender": "file:../../.external/zrender",
"echarts-gl": "file:../../.external/echarts-gl",
"@google/model-viewer": "file:../../.external/model-viewer/packages/model-viewer",
"claygl": "file:../../.external/claygl",
"rubix-solver": "file:../../.external/rubix-solver"
"@google/model-viewer": "^4.3.1",
"@google/model-viewer-effects": "^1.5.0",
"@khronosgroup/gltf-viewer": "^1.1.0",
"@pmndrs/uikit": "^1.0.73",
"@react-spring/three": "^10.1.1",
"@react-spring/web": "^10.1.1",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/postprocessing": "^3.0.4",
"@use-gesture/react": "^10.3.1",
"echarts": "^6.1.0",
"echarts-gl": "^2.1.0",
"leva": "^0.10.1",
"maath": "^0.10.8",
"postprocessing": "^6.39.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"three": "^0.183.0",
"three-stdlib": "^2.36.1",
"zrender": "^6.1.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"vite": "^5.0.0",
"typescript": "^5.3.0"
"typescript": "^5.9.3",
"vite": "^7.1.11"
}
}

View file

@ -0,0 +1,49 @@
import { UEBridge } from './runtime/bridge';
import { BrowserSupportAdapters, listAdapters, loadAdapter } from './runtime/registry';
import { createBrowserShell } from './runtime/shell';
declare global {
interface Window {
HyperTwistBrowserRuntime?: {
adapters: typeof BrowserSupportAdapters;
listAdapters: typeof listAdapters;
loadAdapter: typeof loadAdapter;
receiveCommand: (payload: unknown) => void;
setShellState: (payload: unknown) => void;
renderShell: (root: HTMLElement) => void;
sendState: (payload: unknown) => void;
};
}
}
const runtimeApi = {
adapters: BrowserSupportAdapters,
listAdapters,
loadAdapter,
receiveCommand(payload: unknown) {
UEBridge.receiveCommand(payload);
},
setShellState(payload: unknown) {
UEBridge.setShellState(payload);
},
renderShell(root: HTMLElement) {
createBrowserShell(root);
},
sendState(payload: unknown) {
UEBridge.sendState(payload);
}
};
window.HyperTwistBrowserRuntime = runtimeApi;
const RootElement = document.getElementById('app');
if (RootElement instanceof HTMLElement)
{
runtimeApi.renderShell(RootElement);
}
UEBridge.notifyRuntimeReady({
status: 'ready',
runtime: 'hypertwist-browser-runtime',
adapterCount: listAdapters().length
});

View file

@ -1,71 +1,7 @@
/**
* HyperTwist Browser Runtime Entry Point
*
* Exports all bundled browser-capable libraries for use in the embedded
* WebBrowser widget or external browser runtime.
*
* Phase 1 wiring targets:
* - 1G: tentone/rubix-solver (vision + solver UI)
* - 1H: pmndrs spatial stack (postprocessing, drei, fiber, etc.)
* - 1I: ecomfe analytics stack (zrender, echarts-gl)
* - 1J: google/model-viewer sub-packages
*/
// Three.js core (peer dependency for most spatial packages)
import * as THREE from 'three';
// pmndrs spatial stack (1H)
export { THREE };
export { default as postprocessing } from '@pmndrs/postprocessing';
export { default as drei } from '@react-three/drei';
export { Canvas, useFrame, useThree } from '@react-three/fiber';
export { default as leva } from 'leva';
export { default as maath } from 'maath';
export { default as threeStdlib } from 'three-stdlib';
export { create as createZustandStore } from 'zustand';
export { useGesture } from '@use-gesture/react';
export { useSpring, animated } from '@react-spring/three';
// Analytics stack (1I)
export { default as zrender } from 'zrender';
export { default as echarts } from 'echarts';
export { default as echartsGl } from 'echarts-gl';
// Model viewer (1J)
export { default as ModelViewerElement } from '@google/model-viewer';
// claygl / clay-viewer (1H)
export { default as claygl } from 'claygl';
// rubix-solver (1G)
// Note: tentone solver is primarily a C++/OpenCV desktop app.
// The JS bundle exports a placeholder for future WASM or web port.
export const RubixSolver = {
version: '1.0.0',
note: 'C++ solver compiled as standalone executable. Web port TBD.'
};
// UE Bridge: postMessage API for state synchronization
interface UEStateBridge {
sendState(state: object): void;
onCommand(callback: (cmd: object) => void): void;
}
export const UEBridge: UEStateBridge = {
sendState(state: object) {
if ((window as any).ue && (window as any).ue.hypertwist) {
(window as any).ue.hypertwist.sendState(JSON.stringify(state));
} else {
window.parent.postMessage({ type: 'hypertwist-state', payload: state }, '*');
}
},
onCommand(callback: (cmd: object) => void) {
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'hypertwist-command') {
callback(event.data.payload);
}
});
}
};
console.log('[HyperTwist Browser Runtime] Loaded');
export {
BrowserSupportAdapters,
listAdapters,
loadAdapter
} from './runtime/registry';
export { UEBridge } from './runtime/bridge';
export { createBrowserShell } from './runtime/shell';

View file

@ -0,0 +1,134 @@
type RuntimeListener = (payload: unknown) => void;
function tryParseJsonPayload(value: unknown): unknown
{
if (typeof value !== 'string')
{
return value;
}
try
{
return JSON.parse(value);
}
catch
{
return value;
}
}
function getUnrealBridgeHandle(): Record<string, unknown> | null
{
const Candidate = (window as Record<string, unknown>).ue as Record<string, unknown> | undefined;
if (!Candidate || typeof Candidate !== 'object')
{
return null;
}
const HyperTwistBridge = Candidate.hypertwist;
return HyperTwistBridge && typeof HyperTwistBridge === 'object'
? HyperTwistBridge as Record<string, unknown>
: null;
}
const CommandListeners = new Set<RuntimeListener>();
const ShellStateListeners = new Set<RuntimeListener>();
function dispatchToListeners(Listeners: Set<RuntimeListener>, Payload: unknown): void
{
Listeners.forEach((Listener) => Listener(Payload));
}
function postEnvelope(Type: string, Payload: unknown): void
{
const Envelope = {
type: Type,
payload: Payload,
emittedAtUtc: new Date().toISOString()
};
const SerializedEnvelope = JSON.stringify(Envelope);
const UnrealBridgeHandle = getUnrealBridgeHandle();
if (UnrealBridgeHandle && typeof UnrealBridgeHandle.notifyEnvelope === 'function')
{
(UnrealBridgeHandle.notifyEnvelope as (EnvelopeJson: string) => void)(SerializedEnvelope);
return;
}
if (Type === 'hypertwist-state'
&& UnrealBridgeHandle
&& typeof UnrealBridgeHandle.notifyState === 'function')
{
(UnrealBridgeHandle.notifyState as (StateJson: string) => void)(JSON.stringify(Payload));
return;
}
window.parent?.postMessage(Envelope, '*');
}
window.addEventListener('message', (Event) =>
{
const Envelope = Event.data;
if (!Envelope || typeof Envelope !== 'object')
{
return;
}
const TypedEnvelope = Envelope as Record<string, unknown>;
if (TypedEnvelope.type === 'hypertwist-command')
{
dispatchToListeners(CommandListeners, tryParseJsonPayload(TypedEnvelope.payload));
}
else if (TypedEnvelope.type === 'hypertwist-shell-state')
{
dispatchToListeners(ShellStateListeners, tryParseJsonPayload(TypedEnvelope.payload));
}
});
export const UEBridge = {
onCommand(Listener: RuntimeListener): () => void
{
CommandListeners.add(Listener);
return () => CommandListeners.delete(Listener);
},
onShellState(Listener: RuntimeListener): () => void
{
ShellStateListeners.add(Listener);
return () => ShellStateListeners.delete(Listener);
},
receiveCommand(Payload: unknown): void
{
dispatchToListeners(CommandListeners, tryParseJsonPayload(Payload));
},
setShellState(Payload: unknown): void
{
const ParsedPayload = tryParseJsonPayload(Payload);
dispatchToListeners(ShellStateListeners, ParsedPayload);
},
sendState(Payload: unknown): void
{
postEnvelope('hypertwist-state', Payload);
},
sendCommandResult(Payload: unknown): void
{
postEnvelope('hypertwist-command-result', Payload);
},
notifyRuntimeReady(Payload: unknown): void
{
const UnrealBridgeHandle = getUnrealBridgeHandle();
if (UnrealBridgeHandle && typeof UnrealBridgeHandle.notifyRuntimeReady === 'function')
{
(UnrealBridgeHandle.notifyRuntimeReady as (PayloadJson: string) => void)(JSON.stringify(Payload));
return;
}
postEnvelope('hypertwist-runtime-ready', Payload);
}
};

View file

@ -0,0 +1,463 @@
interface BrowserSupportAdapter {
activation:
| 'bundled-module'
| 'prebuilt-script'
| 'source-sidecar'
| 'native-sidecar'
| 'editor-sidecar'
| 'qa-sidecar';
description: string;
id: string;
packageName?: string;
phase: '1E' | '1G' | '1H' | '1I' | '1J';
repo: string;
sourcePathHint: string;
}
interface LoadedAdapter {
details?: string[];
id: string;
module?: unknown;
note?: string;
status: 'loaded' | 'deferred';
}
const ScriptLoadCache = new Map<string, Promise<unknown>>();
function isDistShell(): boolean
{
return new URL(document.baseURI).pathname.includes('/Content/Browser/dist/');
}
function resolveRepoAssetUrl(RepoRelativePath: string): string
{
const Prefix = isDistShell() ? '../../../' : '../../';
return new URL(`${Prefix}${RepoRelativePath}`, document.baseURI).toString();
}
function loadExternalScript(RepoRelativePath: string): Promise<unknown>
{
const ResolvedUrl = resolveRepoAssetUrl(RepoRelativePath);
if (ScriptLoadCache.has(ResolvedUrl))
{
return ScriptLoadCache.get(ResolvedUrl) as Promise<unknown>;
}
const LoadPromise = new Promise<unknown>((Resolve, Reject) =>
{
const ExistingElement = document.querySelector<HTMLScriptElement>(
`script[data-hypertwist-src="${ResolvedUrl}"]`
);
if (ExistingElement)
{
ExistingElement.addEventListener('load', () => Resolve(undefined), { once: true });
ExistingElement.addEventListener('error', () => Reject(new Error(`Failed to load ${ResolvedUrl}`)), { once: true });
return;
}
const ScriptElement = document.createElement('script');
ScriptElement.src = ResolvedUrl;
ScriptElement.async = true;
ScriptElement.dataset.hypertwistSrc = ResolvedUrl;
ScriptElement.onload = () => Resolve(undefined);
ScriptElement.onerror = () => Reject(new Error(`Failed to load ${ResolvedUrl}`));
document.head.appendChild(ScriptElement);
});
ScriptLoadCache.set(ResolvedUrl, LoadPromise);
return LoadPromise;
}
async function loadSpatialStack(): Promise<LoadedAdapter>
{
const [
Fiber,
Drei,
ReactPostprocessing,
Postprocessing,
Leva,
Maath,
ThreeStdLib,
Zustand,
UseGesture,
ReactSpringThree,
ReactSpringWeb,
UIKit
] = await Promise.all([
import('@react-three/fiber'),
import('@react-three/drei'),
import('@react-three/postprocessing'),
import('postprocessing'),
import('leva'),
import('maath'),
import('three-stdlib'),
import('zustand'),
import('@use-gesture/react'),
import('@react-spring/three'),
import('@react-spring/web'),
import('@pmndrs/uikit')
]);
return {
id: 'phase-1h-spatial-stack',
status: 'loaded',
module: {
Fiber,
Drei,
ReactPostprocessing,
Postprocessing,
Leva,
Maath,
ThreeStdLib,
Zustand,
UseGesture,
ReactSpringThree,
ReactSpringWeb,
UIKit
},
details: [
'@react-three/fiber',
'@react-three/drei',
'@react-three/postprocessing',
'postprocessing',
'leva',
'maath',
'three-stdlib',
'zustand',
'@use-gesture/react',
'@react-spring/three',
'@react-spring/web',
'@pmndrs/uikit'
]
};
}
async function loadAnalyticsStack(): Promise<LoadedAdapter>
{
const [ECharts, ZRender] = await Promise.all([
import('echarts'),
import('zrender')
]);
await import('echarts-gl');
return {
id: 'phase-1i-analytics-stack',
status: 'loaded',
module: {
ECharts,
ZRender
},
details: [
'echarts',
'zrender',
'echarts-gl'
]
};
}
async function loadViewerStack(): Promise<LoadedAdapter>
{
await Promise.all([
import('@google/model-viewer'),
import('@google/model-viewer-effects')
]);
const GltfViewer = await import('@khronosgroup/gltf-viewer');
return {
id: 'phase-1j-viewer-stack',
status: 'loaded',
module: {
ModelViewerElement: customElements.get('model-viewer'),
GltfViewer
},
details: [
'@google/model-viewer',
'@google/model-viewer-effects',
'@khronosgroup/gltf-viewer'
]
};
}
async function loadClayViewerStack(): Promise<LoadedAdapter>
{
await loadExternalScript('.external/claygl/dist/claygl.js');
await loadExternalScript('.external/clay-viewer/dist/clay-viewer.js');
const GlobalWindow = window as Window & {
ClayViewer?: unknown;
clay?: unknown;
};
return {
id: 'phase-1h-clay-viewer-stack',
status: 'loaded',
module: {
clay: GlobalWindow.clay,
ClayViewer: GlobalWindow.ClayViewer
},
details: [
'.external/claygl/dist/claygl.js',
'.external/clay-viewer/dist/clay-viewer.js'
]
};
}
export const BrowserSupportAdapters: BrowserSupportAdapter[] = [
{
id: '1e-gltf-sample-renderer',
phase: '1E',
repo: 'KhronosGroup/glTF-Sample-Renderer',
activation: 'bundled-module',
packageName: '@khronosgroup/gltf-viewer',
sourcePathHint: '.external/glTF-Sample-Renderer',
description: 'Reference glTF renderer and viewer module wired into the first-party browser runtime.'
},
{
id: '1g-tentone-native-solver',
phase: '1G',
repo: 'tentone/rubix-solver',
activation: 'native-sidecar',
sourcePathHint: '.external/rubix-solver',
description: 'Native/OpenCV solver adjunct retained as a browser-shell fallback lane rather than a direct npm package.'
},
{
id: '1h-code-vr',
phase: '1H',
repo: 'NuiLab/code-vr',
activation: 'source-sidecar',
sourcePathHint: '.external/code-vr',
description: 'Rust donor retained as a source-sidecar surface for follow-up browser spatial composition.'
},
{
id: '1h-claygl',
phase: '1H',
repo: 'pissang/claygl',
activation: 'prebuilt-script',
sourcePathHint: '.external/claygl/dist/claygl.js',
description: 'Prebuilt ClayGL runtime script bridged into the browser shell for viewer and QA surfaces.'
},
{
id: '1h-clay-viewer',
phase: '1H',
repo: 'pissang/clay-viewer',
activation: 'prebuilt-script',
sourcePathHint: '.external/clay-viewer/dist/clay-viewer.js',
description: 'Prebuilt Clay Viewer shell loaded beside ClayGL for bounded browser inspection.'
},
{
id: '1h-postprocessing',
phase: '1H',
repo: 'pmndrs/postprocessing',
activation: 'bundled-module',
packageName: 'postprocessing',
sourcePathHint: '.external/postprocessing',
description: 'Three.js post-processing core bundled into the runtime module graph.'
},
{
id: '1h-react-postprocessing',
phase: '1H',
repo: 'pmndrs/react-postprocessing',
activation: 'bundled-module',
packageName: '@react-three/postprocessing',
sourcePathHint: '.external/react-postprocessing',
description: 'React bridge for post-processing loaded into the browser spatial stack.'
},
{
id: '1h-drei',
phase: '1H',
repo: 'pmndrs/drei',
activation: 'bundled-module',
packageName: '@react-three/drei',
sourcePathHint: '.external/drei',
description: 'Drei helper surface bundled for browser-spatial utility composition.'
},
{
id: '1h-uikit',
phase: '1H',
repo: 'pmndrs/uikit',
activation: 'bundled-module',
packageName: '@pmndrs/uikit',
sourcePathHint: '.external/uikit',
description: 'World-anchored UI kit bundled for browser-spatial HUD and control surfaces.'
},
{
id: '1h-three-stdlib',
phase: '1H',
repo: 'pmndrs/three-stdlib',
activation: 'bundled-module',
packageName: 'three-stdlib',
sourcePathHint: '.external/three-stdlib',
description: 'Three.js helper library bundled for browser runtime support.'
},
{
id: '1h-maath',
phase: '1H',
repo: 'pmndrs/maath',
activation: 'bundled-module',
packageName: 'maath',
sourcePathHint: '.external/maath',
description: 'Animation and math helper pack bundled into the runtime.'
},
{
id: '1h-zustand',
phase: '1H',
repo: 'pmndrs/zustand',
activation: 'bundled-module',
packageName: 'zustand',
sourcePathHint: '.external/zustand',
description: 'State-store runtime bundled for browser shell state and derived panel demos.'
},
{
id: '1h-leva',
phase: '1H',
repo: 'pmndrs/leva',
activation: 'bundled-module',
packageName: 'leva',
sourcePathHint: '.external/leva',
description: 'Parameter-control runtime bundled for future tuning overlays.'
},
{
id: '1h-use-gesture',
phase: '1H',
repo: 'pmndrs/use-gesture',
activation: 'bundled-module',
packageName: '@use-gesture/react',
sourcePathHint: '.external/use-gesture',
description: 'Gesture capture hooks bundled for browser interaction surfaces.'
},
{
id: '1h-react-spring',
phase: '1H',
repo: 'pmndrs/react-spring',
activation: 'bundled-module',
packageName: '@react-spring/three and @react-spring/web',
sourcePathHint: '.external/react-spring',
description: 'Motion and interpolation runtime bundled for browser spatial transitions.'
},
{
id: '1i-zrender',
phase: '1I',
repo: 'ecomfe/zrender',
activation: 'bundled-module',
packageName: 'zrender',
sourcePathHint: '.external/zrender',
description: '2D render engine bundled beneath the analytics surface.'
},
{
id: '1i-echarts-gl',
phase: '1I',
repo: 'ecomfe/echarts-gl',
activation: 'bundled-module',
packageName: 'echarts-gl',
sourcePathHint: '.external/echarts-gl',
description: '3D analytics extension bundled beneath the ECharts runtime.'
},
{
id: '1j-model-viewer',
phase: '1J',
repo: 'google/model-viewer/packages/model-viewer',
activation: 'bundled-module',
packageName: '@google/model-viewer',
sourcePathHint: '.external/model-viewer/packages/model-viewer',
description: 'Model Viewer web component bundled into the browser shell.'
},
{
id: '1j-model-viewer-effects',
phase: '1J',
repo: 'google/model-viewer/packages/model-viewer-effects',
activation: 'bundled-module',
packageName: '@google/model-viewer-effects',
sourcePathHint: '.external/model-viewer/packages/model-viewer-effects',
description: 'Effects package bundled beside model-viewer for decorated previews.'
},
{
id: '1j-space-opera',
phase: '1J',
repo: 'google/model-viewer/packages/space-opera',
activation: 'editor-sidecar',
sourcePathHint: '.external/model-viewer/packages/space-opera',
description: 'Editor-oriented inspection UI retained as a sidecar instead of a default runtime export.'
},
{
id: '1j-render-fidelity-tools',
phase: '1J',
repo: 'google/model-viewer/packages/render-fidelity-tools',
activation: 'qa-sidecar',
sourcePathHint: '.external/model-viewer/packages/render-fidelity-tools',
description: 'Renderer comparison and fidelity tooling kept in the QA lane rather than loaded by default.'
}
];
export function listAdapters(): BrowserSupportAdapter[]
{
return [...BrowserSupportAdapters];
}
export async function loadAdapter(AdapterId: string): Promise<LoadedAdapter>
{
switch (AdapterId)
{
case '1e-gltf-sample-renderer':
return loadViewerStack();
case '1g-tentone-native-solver':
return {
id: AdapterId,
status: 'deferred',
note: 'Use the browser shell fallback form or Unreal IPC to route into the native tentone/OpenCV lane.'
};
case '1h-code-vr':
return {
id: AdapterId,
status: 'deferred',
note: 'code-vr stays source-sidecar until a dedicated Rust or WASM bridge is assigned.'
};
case '1h-claygl':
case '1h-clay-viewer':
return loadClayViewerStack();
case '1h-postprocessing':
case '1h-react-postprocessing':
case '1h-drei':
case '1h-uikit':
case '1h-three-stdlib':
case '1h-maath':
case '1h-zustand':
case '1h-leva':
case '1h-use-gesture':
case '1h-react-spring':
return loadSpatialStack();
case '1i-zrender':
case '1i-echarts-gl':
return loadAnalyticsStack();
case '1j-model-viewer':
case '1j-model-viewer-effects':
return loadViewerStack();
case '1j-space-opera':
return {
id: AdapterId,
status: 'deferred',
note: 'space-opera remains intentionally outside the default runtime and is surfaced as an editor-sidecar.'
};
case '1j-render-fidelity-tools':
return {
id: AdapterId,
status: 'deferred',
note: 'render-fidelity-tools stays in the QA lane and is not auto-loaded in the browser shell.'
};
default:
throw new Error(`Unknown browser support adapter: ${AdapterId}`);
}
}
export function resolveSupportAssetUrl(RepoRelativePath: string): string
{
return resolveRepoAssetUrl(RepoRelativePath);
}

View file

@ -0,0 +1,393 @@
import { UEBridge } from './bridge';
import {
BrowserSupportAdapters,
listAdapters,
loadAdapter,
resolveSupportAssetUrl
} from './registry';
type AdapterStatus = 'idle' | 'loading' | 'ready' | 'error';
function createElement<K extends keyof HTMLElementTagNameMap>(
TagName: K,
ClassName?: string,
TextContent?: string
): HTMLElementTagNameMap[K]
{
const Element = document.createElement(TagName);
if (ClassName)
{
Element.className = ClassName;
}
if (TextContent)
{
Element.textContent = TextContent;
}
return Element;
}
function serializePretty(Payload: unknown): string
{
if (typeof Payload === 'string')
{
return Payload;
}
try
{
return JSON.stringify(Payload, null, 2);
}
catch
{
return String(Payload);
}
}
export function createBrowserShell(RootElement: HTMLElement): void
{
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,
'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.'
);
const HeroMetrics = createElement('div', 'hero-grid');
const Metrics = [
{
label: 'Adapters surfaced',
value: String(BrowserSupportAdapters.length)
},
{
label: 'Phase 1 coverage',
value: '1E, 1G, 1H, 1I, 1J'
},
{
label: 'Bridge route',
value: 'UE BindUObject + postMessage fallback'
}
];
Metrics.forEach((Metric) =>
{
const MetricElement = createElement('div', 'metric');
const MetricLabel = createElement('strong', undefined, Metric.label);
const MetricValue = createElement('span', undefined, Metric.value);
MetricElement.append(MetricLabel, MetricValue);
HeroMetrics.appendChild(MetricElement);
});
HeroElement.append(HeroTitle, HeroParagraph, HeroMetrics);
const AdapterSection = createElement('section', 'section');
const AdapterHeader = createElement('div', 'section-header');
const AdapterTitle = createElement('h2', 'section-title', 'Phase 1 Adapter Surface');
const AdapterNote = createElement(
'p',
'section-note',
'Load any adapter to prove the runtime can resolve it, or inspect the activation type when a donor is intentionally retained as a sidecar.'
);
AdapterHeader.append(AdapterTitle, AdapterNote);
const AdapterGrid = createElement('div', 'stack-grid');
const StatusElements = new Map<string, HTMLElement>();
listAdapters().forEach((Adapter) =>
{
const CardElement = createElement('article', 'card');
const CardHeader = createElement('div', 'card-header');
const CardTitleBlock = createElement('div');
const PhaseChip = createElement('span', 'phase-chip', Adapter.phase);
const CardTitle = createElement('h2', undefined, Adapter.repo);
const ActivationChip = createElement('span', 'activation-chip', Adapter.activation);
const StatusChip = createElement('span', 'status-chip', 'idle');
StatusChip.dataset.status = 'idle';
StatusElements.set(Adapter.id, StatusChip);
CardTitleBlock.append(PhaseChip, CardTitle);
CardHeader.append(CardTitleBlock, StatusChip);
const Description = createElement('p', undefined, Adapter.description);
const SourceCode = createElement('code', undefined, `${Adapter.sourcePathHint}${Adapter.packageName ? ` -> ${Adapter.packageName}` : ''}`);
const Actions = createElement('div', 'card-actions');
const LoadButton = createElement('button', undefined, 'Load Adapter');
const InspectButton = createElement('button', 'secondary', 'Send Envelope');
LoadButton.addEventListener('click', async () =>
{
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(
`${Adapter.repo}`,
Result.note
? Result.note
: `Loaded ${Result.details?.join(', ') || Adapter.id}.`
);
if (Adapter.phase === '1I' && Result.status === 'loaded')
{
mountAnalyticsDemo();
}
if ((Adapter.phase === '1J' || Adapter.phase === '1E') && Result.status === 'loaded')
{
mountViewerDemo();
}
}
catch (Error)
{
StatusChip.dataset.status = 'error';
StatusChip.textContent = 'error';
appendLog(`${Adapter.repo}`, Error instanceof Error ? Error.message : 'Unknown load failure.');
}
});
InspectButton.addEventListener('click', () =>
{
UEBridge.sendState({
source: 'browser-runtime-shell',
adapterId: Adapter.id,
repo: Adapter.repo,
activation: Adapter.activation
});
appendLog(Adapter.repo, 'Sent adapter envelope to the Unreal bridge.');
});
Actions.append(LoadButton, InspectButton);
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', 'Runtime Utilities'),
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.'
)
);
const UtilityGrid = createElement('div', 'panel-grid');
const AnalyticsPanel = createElement('section', 'panel');
AnalyticsPanel.append(
createElement('h2', undefined, 'Analytics Demo'),
createElement(
'p',
'runtime-note',
'Loads the ECharts + zrender + echarts-gl lane and renders a small solve-trend chart.'
)
);
const AnalyticsActions = createElement('div', 'solver-actions');
const LoadAnalyticsButton = createElement('button', undefined, 'Load Analytics Demo');
LoadAnalyticsButton.addEventListener('click', async () =>
{
await loadAdapter('1i-zrender');
mountAnalyticsDemo();
appendLog('Analytics Demo', 'Loaded the analytics bundle and rendered the demo chart.');
});
AnalyticsActions.appendChild(LoadAnalyticsButton);
const AnalyticsDemo = createElement('div', 'data-block');
AnalyticsDemo.id = 'analytics-demo';
AnalyticsDemo.textContent = 'Analytics chart will mount here after the adapter loads.';
AnalyticsPanel.append(AnalyticsActions, AnalyticsDemo);
const ViewerPanel = createElement('section', 'panel');
ViewerPanel.append(
createElement('h2', undefined, 'Viewer Demo'),
createElement(
'p',
'runtime-note',
'Loads the model-viewer and Khronos reference-viewer lane, then mounts a bounded local sample asset.'
)
);
const ViewerActions = createElement('div', 'solver-actions');
const LoadViewerButton = createElement('button', undefined, 'Load Viewer Demo');
LoadViewerButton.addEventListener('click', async () =>
{
await loadAdapter('1j-model-viewer');
mountViewerDemo();
appendLog('Viewer Demo', 'Loaded the model-viewer stack and mounted the local sample asset.');
});
ViewerActions.appendChild(LoadViewerButton);
const ViewerDemo = createElement('div', 'data-block');
ViewerDemo.id = 'viewer-demo';
ViewerDemo.textContent = 'Viewer preview will appear here after the adapter loads.';
ViewerPanel.append(ViewerActions, ViewerDemo);
const SolverPanel = createElement('section', 'panel');
SolverPanel.append(
createElement('h2', undefined, 'Tentone Fallback Solver'),
createElement(
'p',
'runtime-note',
'The tentone donor is native/OpenCV, so the browser shell uses a fallback form that sends a solver request envelope back to Unreal instead of faking a browser import.'
)
);
const SolverField = createElement('label', 'field');
SolverField.appendChild(createElement('span', undefined, 'Classic facelet string'));
const SolverInput = createElement('textarea') as HTMLTextAreaElement;
SolverInput.value = 'UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB';
SolverField.appendChild(SolverInput);
const SolverActions = createElement('div', 'solver-actions');
const SendSolverButton = createElement('button', undefined, 'Send Solver Request');
SendSolverButton.addEventListener('click', () =>
{
UEBridge.sendState({
type: 'tentone-native-solver-request',
source: 'browser-shell-fallback',
faceletString: SolverInput.value.trim()
});
appendLog('Tentone Fallback Solver', 'Sent a native-sidecar solver request envelope to Unreal.');
});
SolverActions.appendChild(SendSolverButton);
SolverPanel.append(SolverField, SolverActions);
const StatePanel = createElement('section', 'panel');
StatePanel.append(
createElement('h2', undefined, 'Browser Shell State'),
createElement(
'p',
'runtime-note',
'Unreal can push browser-shell state or generic commands into this page through the embedded bridge.'
)
);
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',
'Compact bridge and adapter events are recorded here for Unreal-side inspection.'
)
);
const LogList = createElement('div', 'log-list');
LogPanel.appendChild(LogList);
function appendLog(Title: string, Message: string): void
{
const Entry = createElement('div', 'log-entry');
const TitleElement = createElement('strong', undefined, Title);
const MessageElement = createElement('div', undefined, Message);
Entry.append(TitleElement, MessageElement);
LogList.prepend(Entry);
}
function mountAnalyticsDemo(): void
{
const ChartHost = document.getElementById('analytics-demo');
if (!(ChartHost instanceof HTMLElement))
{
return;
}
import('echarts').then((EChartsModule) =>
{
const ExistingInstance = EChartsModule.getInstanceByDom(ChartHost);
const Chart = ExistingInstance || EChartsModule.init(ChartHost, undefined, { renderer: 'canvas' });
Chart.setOption({
backgroundColor: 'transparent',
grid: {
top: 24,
right: 16,
bottom: 24,
left: 32
},
xAxis: {
type: 'category',
data: ['Solve 1', 'Solve 2', 'Solve 3', 'Solve 4', 'Solve 5'],
axisLabel: {
color: '#afc0d6'
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#afc0d6',
formatter: (Value: number) => `${Value}s`
}
},
series: [
{
type: 'line',
data: [24.3, 22.7, 21.8, 20.9, 19.4],
smooth: true,
lineStyle: {
color: '#8ee3ff',
width: 3
},
areaStyle: {
color: 'rgba(82, 199, 255, 0.18)'
}
}
]
});
});
}
function mountViewerDemo(): void
{
const ViewerHost = document.getElementById('viewer-demo');
if (!(ViewerHost instanceof HTMLElement))
{
return;
}
ViewerHost.innerHTML = '';
const ModelViewer = document.createElement('model-viewer');
ModelViewer.setAttribute(
'src',
resolveSupportAssetUrl('.external/model-viewer/packages/shared-assets/models/cube.gltf')
);
ModelViewer.setAttribute(
'environment-image',
resolveSupportAssetUrl('.external/model-viewer/packages/shared-assets/environments/aircraft_workshop_01_1k.hdr')
);
ModelViewer.setAttribute('camera-controls', '');
ModelViewer.setAttribute('auto-rotate', '');
ModelViewer.setAttribute('shadow-intensity', '1');
ModelViewer.setAttribute('exposure', '1.1');
ViewerHost.appendChild(ModelViewer);
}
UtilityGrid.append(
AnalyticsPanel,
ViewerPanel,
SolverPanel,
StatePanel,
LogPanel
);
UtilitySection.append(UtilityHeader, UtilityGrid);
ShellElement.append(HeroElement, AdapterSection, UtilitySection);
RootElement.appendChild(ShellElement);
UEBridge.onCommand((Payload) =>
{
appendLog('UE Command', serializePretty(Payload));
});
UEBridge.onShellState((Payload) =>
{
StatePre.textContent = serializePretty(Payload);
appendLog('Browser Shell State', 'Received fresh shell state from Unreal.');
});
}

View file

@ -3,22 +3,19 @@ import { resolve } from 'path';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'HyperTwistBrowserRuntime',
fileName: (format) => `hypertwist-browser-runtime.${format}.js`,
formats: ['umd', 'es']
},
rollupOptions: {
external: [],
output: {
globals: {},
inlineDynamicImports: true
}
},
outDir: 'dist',
emptyOutDir: true,
sourcemap: true
sourcemap: true,
rollupOptions: {
input: {
shell: resolve(__dirname, 'index.html')
},
output: {
assetFileNames: 'assets/[name][extname]',
chunkFileNames: 'assets/[name].js',
entryFileNames: 'assets/[name].js'
}
}
},
resolve: {
alias: {

View file

@ -10,6 +10,7 @@ Output: Space-separated WCA move notation (U, D, F, B, L, R, with ' and 2 suffix
"""
import argparse
import os
import shutil
import subprocess
import sys
@ -24,6 +25,8 @@ BROWNAN_TO_WCA = {
"R": "R",
}
VALID_BROWNAN_FACES = set(BROWNAN_TO_WCA.keys())
def find_brownan_exe():
"""Locate the brownan solver executable next to this wrapper."""
@ -32,13 +35,17 @@ def find_brownan_exe():
os.path.join(script_dir, "..", "..", "UnrealHyperTwist", "Binaries", "Win64", "Solvers", "brownan-solver.exe"),
os.path.join(script_dir, "brownan-solver.exe"),
os.path.join(script_dir, "..", "brownan-solver.exe"),
"brownan-solver.exe",
"brownan-solver",
]
for candidate in candidates:
resolved = os.path.abspath(candidate)
if os.path.isfile(resolved):
return resolved
for candidate in ("brownan-solver.exe", "brownan-solver"):
resolved = shutil.which(candidate)
if resolved:
return os.path.abspath(resolved)
return None
@ -79,6 +86,42 @@ def convert_move(brownan_move: str) -> str:
return f"{wca_face}{suffix}"
def is_brownan_move_token(token: str) -> bool:
"""Return True when a token matches brownan's native move format."""
token = token.strip()
if not token:
return False
if token.startswith("2"):
return len(token) == 2 and token[1] in VALID_BROWNAN_FACES
if token.endswith("'"):
return len(token) == 2 and token[0] in VALID_BROWNAN_FACES
return len(token) == 1 and token in VALID_BROWNAN_FACES
def parse_solution_moves(stdout: str) -> str:
"""Extract the first parseable brownan solution line from solver stdout."""
in_solution = False
for line in stdout.splitlines():
stripped = line.strip()
if stripped == "Solution found!":
in_solution = True
continue
if not in_solution or not stripped:
continue
# The actual move sequence can be preceded by brownan status chatter.
if stripped.startswith("Continuing"):
continue
tokens = stripped.split()
if not all(is_brownan_move_token(token) for token in tokens):
continue
moves = [convert_move(token) for token in tokens]
return " ".join(move for move in moves if move)
return ""
def facelet_to_brownan_raw(facelet: str) -> str:
"""Convert a 54-character kociemba facelet string to brownan 120-char raw format."""
if len(facelet) != 54:
@ -243,28 +286,20 @@ def solve(cube_input: str, brownan_exe: str = None) -> str:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
lines = result.stdout.splitlines()
in_solution = False
moves = []
for line in lines:
stripped = line.strip()
if stripped == "Solution found!":
in_solution = True
continue
if in_solution:
if not stripped:
continue
# Skip the "Continuing to look..." status line; the actual move
# sequence is on the following line.
if stripped.startswith("Continuing"):
continue
for token in stripped.split():
wca = convert_move(token)
if wca:
moves.append(wca)
break
if result.returncode != 0:
details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
print(f"ERROR: Brownan solver failed: {details}", file=sys.stderr)
sys.exit(1)
return " ".join(moves)
solution = parse_solution_moves(result.stdout)
if solution:
return solution
stdout_preview = result.stdout.strip()
stderr_preview = result.stderr.strip()
details = stderr_preview or stdout_preview or "solver returned no output"
print(f"ERROR: Brownan solver returned no parseable solution: {details}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":

View file

@ -6,23 +6,11 @@ kociemba-order facelet string to both the cahidenes and brownan wrappers,
then verifies each solution actually solves the cube by re-applying it in pycuber.
"""
import argparse
import os
import random
import subprocess
import sys
from pathlib import Path
try:
from pycuber import Cube, Formula
except ImportError:
print("ERROR: pycuber package not installed. Run: pip install pycuber")
sys.exit(1)
try:
import kociemba
except ImportError:
print("ERROR: kociemba package not installed. Run: pip install kociemba")
sys.exit(1)
from typing import Any
# Map pycuber colour names to single uppercase letters used in kociemba facelets.
@ -41,9 +29,10 @@ FACES = ["U", "D", "F", "B", "L", "R"]
SUFFIXES = ["", "'", "2"]
N_SCRAMBLES = 10
SCRAMBLE_DEPTH = 12
SCRIPT_DIR = Path(__file__).resolve().parent
def facelet_string_from_cube(cube: Cube) -> str:
def facelet_string_from_cube(cube: Any) -> str:
"""Build a 54-char kociemba-order facelet string from a pycuber Cube."""
out = []
for f in "URFDLB":
@ -55,23 +44,17 @@ def facelet_string_from_cube(cube: Cube) -> str:
def random_scramble(depth: int = SCRAMBLE_DEPTH) -> str:
return " ".join(random.choice(FACES) + random.choice(SUFFIXES) for _ in range(depth))
moves = []
previous_face = None
for _ in range(depth):
available_faces = [face for face in FACES if face != previous_face]
face = random.choice(available_faces)
moves.append(face + random.choice(SUFFIXES))
previous_face = face
return " ".join(moves)
def inverse_moves(moves_str: str) -> list[str]:
rev = list(reversed(moves_str.strip().split()))
out = []
for m in rev:
if m.endswith("'"):
out.append(m[:-1])
elif m.endswith("2"):
out.append(m)
else:
out.append(m + "'")
return out
def is_solved(cube: Cube) -> bool:
def is_solved(cube: Any) -> bool:
for f in "URFDLB":
face = cube.get_face(f)
colours = {cell.colour for row in face for cell in row}
@ -82,7 +65,7 @@ def is_solved(cube: Cube) -> bool:
def solve_with_wrapper(wrapper_path: Path, facelets: str) -> list[str]:
result = subprocess.run(
["python.exe", str(wrapper_path), facelets],
[sys.executable, str(wrapper_path), facelets],
capture_output=True,
text=True,
timeout=300,
@ -120,9 +103,33 @@ def main():
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility")
args = parser.parse_args()
solvers_dir = Path("C:/HyperTwist/UnrealHyperTwist/Binaries/Win64/Solvers")
brownan_wrapper = Path(args.brownan_wrapper) if args.brownan_wrapper else solvers_dir / "brownan_solver_wrapper.py"
cahidenes_wrapper = Path(args.cahidenes_wrapper) if args.cahidenes_wrapper else solvers_dir / "cahidenes_solver_wrapper.py"
try:
from pycuber import Cube, Formula
except ImportError:
print("ERROR: pycuber package not installed. Run: pip install pycuber")
sys.exit(1)
try:
import kociemba
except ImportError:
print("ERROR: kociemba package not installed. Run: pip install kociemba")
sys.exit(1)
solver_candidates = [
SCRIPT_DIR,
SCRIPT_DIR.parent.parent / "UnrealHyperTwist" / "Binaries" / "Win64" / "Solvers",
Path("C:/HyperTwist/UnrealHyperTwist/Binaries/Win64/Solvers"),
]
def resolve_default_wrapper(filename: str) -> Path:
for directory in solver_candidates:
candidate = directory / filename
if candidate.exists():
return candidate
return solver_candidates[0] / filename
brownan_wrapper = Path(args.brownan_wrapper) if args.brownan_wrapper else resolve_default_wrapper("brownan_solver_wrapper.py")
cahidenes_wrapper = Path(args.cahidenes_wrapper) if args.cahidenes_wrapper else resolve_default_wrapper("cahidenes_solver_wrapper.py")
if not brownan_wrapper.exists():
print(f"ERROR: Brownan wrapper not found at {brownan_wrapper}")

View file

@ -9,10 +9,9 @@ Returns:
Path to generated WAV file on success
"""
import argparse
import sys
import json
import subprocess
import os
import shutil
import subprocess
def find_piper_exe():
@ -23,13 +22,17 @@ def find_piper_exe():
os.path.join(script_dir, "..", "..", "..", "Binaries", "Win64", "Solvers", "piper", "piper", "piper.exe"),
os.path.join(script_dir, "piper", "piper.exe"),
os.path.join(script_dir, "..", "piper", "piper", "piper.exe"),
"piper",
"piper.exe",
]
for candidate in candidates:
resolved = os.path.abspath(candidate)
if os.path.isfile(resolved):
return resolved
for candidate in ("piper.exe", "piper"):
resolved = shutil.which(candidate)
if resolved:
return os.path.abspath(resolved)
return None
@ -51,12 +54,17 @@ def synthesize(text: str, model_path: str, config_path: str, output_path: str) -
"""Synthesize speech using piper."""
if not os.path.exists(model_path):
return f"ERROR: Model not found: {model_path}"
if not os.path.exists(config_path):
return f"ERROR: Config not found: {config_path}"
piper_exe = find_piper_exe()
if not piper_exe:
return "ERROR: piper executable not found. Place it in Solvers/piper/piper/ or add to PATH."
espeak_data = find_espeak_data(piper_exe)
output_dir = os.path.dirname(os.path.abspath(output_path))
if output_dir:
os.makedirs(output_dir, exist_ok=True)
cmd = [
piper_exe,
@ -70,6 +78,8 @@ def synthesize(text: str, model_path: str, config_path: str, output_path: str) -
try:
result = subprocess.run(cmd, input=text, text=True, capture_output=True, timeout=60)
if result.returncode == 0:
if not os.path.exists(output_path):
return f"ERROR: Piper reported success but did not create output: {output_path}"
return os.path.abspath(output_path)
return f"ERROR: {result.stderr}"
except Exception as e:

View file

@ -0,0 +1,23 @@
#include "HyperTwistBrowser/HyperTwistBrowserBridgeObject.h"
void UHyperTwistBrowserBridgeObject::NotifyEnvelope(const FString& EnvelopeJson)
{
LastEnvelopeJson = EnvelopeJson;
}
void UHyperTwistBrowserBridgeObject::NotifyState(const FString& StateJson)
{
LastStateJson = StateJson;
}
void UHyperTwistBrowserBridgeObject::NotifyRuntimeReady(const FString& RuntimeReadyJson)
{
LastRuntimeReadyJson = RuntimeReadyJson;
}
void UHyperTwistBrowserBridgeObject::ResetReceivedMessages()
{
LastEnvelopeJson.Reset();
LastStateJson.Reset();
LastRuntimeReadyJson.Reset();
}

View file

@ -0,0 +1,177 @@
#include "HyperTwistBrowser/HyperTwistBrowserWidget.h"
#include "HyperTwistBrowser/HyperTwistBrowserBridgeObject.h"
#include "Misc/Paths.h"
#include "SWebBrowser.h"
namespace HyperTwistBrowserWidgetInternal
{
FString MakeFileUrl(const FString& AbsolutePath)
{
FString Normalized = FPaths::ConvertRelativePathToFull(AbsolutePath);
FPaths::NormalizeFilename(Normalized);
Normalized.ReplaceInline(TEXT(" "), TEXT("%20"));
return FString::Printf(TEXT("file:///%s"), *Normalized);
}
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();
}
}
void UHyperTwistBrowserWidget::LoadBundledBrowserShell()
{
bUseBundledBrowserShell = true;
InitialUrl = ResolveBundledBrowserShellUrl();
if (BrowserWidget.IsValid())
{
BrowserWidget->LoadURL(InitialUrl);
}
}
void UHyperTwistBrowserWidget::LoadBrowserUrl(const FString& NewUrl)
{
bUseBundledBrowserShell = false;
InitialUrl = NewUrl;
if (BrowserWidget.IsValid() && !NewUrl.IsEmpty())
{
BrowserWidget->LoadURL(NewUrl);
}
}
void UHyperTwistBrowserWidget::DispatchCommandJson(const FString& CommandJson)
{
if (!BrowserWidget.IsValid() || CommandJson.IsEmpty())
{
return;
}
const FString EscapedJson = EscapeForJavaScriptSingleQuotedString(CommandJson);
BrowserWidget->ExecuteJavascript(FString::Printf(
TEXT("window.HyperTwistBrowserRuntime && window.HyperTwistBrowserRuntime.receiveCommand(JSON.parse('%s'));"),
*EscapedJson
));
}
void UHyperTwistBrowserWidget::PushShellStateJson(const FString& StateJson)
{
if (!BrowserWidget.IsValid() || StateJson.IsEmpty())
{
return;
}
const FString EscapedJson = EscapeForJavaScriptSingleQuotedString(StateJson);
BrowserWidget->ExecuteJavascript(FString::Printf(
TEXT("window.HyperTwistBrowserRuntime && window.HyperTwistBrowserRuntime.setShellState(JSON.parse('%s'));"),
*EscapedJson
));
}
FString UHyperTwistBrowserWidget::GetLastEnvelopeJson() const
{
return BrowserBridgeObject != nullptr ? BrowserBridgeObject->LastEnvelopeJson : FString();
}
FString UHyperTwistBrowserWidget::GetLastRuntimeReadyJson() const
{
return BrowserBridgeObject != nullptr ? BrowserBridgeObject->LastRuntimeReadyJson : FString();
}
FString UHyperTwistBrowserWidget::GetLastStateJson() const
{
return BrowserBridgeObject != nullptr ? BrowserBridgeObject->LastStateJson : FString();
}
FString UHyperTwistBrowserWidget::ResolveBundledBrowserShellUrl()
{
return HyperTwistBrowserWidgetInternal::MakeFileUrl(
HyperTwistBrowserWidgetInternal::ResolveBundledBrowserShellAbsolutePath()
);
}
TSharedRef<SWidget> UHyperTwistBrowserWidget::RebuildWidget()
{
if (BrowserBridgeObject == nullptr)
{
BrowserBridgeObject = NewObject<UHyperTwistBrowserBridgeObject>(this, TEXT("HyperTwistBrowserBridge"));
}
else
{
BrowserBridgeObject->ResetReceivedMessages();
}
BrowserWidget =
SNew(SWebBrowser)
.InitialURL(ResolveInitialUrl())
.ShowControls(bShowBrowserControls);
EnsureBridgeBound();
return BrowserWidget.ToSharedRef();
}
void UHyperTwistBrowserWidget::ReleaseSlateResources(const bool bReleaseChildren)
{
Super::ReleaseSlateResources(bReleaseChildren);
if (BrowserWidget.IsValid() && BrowserBridgeObject != nullptr)
{
BrowserWidget->UnbindUObject(TEXT("hypertwist"), BrowserBridgeObject, true);
}
BrowserWidget.Reset();
}
void UHyperTwistBrowserWidget::SynchronizeProperties()
{
Super::SynchronizeProperties();
if (BrowserWidget.IsValid())
{
EnsureBridgeBound();
const FString ResolvedUrl = ResolveInitialUrl();
if (!ResolvedUrl.IsEmpty())
{
BrowserWidget->LoadURL(ResolvedUrl);
}
}
}
FString UHyperTwistBrowserWidget::EscapeForJavaScriptSingleQuotedString(const FString& Input)
{
FString Escaped = Input;
Escaped.ReplaceInline(TEXT("\\"), TEXT("\\\\"));
Escaped.ReplaceInline(TEXT("'"), TEXT("\\'"));
Escaped.ReplaceInline(TEXT("\r"), TEXT("\\r"));
Escaped.ReplaceInline(TEXT("\n"), TEXT("\\n"));
Escaped.ReplaceInline(TEXT("\t"), TEXT("\\t"));
return Escaped;
}
FString UHyperTwistBrowserWidget::ResolveInitialUrl() const
{
return bUseBundledBrowserShell
? ResolveBundledBrowserShellUrl()
: InitialUrl;
}
void UHyperTwistBrowserWidget::EnsureBridgeBound()
{
if (BrowserWidget.IsValid() && BrowserBridgeObject != nullptr)
{
BrowserWidget->BindUObject(TEXT("hypertwist"), BrowserBridgeObject, true);
}
}

View file

@ -1,5 +1,39 @@
#include "HyperTwistIPC/HyperTwistIPCProcessManager.h"
namespace HyperTwistIPCProcessManagerInternal
{
bool RequiresExplicitPathCheck(const FString& ExecutablePath)
{
return ExecutablePath.Contains(TEXT("/"))
|| ExecutablePath.Contains(TEXT("\\"))
|| ExecutablePath.Contains(TEXT(":"));
}
FString QuoteProcessArgument(const FString& Argument)
{
FString Escaped = Argument;
Escaped.ReplaceInline(TEXT("\""), TEXT("\\\""));
const bool bNeedsQuotes = Escaped.IsEmpty()
|| Escaped.Contains(TEXT(" "))
|| Escaped.Contains(TEXT("\t"))
|| Escaped.Contains(TEXT("\""));
return bNeedsQuotes ? FString::Printf(TEXT("\"%s\""), *Escaped) : Escaped;
}
FString BuildCommandLine(const TArray<FString>& Arguments)
{
TArray<FString> QuotedArguments;
QuotedArguments.Reserve(Arguments.Num());
for (const FString& Argument : Arguments)
{
QuotedArguments.Add(QuoteProcessArgument(Argument));
}
return FString::Join(QuotedArguments, TEXT(" "));
}
}
UHyperTwistIPCProcessManager::UHyperTwistIPCProcessManager()
{
}
@ -19,13 +53,20 @@ FHyperTwistIPCResult UHyperTwistIPCProcessManager::RunProcess(const FHyperTwistI
{
FHyperTwistIPCResult Result;
if (!FPaths::FileExists(Options.ExecutablePath))
if (Options.ExecutablePath.IsEmpty())
{
Result.ErrorMessage = TEXT("Executable path is empty");
return Result;
}
if (HyperTwistIPCProcessManagerInternal::RequiresExplicitPathCheck(Options.ExecutablePath)
&& !FPaths::FileExists(Options.ExecutablePath))
{
Result.ErrorMessage = FString::Printf(TEXT("Executable not found: %s"), *Options.ExecutablePath);
return Result;
}
FString Params = FString::Join(Options.Arguments, TEXT(" "));
FString Params = HyperTwistIPCProcessManagerInternal::BuildCommandLine(Options.Arguments);
FString WorkingDir = Options.WorkingDirectory.IsEmpty()
? FPaths::GetPath(Options.ExecutablePath)
: Options.WorkingDirectory;
@ -115,12 +156,18 @@ FHyperTwistIPCResult UHyperTwistIPCProcessManager::RunProcess(const FHyperTwistI
int32 UHyperTwistIPCProcessManager::StartProcess(const FHyperTwistIPCProcessOptions& Options)
{
if (!FPaths::FileExists(Options.ExecutablePath))
if (Options.ExecutablePath.IsEmpty())
{
return -1;
}
FString Params = FString::Join(Options.Arguments, TEXT(" "));
if (HyperTwistIPCProcessManagerInternal::RequiresExplicitPathCheck(Options.ExecutablePath)
&& !FPaths::FileExists(Options.ExecutablePath))
{
return -1;
}
FString Params = HyperTwistIPCProcessManagerInternal::BuildCommandLine(Options.Arguments);
FString WorkingDir = Options.WorkingDirectory.IsEmpty()
? FPaths::GetPath(Options.ExecutablePath)
: Options.WorkingDirectory;

View file

@ -1,10 +1,73 @@
#include "HyperTwistIPC/HyperTwistSolverOracleLibrary.h"
#include "HyperTwistSolverLibrary.h"
#include "ThirdParty/rob-twophase/cubie.h"
#include "ThirdParty/rob-twophase/face.h"
#include "ThirdParty/rob-twophase/move.h"
#include "Misc/Paths.h"
#include "Misc/FileHelper.h"
#include "HAL/PlatformFilemanager.h"
#include "UObject/Package.h"
namespace HyperTwistSolverOracleLibraryInternal
{
FString GetPythonExecutableName()
{
#if PLATFORM_WINDOWS
return TEXT("python.exe");
#else
return TEXT("python3");
#endif
}
bool TryResolveRobTwophaseMoveIndex(const FString& MoveString, int32& OutMoveIndex)
{
const FString TrimmedMove = MoveString.TrimStartAndEnd();
for (int32 MoveIndex = 0; MoveIndex < move::COUNT; ++MoveIndex)
{
if (TrimmedMove.Equals(UTF8_TO_TCHAR(move::names[MoveIndex].c_str()), ESearchCase::IgnoreCase))
{
OutMoveIndex = MoveIndex;
return true;
}
}
return false;
}
bool TryReplayMoveSequence(
const FString& FaceletString,
const TArray<FString>& Moves,
cubie::cube& OutCube
)
{
if (!UHyperTwistSolverLibrary::InitializeSolver() || !UHyperTwistSolverLibrary::VerifyFaceletString(FaceletString))
{
return false;
}
std::string Facelets(TCHAR_TO_UTF8(*FaceletString));
if (face::to_cubie(Facelets, OutCube) != 0 || cubie::check_cube(OutCube) != 0)
{
return false;
}
for (const FString& MoveString : Moves)
{
int32 MoveIndex = INDEX_NONE;
if (!TryResolveRobTwophaseMoveIndex(MoveString, MoveIndex))
{
return false;
}
cubie::cube NextCube;
cubie::mul(OutCube, move::cubes[MoveIndex], NextCube);
OutCube = NextCube;
}
return cubie::check_cube(OutCube) == 0;
}
}
FString UHyperTwistSolverOracleLibrary::GetBrownanSolverPath()
{
return FPaths::ConvertRelativePathToFull(
@ -44,19 +107,27 @@ TArray<FString> UHyperTwistSolverOracleLibrary::SolveWithBrownanOracle(const FSt
GetTransientPackage());
FHyperTwistIPCProcessOptions Options;
Options.ExecutablePath = TEXT("python.exe");
Options.ExecutablePath = HyperTwistSolverOracleLibraryInternal::GetPythonExecutableName();
Options.Arguments.Add(WrapperPath);
Options.Arguments.Add(FaceletString);
Options.WorkingDirectory = FPaths::GetPath(WrapperPath);
Options.TimeoutSeconds = 60.0f;
FHyperTwistIPCResult Result = IPC->RunProcess(Options);
if (!Result.bSuccess)
{
UE_LOG(LogTemp, Warning, TEXT("Brownan solver failed: %s"), *Result.ErrorMessage);
const FString FailureDetails = Result.ErrorMessage.IsEmpty() ? Result.StdOut : Result.ErrorMessage;
UE_LOG(LogTemp, Warning, TEXT("Brownan solver failed: %s"), *FailureDetails);
return EmptyResult;
}
FString Output = Result.StdOut.TrimStartAndEnd();
if (Output.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("Brownan solver returned no parseable solution output for state '%s'."), *FaceletString);
return EmptyResult;
}
TArray<FString> Moves;
Output.ParseIntoArrayWS(Moves);
return Moves;
@ -64,10 +135,9 @@ TArray<FString> UHyperTwistSolverOracleLibrary::SolveWithBrownanOracle(const FSt
bool UHyperTwistSolverOracleLibrary::VerifySolutionWithBrownanOracle(const FString& CubeState, const TArray<FString>& Moves)
{
TArray<FString> BrownanMoves = SolveWithBrownanOracle(CubeState);
FString Output = FString::Join(BrownanMoves, TEXT(" ")).TrimStartAndEnd();
FString Expected = FString::Join(Moves, TEXT(" ")).TrimStartAndEnd();
return Output.Equals(Expected, ESearchCase::IgnoreCase);
cubie::cube FinalCube;
return HyperTwistSolverOracleLibraryInternal::TryReplayMoveSequence(CubeState, Moves, FinalCube)
&& FinalCube == cubie::SOLVED_CUBE;
}
TArray<FString> UHyperTwistSolverOracleLibrary::SolveWithCahidenesOracle(const FString& FaceletString)
@ -84,19 +154,27 @@ TArray<FString> UHyperTwistSolverOracleLibrary::SolveWithCahidenesOracle(const F
GetTransientPackage());
FHyperTwistIPCProcessOptions Options;
Options.ExecutablePath = TEXT("python.exe");
Options.ExecutablePath = HyperTwistSolverOracleLibraryInternal::GetPythonExecutableName();
Options.Arguments.Add(WrapperPath);
Options.Arguments.Add(FaceletString);
Options.WorkingDirectory = FPaths::GetPath(WrapperPath);
Options.TimeoutSeconds = 30.0f;
FHyperTwistIPCResult Result = IPC->RunProcess(Options);
if (!Result.bSuccess)
{
UE_LOG(LogTemp, Warning, TEXT("Cahidenes solver failed: %s"), *Result.ErrorMessage);
const FString FailureDetails = Result.ErrorMessage.IsEmpty() ? Result.StdOut : Result.ErrorMessage;
UE_LOG(LogTemp, Warning, TEXT("Cahidenes solver failed: %s"), *FailureDetails);
return EmptyResult;
}
FString Output = Result.StdOut.TrimStartAndEnd();
if (Output.IsEmpty())
{
UE_LOG(LogTemp, Warning, TEXT("Cahidenes solver returned no parseable solution output for state '%s'."), *FaceletString);
return EmptyResult;
}
TArray<FString> Moves;
Output.ParseIntoArrayWS(Moves);
return Moves;
@ -104,6 +182,11 @@ TArray<FString> UHyperTwistSolverOracleLibrary::SolveWithCahidenesOracle(const F
bool UHyperTwistSolverOracleLibrary::CompareSolvers(const FString& FaceletString)
{
if (!UHyperTwistSolverLibrary::VerifyFaceletString(FaceletString))
{
return false;
}
// rob-twophase result
TArray<FString> RobTwophaseMoves = UHyperTwistSolverLibrary::SolveClassicState(FaceletString, 5000, 25, 1);
@ -117,11 +200,17 @@ bool UHyperTwistSolverOracleLibrary::CompareSolvers(const FString& FaceletString
const int32 CahCount = CahidenesMoves.Num();
const int32 BrownCount = BrownanMoves.Num();
if (RobCount > 0 && CahCount > 0 && BrownCount > 0)
if (RobCount <= 0 || CahCount <= 0 || BrownCount <= 0)
{
return RobCount == CahCount && CahCount == BrownCount;
return false;
}
// If any solver failed, consider comparison inconclusive but not a hard failure
return true;
if (!VerifySolutionWithBrownanOracle(FaceletString, RobTwophaseMoves)
|| !VerifySolutionWithBrownanOracle(FaceletString, CahidenesMoves)
|| !VerifySolutionWithBrownanOracle(FaceletString, BrownanMoves))
{
return false;
}
return RobCount == CahCount && CahCount == BrownCount;
}

View file

@ -0,0 +1,515 @@
#include "HyperTwistRecognition/HyperTwistSpeechLibrary.h"
#include "Components/AudioComponent.h"
#include "Engine/Engine.h"
#include "Engine/GameInstance.h"
#include "EngineUtils.h"
#include "HyperTwistBootstrap/HyperTwistContractLibrary.h"
#include "HyperTwistRecognition/HyperTwistVoiceClient.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
#include "TimerManager.h"
namespace HyperTwistSpeechLibraryInternal
{
const FName SpeechAudioComponentTag(TEXT("HyperTwistSpeechAudio"));
constexpr float DefaultDuckVolumeMultiplier = 0.15f;
constexpr float RestoreDelaySeconds = 0.2f;
struct FManagedAudioComponentEntry
{
TWeakObjectPtr<UAudioComponent> AudioComponent;
bool bTreatAsSpeechAudio = false;
};
struct FManagedAudioRegistry
{
TArray<FManagedAudioComponentEntry> RegisteredComponents;
TMap<TWeakObjectPtr<UAudioComponent>, float> OriginalVolumeMap;
FTimerHandle RestoreTimerHandle;
bool bDuckingActive = false;
};
FManagedAudioRegistry GManagedAudioRegistry;
UHyperTwistTrainingSubsystem* ResolveTrainingSubsystem(UObject* ContextObject)
{
if (UHyperTwistTrainingSubsystem* TrainingSubsystem = Cast<UHyperTwistTrainingSubsystem>(ContextObject))
{
return TrainingSubsystem;
}
if (ContextObject == nullptr)
{
return nullptr;
}
if (UWorld* World = ContextObject->GetWorld())
{
if (UGameInstance* GameInstance = World->GetGameInstance())
{
return GameInstance->GetSubsystem<UHyperTwistTrainingSubsystem>();
}
}
return nullptr;
}
IHyperTwistVoiceClient* ResolveVoiceClient(UObject* VoiceClientObject)
{
return VoiceClientObject != nullptr ? Cast<IHyperTwistVoiceClient>(VoiceClientObject) : nullptr;
}
UObject* CreateVoiceClient(UObject* ContextObject, const bool bUseMockVoiceClient)
{
UObject* Outer = ContextObject != nullptr ? ContextObject : GetTransientPackage();
return bUseMockVoiceClient
? static_cast<UObject*>(NewObject<UHyperTwistMockVoiceClient>(Outer))
: static_cast<UObject*>(NewObject<UHyperTwistHttpVoiceClient>(Outer));
}
void CompactManagedAudioRegistry()
{
GManagedAudioRegistry.RegisteredComponents.RemoveAll(
[](const FManagedAudioComponentEntry& Entry)
{
return !Entry.AudioComponent.IsValid();
}
);
for (auto It = GManagedAudioRegistry.OriginalVolumeMap.CreateIterator(); It; ++It)
{
if (!It.Key().IsValid())
{
It.RemoveCurrent();
}
}
}
bool IsSpeechAudioComponent(const UAudioComponent* AudioComponent)
{
if (AudioComponent == nullptr)
{
return false;
}
if (AudioComponent->ComponentHasTag(SpeechAudioComponentTag))
{
return true;
}
for (const FManagedAudioComponentEntry& Entry : GManagedAudioRegistry.RegisteredComponents)
{
if (Entry.AudioComponent.Get() == AudioComponent)
{
return Entry.bTreatAsSpeechAudio;
}
}
return false;
}
TArray<UAudioComponent*> ResolveManagedAudioComponents()
{
CompactManagedAudioRegistry();
TArray<UAudioComponent*> Components;
for (const FManagedAudioComponentEntry& Entry : GManagedAudioRegistry.RegisteredComponents)
{
if (UAudioComponent* AudioComponent = Entry.AudioComponent.Get())
{
Components.AddUnique(AudioComponent);
}
}
if (Components.Num() > 0)
{
return Components;
}
if (GEngine == nullptr)
{
return Components;
}
for (const FWorldContext& WorldContext : GEngine->GetWorldContexts())
{
UWorld* World = WorldContext.World();
if (World == nullptr || World->IsPreviewWorld())
{
continue;
}
for (TActorIterator<AActor> ActorIt(World); ActorIt; ++ActorIt)
{
TInlineComponentArray<UAudioComponent*> AudioComponents(*ActorIt);
for (UAudioComponent* AudioComponent : AudioComponents)
{
if (AudioComponent != nullptr)
{
Components.AddUnique(AudioComponent);
}
}
}
}
return Components;
}
int32 ApplyManagedAudioDucking(const float DuckVolumeMultiplier)
{
CompactManagedAudioRegistry();
const float TargetVolume = FMath::Clamp(DuckVolumeMultiplier, 0.0f, 1.0f);
const TArray<UAudioComponent*> AudioComponents = ResolveManagedAudioComponents();
int32 DuckedComponentCount = 0;
for (UAudioComponent* AudioComponent : AudioComponents)
{
if (AudioComponent == nullptr || IsSpeechAudioComponent(AudioComponent))
{
continue;
}
const TWeakObjectPtr<UAudioComponent> AudioComponentKey(AudioComponent);
if (!GManagedAudioRegistry.OriginalVolumeMap.Contains(AudioComponentKey))
{
GManagedAudioRegistry.OriginalVolumeMap.Add(AudioComponentKey, AudioComponent->VolumeMultiplier);
}
AudioComponent->SetVolumeMultiplier(TargetVolume);
++DuckedComponentCount;
}
GManagedAudioRegistry.bDuckingActive = DuckedComponentCount > 0;
return DuckedComponentCount;
}
int32 RestoreManagedAudioDucking()
{
CompactManagedAudioRegistry();
int32 RestoredComponentCount = 0;
for (auto It = GManagedAudioRegistry.OriginalVolumeMap.CreateIterator(); It; ++It)
{
if (UAudioComponent* AudioComponent = It.Key().Get())
{
AudioComponent->SetVolumeMultiplier(It.Value());
++RestoredComponentCount;
}
It.RemoveCurrent();
}
GManagedAudioRegistry.bDuckingActive = false;
return RestoredComponentCount;
}
void ScheduleManagedAudioRestore(UObject* ContextObject)
{
if (ContextObject != nullptr)
{
if (UWorld* World = ContextObject->GetWorld())
{
World->GetTimerManager().ClearTimer(GManagedAudioRegistry.RestoreTimerHandle);
World->GetTimerManager().SetTimer(
GManagedAudioRegistry.RestoreTimerHandle,
[]()
{
RestoreManagedAudioDucking();
},
RestoreDelaySeconds,
false
);
return;
}
}
RestoreManagedAudioDucking();
}
uint32 ReadLittleEndianUint32(const TArray<uint8>& Bytes, const int32 Offset)
{
if (!Bytes.IsValidIndex(Offset + 3))
{
return 0;
}
return static_cast<uint32>(Bytes[Offset])
| (static_cast<uint32>(Bytes[Offset + 1]) << 8)
| (static_cast<uint32>(Bytes[Offset + 2]) << 16)
| (static_cast<uint32>(Bytes[Offset + 3]) << 24);
}
TArray<uint8> ExtractPcm16Payload(const TArray<uint8>& AudioBytes, const FString& AudioEncoding)
{
if (!AudioEncoding.Contains(TEXT("wav"), ESearchCase::IgnoreCase)
|| AudioBytes.Num() < 12
|| AudioBytes[0] != 'R'
|| AudioBytes[1] != 'I'
|| AudioBytes[2] != 'F'
|| AudioBytes[3] != 'F'
|| AudioBytes[8] != 'W'
|| AudioBytes[9] != 'A'
|| AudioBytes[10] != 'V'
|| AudioBytes[11] != 'E')
{
return AudioBytes;
}
int32 ChunkOffset = 12;
while (ChunkOffset + 8 <= AudioBytes.Num())
{
const uint32 ChunkSize = ReadLittleEndianUint32(AudioBytes, ChunkOffset + 4);
const int32 ChunkDataOffset = ChunkOffset + 8;
const int32 ChunkEndOffset = ChunkDataOffset + static_cast<int32>(ChunkSize);
if (ChunkEndOffset > AudioBytes.Num())
{
break;
}
if (AudioBytes[ChunkOffset] == 'd'
&& AudioBytes[ChunkOffset + 1] == 'a'
&& AudioBytes[ChunkOffset + 2] == 't'
&& AudioBytes[ChunkOffset + 3] == 'a')
{
TArray<uint8> Payload;
Payload.Append(AudioBytes.GetData() + ChunkDataOffset, static_cast<int32>(ChunkSize));
return Payload;
}
ChunkOffset = ChunkEndOffset + (ChunkSize % 2 == 0 ? 0 : 1);
}
return AudioBytes;
}
}
bool UHyperTwistSpeechLibrary::StartDictationSession(
UObject* WorldContextObject,
FString& OutError,
const bool bDuckAudio,
const float DuckVolumeMultiplier
)
{
if (UHyperTwistTrainingSubsystem* TrainingSubsystem =
HyperTwistSpeechLibraryInternal::ResolveTrainingSubsystem(WorldContextObject))
{
if (TrainingSubsystem->OpenActiveCompanionSpeechSession(OutError))
{
if (bDuckAudio)
{
HyperTwistSpeechLibraryInternal::ApplyManagedAudioDucking(DuckVolumeMultiplier);
}
return true;
}
}
else
{
OutError = TEXT("training-subsystem-unavailable");
}
return false;
}
FHyperTwistSpeechTranscriptResult UHyperTwistSpeechLibrary::SubmitDictationUtterance(
UObject* WorldContextObject,
const FHyperTwistSpeechUtteranceEnvelope& Utterance
)
{
if (UHyperTwistTrainingSubsystem* TrainingSubsystem =
HyperTwistSpeechLibraryInternal::ResolveTrainingSubsystem(WorldContextObject))
{
const FHyperTwistTrainingCompanionSpeechSessionState SessionState =
TrainingSubsystem->GetActiveCompanionSpeechSessionState();
if (!SessionState.bSessionOpen)
{
FString OpenError;
StartDictationSession(WorldContextObject, OpenError, true, HyperTwistSpeechLibraryInternal::DefaultDuckVolumeMultiplier);
}
return TrainingSubsystem->SubmitActiveCompanionSpeechUtterance(Utterance);
}
FHyperTwistSpeechTranscriptResult Result;
Result.Warnings.Add(TEXT("training-subsystem-unavailable"));
return Result;
}
bool UHyperTwistSpeechLibrary::EndDictationSession(
UObject* WorldContextObject,
FString& OutError,
const bool bRestoreAudio
)
{
if (UHyperTwistTrainingSubsystem* TrainingSubsystem =
HyperTwistSpeechLibraryInternal::ResolveTrainingSubsystem(WorldContextObject))
{
const bool bClosed = TrainingSubsystem->CloseActiveCompanionSpeechSession(OutError);
if (bClosed && bRestoreAudio)
{
HyperTwistSpeechLibraryInternal::ScheduleManagedAudioRestore(WorldContextObject);
}
return bClosed;
}
OutError = TEXT("training-subsystem-unavailable");
return false;
}
FHyperTwistTrainingCompanionSpeechSessionState UHyperTwistSpeechLibrary::GetActiveDictationSessionState(
UObject* WorldContextObject
)
{
if (UHyperTwistTrainingSubsystem* TrainingSubsystem =
HyperTwistSpeechLibraryInternal::ResolveTrainingSubsystem(WorldContextObject))
{
return TrainingSubsystem->GetActiveCompanionSpeechSessionState();
}
return FHyperTwistTrainingCompanionSpeechSessionState();
}
void UHyperTwistSpeechLibrary::RegisterManagedAudioComponent(
UAudioComponent* AudioComponent,
const bool bTreatAsSpeechAudio
)
{
if (AudioComponent == nullptr)
{
return;
}
HyperTwistSpeechLibraryInternal::CompactManagedAudioRegistry();
for (HyperTwistSpeechLibraryInternal::FManagedAudioComponentEntry& Entry :
HyperTwistSpeechLibraryInternal::GManagedAudioRegistry.RegisteredComponents)
{
if (Entry.AudioComponent.Get() == AudioComponent)
{
Entry.bTreatAsSpeechAudio = bTreatAsSpeechAudio;
if (bTreatAsSpeechAudio)
{
AudioComponent->ComponentTags.AddUnique(
HyperTwistSpeechLibraryInternal::SpeechAudioComponentTag
);
}
return;
}
}
HyperTwistSpeechLibraryInternal::FManagedAudioComponentEntry Entry;
Entry.AudioComponent = AudioComponent;
Entry.bTreatAsSpeechAudio = bTreatAsSpeechAudio;
HyperTwistSpeechLibraryInternal::GManagedAudioRegistry.RegisteredComponents.Add(Entry);
if (bTreatAsSpeechAudio)
{
AudioComponent->ComponentTags.AddUnique(HyperTwistSpeechLibraryInternal::SpeechAudioComponentTag);
}
}
void UHyperTwistSpeechLibrary::UnregisterManagedAudioComponent(UAudioComponent* AudioComponent)
{
if (AudioComponent == nullptr)
{
return;
}
HyperTwistSpeechLibraryInternal::GManagedAudioRegistry.RegisteredComponents.RemoveAll(
[AudioComponent](const HyperTwistSpeechLibraryInternal::FManagedAudioComponentEntry& Entry)
{
return Entry.AudioComponent.Get() == AudioComponent;
}
);
HyperTwistSpeechLibraryInternal::GManagedAudioRegistry.OriginalVolumeMap.Remove(AudioComponent);
}
int32 UHyperTwistSpeechLibrary::DuckManagedAudioComponents(const float DuckVolumeMultiplier)
{
return HyperTwistSpeechLibraryInternal::ApplyManagedAudioDucking(DuckVolumeMultiplier);
}
int32 UHyperTwistSpeechLibrary::RestoreManagedAudioComponents()
{
return HyperTwistSpeechLibraryInternal::RestoreManagedAudioDucking();
}
int32 UHyperTwistSpeechLibrary::GetManagedAudioComponentCount()
{
HyperTwistSpeechLibraryInternal::CompactManagedAudioRegistry();
return HyperTwistSpeechLibraryInternal::GManagedAudioRegistry.RegisteredComponents.Num();
}
TArray<FHyperTwistVoiceProfileSummary> UHyperTwistSpeechLibrary::ListVoiceProfiles(
UObject* ContextObject,
const bool bUseMockVoiceClient
)
{
TStrongObjectPtr<UObject> VoiceClientObject(
HyperTwistSpeechLibraryInternal::CreateVoiceClient(ContextObject, bUseMockVoiceClient)
);
if (IHyperTwistVoiceClient* VoiceClient =
HyperTwistSpeechLibraryInternal::ResolveVoiceClient(VoiceClientObject.Get()))
{
return VoiceClient->ListVoiceProfiles();
}
return TArray<FHyperTwistVoiceProfileSummary>();
}
FHyperTwistNarrationSynthesisResult UHyperTwistSpeechLibrary::SynthesizeNarration(
UObject* ContextObject,
const FHyperTwistNarrationSynthesisRequest& Request,
const bool bUseMockVoiceClient
)
{
TStrongObjectPtr<UObject> VoiceClientObject(
HyperTwistSpeechLibraryInternal::CreateVoiceClient(ContextObject, bUseMockVoiceClient)
);
if (IHyperTwistVoiceClient* VoiceClient =
HyperTwistSpeechLibraryInternal::ResolveVoiceClient(VoiceClientObject.Get()))
{
return VoiceClient->SynthesizeNarration(Request);
}
FHyperTwistNarrationSynthesisResult Result;
Result.RequestId = Request.RequestId;
Result.Warnings.Add(TEXT("voice-client-unavailable"));
return Result;
}
TArray<uint8> UHyperTwistSpeechLibrary::Synthesize(
UObject* ContextObject,
const FString& Text,
const FString& VoiceId,
const bool bUseMockVoiceClient
)
{
FHyperTwistNarrationSynthesisRequest Request =
UHyperTwistContractLibrary::MakeSampleNarrationSynthesisRequest();
Request.RequestId = FGuid::NewGuid().ToString(EGuidFormats::DigitsWithHyphensLower);
Request.ScriptText = Text;
Request.SubtitleSeedText = Text;
if (!VoiceId.IsEmpty())
{
Request.VoiceProfileId = VoiceId;
}
const FHyperTwistNarrationSynthesisResult Result =
SynthesizeNarration(ContextObject, Request, bUseMockVoiceClient);
return HyperTwistSpeechLibraryInternal::ExtractPcm16Payload(Result.AudioBytes, Result.AudioEncoding);
}
FHyperTwistVoiceServiceHealth UHyperTwistSpeechLibrary::GetVoiceServiceHealth(
UObject* ContextObject,
const bool bUseMockVoiceClient
)
{
TStrongObjectPtr<UObject> VoiceClientObject(
HyperTwistSpeechLibraryInternal::CreateVoiceClient(ContextObject, bUseMockVoiceClient)
);
if (IHyperTwistVoiceClient* VoiceClient =
HyperTwistSpeechLibraryInternal::ResolveVoiceClient(VoiceClientObject.Get()))
{
return VoiceClient->GetVoiceServiceHealth();
}
return FHyperTwistVoiceServiceHealth();
}

View file

@ -1,6 +1,11 @@
#include "HyperTwistSimulation/HyperTwistClassicCubeActor.h"
#include "Engine/World.h"
namespace HyperTwistClassicCubeActorInternal
{
constexpr float RotationDotTolerance = 0.9999f;
}
AHyperTwistClassicCubeActor::AHyperTwistClassicCubeActor()
{
PrimaryActorTick.bCanEverTick = true;
@ -54,6 +59,9 @@ void AHyperTwistClassicCubeActor::ResetCube()
ClearPieces();
TrackedPieces.Empty();
RotationQueue.Empty();
CurrentScrambleMoves.Empty();
TotalCompletedMoveCount = 0;
GameplayCompletedMoveCount = 0;
bIsAnimating = false;
ActiveRotation = FActiveRotation();
GenerateCube();
@ -76,25 +84,35 @@ bool AHyperTwistClassicCubeActor::ProcessClick(const FVector& RayOrigin, const F
return false;
}
// Map hit normal to cube face
const FVector& Normal = Hit.ImpactNormal;
// 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;
bool bValidFace = true;
if (FMath::IsNearlyEqual(Normal.Z, 1.0f)) Face = EHyperTwistClassicCubeFace::Up;
else if (FMath::IsNearlyEqual(Normal.Z, -1.0f)) Face = EHyperTwistClassicCubeFace::Down;
else if (FMath::IsNearlyEqual(Normal.Y, 1.0f)) Face = EHyperTwistClassicCubeFace::Front;
else if (FMath::IsNearlyEqual(Normal.Y, -1.0f)) Face = EHyperTwistClassicCubeFace::Back;
else if (FMath::IsNearlyEqual(Normal.X, 1.0f)) Face = EHyperTwistClassicCubeFace::Right;
else if (FMath::IsNearlyEqual(Normal.X, -1.0f)) Face = EHyperTwistClassicCubeFace::Left;
else bValidFace = false;
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 (!bValidFace)
if (Normal.IsNearlyZero())
{
return false;
}
RotateFace(Face, bCounterClockwise ? EHyperTwistRotationDirection::CounterClockwise : EHyperTwistRotationDirection::Clockwise);
RotateFace(
Face,
bCounterClockwise ? EHyperTwistRotationDirection::CounterClockwise
: EHyperTwistRotationDirection::Clockwise
);
return true;
}
@ -103,6 +121,14 @@ TArray<FString> AHyperTwistClassicCubeActor::GenerateScramble(int32 Length)
TArray<FString> Result;
const TArray<FString> FaceNames = { TEXT("U"), TEXT("D"), TEXT("F"), TEXT("B"), TEXT("R"), TEXT("L") };
const TArray<FString> Suffixes = { TEXT(""), TEXT("'"), TEXT("2") };
const TArray<EHyperTwistClassicCubeFace> FaceOrder = {
EHyperTwistClassicCubeFace::Up,
EHyperTwistClassicCubeFace::Down,
EHyperTwistClassicCubeFace::Front,
EHyperTwistClassicCubeFace::Back,
EHyperTwistClassicCubeFace::Right,
EHyperTwistClassicCubeFace::Left
};
// Seed random if not already seeded
static bool bSeeded = false;
@ -112,59 +138,72 @@ TArray<FString> AHyperTwistClassicCubeActor::GenerateScramble(int32 Length)
bSeeded = true;
}
Length = FMath::Max(0, Length);
int32 PreviousFaceIndex = INDEX_NONE;
int32 PreviousAxisIndex = INDEX_NONE;
for (int32 i = 0; i < Length; ++i)
{
FString Move = FaceNames[FMath::RandRange(0, 5)];
TArray<int32> CandidateFaceIndices;
for (int32 FaceIndex = 0; FaceIndex < FaceNames.Num(); ++FaceIndex)
{
if (FaceIndex == PreviousFaceIndex)
{
continue;
}
const int32 AxisIndex = GetFaceAxisIndex(FaceOrder[FaceIndex]);
if (AxisIndex == PreviousAxisIndex)
{
continue;
}
CandidateFaceIndices.Add(FaceIndex);
}
if (CandidateFaceIndices.IsEmpty())
{
for (int32 FaceIndex = 0; FaceIndex < FaceNames.Num(); ++FaceIndex)
{
if (FaceIndex != PreviousFaceIndex)
{
CandidateFaceIndices.Add(FaceIndex);
}
}
}
const int32 SelectedFaceIndex =
CandidateFaceIndices[FMath::RandRange(0, CandidateFaceIndices.Num() - 1)];
FString Move = FaceNames[SelectedFaceIndex];
FString Suffix = Suffixes[FMath::RandRange(0, 2)];
Result.Add(Move + Suffix);
PreviousFaceIndex = SelectedFaceIndex;
PreviousAxisIndex = GetFaceAxisIndex(FaceOrder[SelectedFaceIndex]);
}
return Result;
}
void AHyperTwistClassicCubeActor::ApplyScramble(const TArray<FString>& MoveStrings)
{
auto ParseFaceChar = [](TCHAR C) -> EHyperTwistClassicCubeFace
{
switch (C)
{
case TEXT('U'): return EHyperTwistClassicCubeFace::Up;
case TEXT('D'): return EHyperTwistClassicCubeFace::Down;
case TEXT('F'): return EHyperTwistClassicCubeFace::Front;
case TEXT('B'): return EHyperTwistClassicCubeFace::Back;
case TEXT('R'): return EHyperTwistClassicCubeFace::Right;
case TEXT('L'): return EHyperTwistClassicCubeFace::Left;
}
return EHyperTwistClassicCubeFace::Up;
};
CurrentScrambleMoves.Empty();
for (const FString& Move : MoveStrings)
{
if (Move.Len() == 0)
EHyperTwistClassicCubeFace Face = EHyperTwistClassicCubeFace::Up;
EHyperTwistRotationDirection Direction = EHyperTwistRotationDirection::Clockwise;
int32 QuarterTurns = 0;
if (!TryParseMoveString(Move, Face, Direction, QuarterTurns))
{
continue;
}
EHyperTwistClassicCubeFace Face = ParseFaceChar(Move[0]);
EHyperTwistRotationDirection Dir = EHyperTwistRotationDirection::Clockwise;
int32 Count = 1;
if (Move.Len() > 1)
CurrentScrambleMoves.Add(Move.TrimStartAndEnd());
for (int32 i = 0; i < QuarterTurns; ++i)
{
if (Move[1] == TEXT('\''))
{
Dir = EHyperTwistRotationDirection::CounterClockwise;
}
else if (Move[1] == TEXT('2'))
{
Count = 2;
}
}
for (int32 i = 0; i < Count; ++i)
{
RotateFace(Face, Dir);
QueueRotation(Face, Direction, false);
}
}
ProcessRotationQueue();
}
void AHyperTwistClassicCubeActor::ClearPieces()
@ -372,9 +411,70 @@ bool AHyperTwistClassicCubeActor::IsAnimating() const
return bIsAnimating;
}
bool AHyperTwistClassicCubeActor::IsSolved() const
{
if (bIsAnimating || !RotationQueue.IsEmpty())
{
return false;
}
for (const FTrackedPiece& Piece : TrackedPieces)
{
if (Piece.Mesh == nullptr)
{
continue;
}
if (!Piece.GridPos.Equals(GetExpectedGridPositionFromIdentity(Piece.IdentityFaces), KINDA_SMALL_NUMBER))
{
return false;
}
if (!IsSolvedOrientation(Piece.Orientation))
{
return false;
}
}
return true;
}
void AHyperTwistClassicCubeActor::RotateFace(EHyperTwistClassicCubeFace Face, EHyperTwistRotationDirection Direction)
{
RotationQueue.Add(TPair<EHyperTwistClassicCubeFace, EHyperTwistRotationDirection>(Face, Direction));
QueueRotation(Face, Direction, true);
}
int32 AHyperTwistClassicCubeActor::GetTotalCompletedMoveCount() const
{
return TotalCompletedMoveCount;
}
int32 AHyperTwistClassicCubeActor::GetGameplayCompletedMoveCount() const
{
return GameplayCompletedMoveCount;
}
int32 AHyperTwistClassicCubeActor::GetQueuedRotationCount() const
{
return RotationQueue.Num();
}
FString AHyperTwistClassicCubeActor::GetCurrentScrambleNotation() const
{
return FString::Join(CurrentScrambleMoves, TEXT(" "));
}
void AHyperTwistClassicCubeActor::QueueRotation(
EHyperTwistClassicCubeFace Face,
EHyperTwistRotationDirection Direction,
const bool bGameplayMove
)
{
FQueuedRotationRequest Request;
Request.Face = Face;
Request.Direction = Direction;
Request.bGameplayMove = bGameplayMove;
RotationQueue.Add(Request);
ProcessRotationQueue();
}
@ -385,13 +485,19 @@ void AHyperTwistClassicCubeActor::ProcessRotationQueue()
return;
}
auto Next = RotationQueue[0];
const FQueuedRotationRequest Next = RotationQueue[0];
RotationQueue.RemoveAt(0);
StartFaceRotation(Next.Key, Next.Value);
StartFaceRotation(Next.Face, Next.Direction);
ActiveRotation.bGameplayMove = Next.bGameplayMove;
}
void AHyperTwistClassicCubeActor::StartFaceRotation(EHyperTwistClassicCubeFace Face, EHyperTwistRotationDirection Direction)
{
if (!GetWorld())
{
return;
}
TArray<int32> Indices = GetFacePieceIndices(TrackedPieces, Face);
if (Indices.IsEmpty())
{
@ -450,7 +556,16 @@ void AHyperTwistClassicCubeActor::FinalizeRotation()
{
FTrackedPiece& Piece = TrackedPieces[Idx];
Piece.GridPos = RotateGridPosition(Piece.GridPos, ActiveRotation.Face, ActiveRotation.Direction);
Piece.Orientation = FaceRot * Piece.Orientation;
Piece.GridPos.X = FMath::RoundToFloat(Piece.GridPos.X);
Piece.GridPos.Y = FMath::RoundToFloat(Piece.GridPos.Y);
Piece.GridPos.Z = FMath::RoundToFloat(Piece.GridPos.Z);
Piece.Orientation = (FaceRot * Piece.Orientation).GetNormalized();
}
++TotalCompletedMoveCount;
if (ActiveRotation.bGameplayMove)
{
++GameplayCompletedMoveCount;
}
// Destroy pivot
@ -485,10 +600,10 @@ FVector AHyperTwistClassicCubeActor::RotateGridPosition(const FVector& Pos, EHyp
{
case EHyperTwistClassicCubeFace::Up: return FVector( Y, -X, Z);
case EHyperTwistClassicCubeFace::Down: return FVector(-Y, X, Z);
case EHyperTwistClassicCubeFace::Front: return FVector(-Z, Y, X);
case EHyperTwistClassicCubeFace::Front: return FVector( Z, Y, -X);
case EHyperTwistClassicCubeFace::Back: return FVector(-Z, Y, X);
case EHyperTwistClassicCubeFace::Right: return FVector( X, -Z, Y);
case EHyperTwistClassicCubeFace::Left: return FVector( X, -Z, Y);
case EHyperTwistClassicCubeFace::Left: return FVector( X, Z, -Y);
}
}
else
@ -497,10 +612,10 @@ FVector AHyperTwistClassicCubeActor::RotateGridPosition(const FVector& Pos, EHyp
{
case EHyperTwistClassicCubeFace::Up: return FVector(-Y, X, Z);
case EHyperTwistClassicCubeFace::Down: return FVector( Y, -X, Z);
case EHyperTwistClassicCubeFace::Front: return FVector( Z, Y, -X);
case EHyperTwistClassicCubeFace::Front: return FVector(-Z, Y, X);
case EHyperTwistClassicCubeFace::Back: return FVector( Z, Y, -X);
case EHyperTwistClassicCubeFace::Right: return FVector( X, Z, -Y);
case EHyperTwistClassicCubeFace::Left: return FVector( X, Z, -Y);
case EHyperTwistClassicCubeFace::Left: return FVector( X, -Z, Y);
}
}
return Pos;
@ -513,10 +628,10 @@ FQuat AHyperTwistClassicCubeActor::GetFaceRotationQuat(EHyperTwistClassicCubeFac
{
case EHyperTwistClassicCubeFace::Up: return FQuat(FRotator(0.0f, bCW ? -90.0f : 90.0f, 0.0f));
case EHyperTwistClassicCubeFace::Down: return FQuat(FRotator(0.0f, bCW ? 90.0f : -90.0f, 0.0f));
case EHyperTwistClassicCubeFace::Front: return FQuat(FRotator(bCW ? -90.0f : 90.0f, 0.0f, 0.0f));
case EHyperTwistClassicCubeFace::Front: return FQuat(FRotator(bCW ? 90.0f : -90.0f, 0.0f, 0.0f));
case EHyperTwistClassicCubeFace::Back: return FQuat(FRotator(bCW ? -90.0f : 90.0f, 0.0f, 0.0f));
case EHyperTwistClassicCubeFace::Right: return FQuat(FRotator(0.0f, 0.0f, bCW ? 90.0f : -90.0f));
case EHyperTwistClassicCubeFace::Left: return FQuat(FRotator(0.0f, 0.0f, bCW ? 90.0f : -90.0f));
case EHyperTwistClassicCubeFace::Left: return FQuat(FRotator(0.0f, 0.0f, bCW ? -90.0f : 90.0f));
}
return FQuat::Identity;
}
@ -562,3 +677,95 @@ FVector AHyperTwistClassicCubeActor::GetFaceCenter(EHyperTwistClassicCubeFace Fa
}
return FVector::ZeroVector;
}
bool AHyperTwistClassicCubeActor::TryParseMoveString(
const FString& MoveString,
EHyperTwistClassicCubeFace& OutFace,
EHyperTwistRotationDirection& OutDirection,
int32& OutQuarterTurns
)
{
const FString TrimmedMove = MoveString.TrimStartAndEnd();
if (TrimmedMove.IsEmpty() || TrimmedMove.Len() > 2)
{
return false;
}
switch (TrimmedMove[0])
{
case TEXT('U'): OutFace = EHyperTwistClassicCubeFace::Up; break;
case TEXT('D'): OutFace = EHyperTwistClassicCubeFace::Down; break;
case TEXT('F'): OutFace = EHyperTwistClassicCubeFace::Front; break;
case TEXT('B'): OutFace = EHyperTwistClassicCubeFace::Back; break;
case TEXT('R'): OutFace = EHyperTwistClassicCubeFace::Right; break;
case TEXT('L'): OutFace = EHyperTwistClassicCubeFace::Left; break;
default: return false;
}
OutDirection = EHyperTwistRotationDirection::Clockwise;
OutQuarterTurns = 1;
if (TrimmedMove.Len() == 2)
{
if (TrimmedMove[1] == TEXT('\''))
{
OutDirection = EHyperTwistRotationDirection::CounterClockwise;
}
else if (TrimmedMove[1] == TEXT('2'))
{
OutQuarterTurns = 2;
}
else
{
return false;
}
}
return true;
}
int32 AHyperTwistClassicCubeActor::GetFaceAxisIndex(EHyperTwistClassicCubeFace Face)
{
switch (Face)
{
case EHyperTwistClassicCubeFace::Up:
case EHyperTwistClassicCubeFace::Down:
return 2;
case EHyperTwistClassicCubeFace::Front:
case EHyperTwistClassicCubeFace::Back:
return 1;
case EHyperTwistClassicCubeFace::Left:
case EHyperTwistClassicCubeFace::Right:
return 0;
}
return INDEX_NONE;
}
FVector AHyperTwistClassicCubeActor::GetExpectedGridPositionFromIdentity(
const TArray<EHyperTwistClassicCubeFace>& IdentityFaces
)
{
FVector Result = FVector::ZeroVector;
for (const EHyperTwistClassicCubeFace Face : IdentityFaces)
{
switch (Face)
{
case EHyperTwistClassicCubeFace::Up: Result.Z = 1.0f; break;
case EHyperTwistClassicCubeFace::Down: Result.Z = -1.0f; break;
case EHyperTwistClassicCubeFace::Front: Result.Y = 1.0f; break;
case EHyperTwistClassicCubeFace::Back: Result.Y = -1.0f; break;
case EHyperTwistClassicCubeFace::Left: Result.X = -1.0f; break;
case EHyperTwistClassicCubeFace::Right: Result.X = 1.0f; break;
}
}
return Result;
}
bool AHyperTwistClassicCubeActor::IsSolvedOrientation(const FQuat& Orientation)
{
const FQuat Normalized = Orientation.GetNormalized();
const double IdentityDot = FMath::Abs(Normalized.W);
return IdentityDot >= HyperTwistClassicCubeActorInternal::RotationDotTolerance;
}

View file

@ -0,0 +1,352 @@
#include "HyperTwistSimulation/HyperTwistClassicCubeGameMode.h"
#include "Blueprint/UserWidget.h"
#include "EngineUtils.h"
#include "GameFramework/PlayerController.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeActor.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
namespace HyperTwistClassicCubeGameModeInternal
{
FString FormatMilliseconds(const int32 Milliseconds)
{
const int32 SafeMilliseconds = FMath::Max(Milliseconds, 0);
const int32 Minutes = SafeMilliseconds / 60000;
const int32 Seconds = (SafeMilliseconds / 1000) % 60;
const int32 MillisRemainder = SafeMilliseconds % 1000;
return FString::Printf(TEXT("%02d:%02d.%03d"), Minutes, Seconds, MillisRemainder);
}
EHyperTwistTrainingPenalty ResolveEffectivePenalty(const FHyperTwistTrainingLiveTimerState& TimerState)
{
if (TimerState.PendingPenalty != EHyperTwistTrainingPenalty::None)
{
return TimerState.PendingPenalty;
}
if (TimerState.ManualPenalty != EHyperTwistTrainingPenalty::None)
{
return TimerState.ManualPenalty;
}
return TimerState.InspectionPenalty;
}
FString BuildFinalTimeLabel(const FHyperTwistTrainingLiveTimerState& TimerState)
{
const EHyperTwistTrainingPenalty EffectivePenalty = ResolveEffectivePenalty(TimerState);
if (EffectivePenalty == EHyperTwistTrainingPenalty::DNF)
{
return TEXT("DNF");
}
int32 FinalTimeMs = FMath::Max(TimerState.SolveElapsedMs, 0);
if (EffectivePenalty == EHyperTwistTrainingPenalty::Plus2)
{
FinalTimeMs += 2000;
}
return FormatMilliseconds(FinalTimeMs);
}
}
AHyperTwistClassicCubeGameMode::AHyperTwistClassicCubeGameMode()
{
PrimaryActorTick.bCanEverTick = true;
}
void AHyperTwistClassicCubeGameMode::BeginPlay()
{
Super::BeginPlay();
if (APlayerController* PlayerController = GetWorld() != nullptr ? GetWorld()->GetFirstPlayerController() : nullptr)
{
PlayerController->bShowMouseCursor = true;
PlayerController->bEnableClickEvents = true;
PlayerController->bEnableMouseOverEvents = true;
}
ResolveOrSpawnCubeActor();
if (bAutoCreateHud)
{
ResolveOrCreateHudWidget();
}
StartFreshAttempt();
}
void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
ResolveOrSpawnCubeActor();
if (bAutoCreateHud)
{
ResolveOrCreateHudWidget();
}
UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
if (ActiveCubeActor != nullptr)
{
if (bAwaitingScrambleSettlement
&& !ActiveCubeActor->IsAnimating()
&& ActiveCubeActor->GetQueuedRotationCount() == 0)
{
bAwaitingScrambleSettlement = false;
if (TrainingSubsystem != nullptr)
{
TrainingSubsystem->StartActiveLiveTimer();
bInspectionStarted = TrainingSubsystem->HasActiveLiveTimer();
}
}
const int32 CurrentGameplayMoveCount = ActiveCubeActor->GetGameplayCompletedMoveCount();
if (CurrentGameplayMoveCount > ObservedGameplayMoveCount)
{
if (!bSolveStartedFromGameplayMove)
{
if (TrainingSubsystem != nullptr)
{
const FHyperTwistTrainingLiveTimerState LiveTimerState =
TrainingSubsystem->GetActiveLiveTimerState();
if (!TrainingSubsystem->HasActiveLiveTimer())
{
TrainingSubsystem->StartActiveLiveTimer();
}
else if (LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Inspection)
{
TrainingSubsystem->AdvanceActiveLiveTimerToSolvePhase();
}
else if (LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Idle)
{
TrainingSubsystem->StartActiveLiveTimer();
}
}
bSolveStartedFromGameplayMove = true;
}
ObservedGameplayMoveCount = CurrentGameplayMoveCount;
}
if (!bAttemptComplete
&& bSolveStartedFromGameplayMove
&& !ActiveCubeActor->IsAnimating()
&& ActiveCubeActor->GetQueuedRotationCount() == 0
&& ActiveCubeActor->IsSolved())
{
if (TrainingSubsystem != nullptr && TrainingSubsystem->HasActiveLiveTimer())
{
LastCompletedTimerState = TrainingSubsystem->GetActiveLiveTimerState();
LastAttemptStepResult =
TrainingSubsystem->SubmitActiveLiveTimedAttempt(EHyperTwistTrainingAttemptResult::Success);
}
bAttemptComplete = true;
}
}
RefreshHud();
}
void AHyperTwistClassicCubeGameMode::StartFreshAttempt()
{
ActiveCubeActor = ResolveOrSpawnCubeActor();
if (ActiveCubeActor == nullptr)
{
RefreshHud();
return;
}
if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
{
TrainingSubsystem->CancelActiveLiveTimer();
TrainingSubsystem->StartSampleClassicTrainingRun(
DefaultUserId,
BuildSessionId(),
EHyperTwistTrainingDeliveryMode::Timer
);
}
ActiveCubeActor->ResetCube();
const TArray<FString> ScrambleMoves = ActiveCubeActor->GenerateScramble(ScrambleLength);
ActiveCubeActor->ApplyScramble(ScrambleMoves);
LastCompletedTimerState = FHyperTwistTrainingLiveTimerState();
LastAttemptStepResult = FHyperTwistTrainingRunStepResult();
bAwaitingScrambleSettlement = true;
bInspectionStarted = false;
bSolveStartedFromGameplayMove = false;
bAttemptComplete = false;
ObservedGameplayMoveCount = 0;
RefreshHud();
}
UHyperTwistTrainingSubsystem* AHyperTwistClassicCubeGameMode::ResolveTrainingSubsystem() const
{
if (GetWorld() == nullptr)
{
return nullptr;
}
if (UGameInstance* GameInstance = GetWorld()->GetGameInstance())
{
return GameInstance->GetSubsystem<UHyperTwistTrainingSubsystem>();
}
return nullptr;
}
AHyperTwistClassicCubeActor* AHyperTwistClassicCubeGameMode::ResolveOrSpawnCubeActor()
{
if (ActiveCubeActor != nullptr)
{
return ActiveCubeActor;
}
if (GetWorld() == nullptr)
{
return nullptr;
}
for (TActorIterator<AHyperTwistClassicCubeActor> ActorIt(GetWorld()); ActorIt; ++ActorIt)
{
ActiveCubeActor = *ActorIt;
break;
}
if (ActiveCubeActor == nullptr && bAutoSpawnCubeActor)
{
ActiveCubeActor = GetWorld()->SpawnActor<AHyperTwistClassicCubeActor>(
AHyperTwistClassicCubeActor::StaticClass(),
CubeSpawnLocation,
CubeSpawnRotation
);
}
return ActiveCubeActor;
}
UHyperTwistClassicCubeHUDWidget* AHyperTwistClassicCubeGameMode::ResolveOrCreateHudWidget()
{
if (ActiveHudWidget != nullptr)
{
return ActiveHudWidget;
}
if (GetWorld() == nullptr)
{
return nullptr;
}
APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
if (PlayerController == nullptr)
{
return nullptr;
}
const TSubclassOf<UHyperTwistClassicCubeHUDWidget> ResolvedClass =
*HudWidgetClass != nullptr
? HudWidgetClass
: TSubclassOf<UHyperTwistClassicCubeHUDWidget>(UHyperTwistClassicCubeHUDWidget::StaticClass());
ActiveHudWidget = CreateWidget<UHyperTwistClassicCubeHUDWidget>(PlayerController, ResolvedClass);
if (ActiveHudWidget != nullptr)
{
ActiveHudWidget->AddToViewport(HudZOrder);
}
return ActiveHudWidget;
}
void AHyperTwistClassicCubeGameMode::RefreshHud()
{
if (ResolveOrCreateHudWidget() == nullptr)
{
return;
}
FString StatusLine = TEXT("status: waiting for classic cube actor");
FString ScrambleLine = TEXT("scramble: pending");
FString TimerLine = TEXT("timer: idle");
FString InspectionLine = TEXT("inspection: pending");
FString ResultLine = TEXT("result: waiting for first attempt");
UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
FHyperTwistTrainingLiveTimerState LiveTimerState;
const bool bHasLiveTimer = TrainingSubsystem != nullptr && TrainingSubsystem->HasActiveLiveTimer();
if (bHasLiveTimer)
{
LiveTimerState = TrainingSubsystem->GetActiveLiveTimerState();
}
if (ActiveCubeActor != nullptr)
{
const FString ScrambleNotation = ActiveCubeActor->GetCurrentScrambleNotation();
ScrambleLine = ScrambleNotation.IsEmpty()
? TEXT("scramble: pending")
: FString::Printf(TEXT("scramble: %s"), *ScrambleNotation);
if (bAwaitingScrambleSettlement)
{
StatusLine = TEXT("status: scrambling cube");
InspectionLine = TEXT("inspection: arming after scramble playback");
}
else if (bAttemptComplete)
{
StatusLine = TEXT("status: solved");
InspectionLine = TEXT("inspection: closed on first gameplay turn");
TimerLine = FString::Printf(
TEXT("timer: %s"),
*HyperTwistClassicCubeGameModeInternal::BuildFinalTimeLabel(LastCompletedTimerState)
);
ResultLine = LastAttemptStepResult.IsStructurallyValid()
? TEXT("result: timed solve submitted")
: TEXT("result: solved outside active timer lane");
}
else if (bHasLiveTimer && LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Inspection)
{
StatusLine = TEXT("status: inspection running");
const int32 RemainingInspectionMs =
FMath::Max(LiveTimerState.InspectionLimitMs - LiveTimerState.InspectionElapsedMs, 0);
InspectionLine = FString::Printf(
TEXT("inspection: %s remaining"),
*HyperTwistClassicCubeGameModeInternal::FormatMilliseconds(RemainingInspectionMs)
);
}
else if (bHasLiveTimer
&& (LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Solving
|| LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Paused))
{
StatusLine = LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Paused
? TEXT("status: solve paused")
: TEXT("status: solving");
InspectionLine = TEXT("inspection: closed on first gameplay turn");
TimerLine = FString::Printf(
TEXT("timer: %s"),
*HyperTwistClassicCubeGameModeInternal::FormatMilliseconds(
FMath::Max(LiveTimerState.DisplayedElapsedMs, LiveTimerState.SolveElapsedMs)
)
);
}
else if (bInspectionStarted)
{
StatusLine = TEXT("status: ready for first gameplay turn");
InspectionLine = TEXT("inspection: ready");
}
else
{
StatusLine = TEXT("status: preparing attempt");
}
}
ActiveHudWidget->SetHudLines(StatusLine, ScrambleLine, TimerLine, InspectionLine, ResultLine);
}
FString AHyperTwistClassicCubeGameMode::BuildSessionId() const
{
return FString::Printf(
TEXT("%s_%s"),
*SessionIdPrefix,
*FGuid::NewGuid().ToString(EGuidFormats::Digits)
);
}

View file

@ -0,0 +1,92 @@
#include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h"
#include "Components/TextBlock.h"
#include "Components/VerticalBox.h"
#include "Components/VerticalBoxSlot.h"
#include "Blueprint/WidgetTree.h"
void UHyperTwistClassicCubeHUDWidget::NativeConstruct()
{
Super::NativeConstruct();
EnsureWidgetTreeBuilt();
}
void UHyperTwistClassicCubeHUDWidget::SetHudLines(
const FString& InStatusLine,
const FString& InScrambleLine,
const FString& InTimerLine,
const FString& InInspectionLine,
const FString& InResultLine
)
{
EnsureWidgetTreeBuilt();
if (StatusTextBlock != nullptr)
{
StatusTextBlock->SetText(FText::FromString(InStatusLine));
}
if (ScrambleTextBlock != nullptr)
{
ScrambleTextBlock->SetText(FText::FromString(InScrambleLine));
}
if (TimerTextBlock != nullptr)
{
TimerTextBlock->SetText(FText::FromString(InTimerLine));
}
if (InspectionTextBlock != nullptr)
{
InspectionTextBlock->SetText(FText::FromString(InInspectionLine));
}
if (ResultTextBlock != nullptr)
{
ResultTextBlock->SetText(FText::FromString(InResultLine));
}
}
void UHyperTwistClassicCubeHUDWidget::EnsureWidgetTreeBuilt()
{
if (WidgetTree == nullptr || WidgetTree->RootWidget != nullptr)
{
return;
}
UVerticalBox* RootLayout =
WidgetTree->ConstructWidget<UVerticalBox>(UVerticalBox::StaticClass(), TEXT("ClassicCubeHudRoot"));
WidgetTree->RootWidget = RootLayout;
StatusTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudStatus"));
ScrambleTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudScramble"));
TimerTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudTimer"));
InspectionTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudInspection"));
ResultTextBlock = AddLine(RootLayout, TEXT("ClassicCubeHudResult"));
SetHudLines(
TEXT("status: preparing classic cube lane"),
TEXT("scramble: pending"),
TEXT("timer: idle"),
TEXT("inspection: pending"),
TEXT("result: waiting for first attempt")
);
}
UTextBlock* UHyperTwistClassicCubeHUDWidget::AddLine(UVerticalBox* Parent, const TCHAR* WidgetName)
{
if (WidgetTree == nullptr || Parent == nullptr)
{
return nullptr;
}
UTextBlock* TextBlock =
WidgetTree->ConstructWidget<UTextBlock>(UTextBlock::StaticClass(), WidgetName);
if (TextBlock == nullptr)
{
return nullptr;
}
if (UVerticalBoxSlot* VerticalBoxSlot = Parent->AddChildToVerticalBox(TextBlock))
{
VerticalBoxSlot->SetPadding(FMargin(0.0f, 4.0f, 0.0f, 4.0f));
}
return TextBlock;
}

View file

@ -0,0 +1,33 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/Object.h"
#include "HyperTwistBrowserBridgeObject.generated.h"
UCLASS(BlueprintType)
class UNREALHYPERTWIST_API UHyperTwistBrowserBridgeObject : public UObject
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Browser")
void NotifyEnvelope(const FString& EnvelopeJson);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Browser")
void NotifyState(const FString& StateJson);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Browser")
void NotifyRuntimeReady(const FString& RuntimeReadyJson);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Browser")
void ResetReceivedMessages();
UPROPERTY(BlueprintReadOnly, Category = "HyperTwist|Browser")
FString LastEnvelopeJson;
UPROPERTY(BlueprintReadOnly, Category = "HyperTwist|Browser")
FString LastStateJson;
UPROPERTY(BlueprintReadOnly, Category = "HyperTwist|Browser")
FString LastRuntimeReadyJson;
};

View file

@ -0,0 +1,63 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/Widget.h"
#include "HyperTwistBrowserWidget.generated.h"
class SWebBrowser;
class UHyperTwistBrowserBridgeObject;
UCLASS(BlueprintType)
class UNREALHYPERTWIST_API UHyperTwistBrowserWidget : public UWidget
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Browser")
bool bUseBundledBrowserShell = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Browser")
bool bShowBrowserControls = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Browser")
FString InitialUrl;
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Browser")
void LoadBundledBrowserShell();
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Browser")
void LoadBrowserUrl(const FString& NewUrl);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Browser")
void DispatchCommandJson(const FString& CommandJson);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Browser")
void PushShellStateJson(const FString& StateJson);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Browser")
FString GetLastEnvelopeJson() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|Browser")
FString GetLastRuntimeReadyJson() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|Browser")
FString GetLastStateJson() const;
UFUNCTION(BlueprintPure, Category = "HyperTwist|Browser")
static FString ResolveBundledBrowserShellUrl();
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
virtual void ReleaseSlateResources(bool bReleaseChildren) override;
virtual void SynchronizeProperties() override;
private:
static FString EscapeForJavaScriptSingleQuotedString(const FString& Input);
FString ResolveInitialUrl() const;
void EnsureBridgeBound();
UPROPERTY(Transient)
TObjectPtr<UHyperTwistBrowserBridgeObject> BrowserBridgeObject = nullptr;
TSharedPtr<SWebBrowser> BrowserWidget;
};

View file

@ -0,0 +1,87 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "HyperTwistRecognition/HyperTwistRecognitionTypes.h"
#include "HyperTwistTraining/HyperTwistTrainingTypes.h"
#include "HyperTwistSpeechLibrary.generated.h"
class UAudioComponent;
UCLASS()
class UNREALHYPERTWIST_API UHyperTwistSpeechLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech", meta = (WorldContext = "WorldContextObject"))
static bool StartDictationSession(
UObject* WorldContextObject,
FString& OutError,
bool bDuckAudio = true,
float DuckVolumeMultiplier = 0.15f
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech", meta = (WorldContext = "WorldContextObject"))
static FHyperTwistSpeechTranscriptResult SubmitDictationUtterance(
UObject* WorldContextObject,
const FHyperTwistSpeechUtteranceEnvelope& Utterance
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech", meta = (WorldContext = "WorldContextObject"))
static bool EndDictationSession(
UObject* WorldContextObject,
FString& OutError,
bool bRestoreAudio = true
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Speech", meta = (WorldContext = "WorldContextObject"))
static FHyperTwistTrainingCompanionSpeechSessionState GetActiveDictationSessionState(
UObject* WorldContextObject
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech|Audio")
static void RegisterManagedAudioComponent(
UAudioComponent* AudioComponent,
bool bTreatAsSpeechAudio = false
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech|Audio")
static void UnregisterManagedAudioComponent(UAudioComponent* AudioComponent);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech|Audio")
static int32 DuckManagedAudioComponents(float DuckVolumeMultiplier = 0.15f);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech|Audio")
static int32 RestoreManagedAudioComponents();
UFUNCTION(BlueprintPure, Category = "HyperTwist|Speech|Audio")
static int32 GetManagedAudioComponentCount();
UFUNCTION(BlueprintPure, Category = "HyperTwist|Speech|Voice")
static TArray<FHyperTwistVoiceProfileSummary> ListVoiceProfiles(
UObject* ContextObject,
bool bUseMockVoiceClient = false
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech|Voice")
static FHyperTwistNarrationSynthesisResult SynthesizeNarration(
UObject* ContextObject,
const FHyperTwistNarrationSynthesisRequest& Request,
bool bUseMockVoiceClient = false
);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Speech|Voice")
static TArray<uint8> Synthesize(
UObject* ContextObject,
const FString& Text,
const FString& VoiceId,
bool bUseMockVoiceClient = false
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Speech|Voice")
static FHyperTwistVoiceServiceHealth GetVoiceServiceHealth(
UObject* ContextObject,
bool bUseMockVoiceClient = false
);
};

View file

@ -54,6 +54,10 @@ public:
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube")
void ResetCube();
/** True when every tracked piece is back in its canonical solved position and orientation. */
UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube")
bool IsSolved() const;
/**
* Process a click ray. If it hits a visible cube face, rotates that face.
* @param RayOrigin World-space ray origin (e.g. camera location)
@ -72,6 +76,22 @@ public:
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Cube")
void ApplyScramble(const TArray<FString>& MoveStrings);
/** Total completed quarter turns, including scripted scramble turns. */
UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube")
int32 GetTotalCompletedMoveCount() const;
/** Total completed gameplay quarter turns triggered outside scramble playback. */
UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube")
int32 GetGameplayCompletedMoveCount() const;
/** Number of queued rotations still waiting to start. */
UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube")
int32 GetQueuedRotationCount() const;
/** The current active scramble as a single WCA-style notation string. */
UFUNCTION(BlueprintPure, Category = "HyperTwist|Cube")
FString GetCurrentScrambleNotation() const;
virtual void OnConstruction(const FTransform& Transform) override;
protected:
@ -97,6 +117,14 @@ private:
float Duration = 0.15f;
EHyperTwistClassicCubeFace Face = EHyperTwistClassicCubeFace::Up;
EHyperTwistRotationDirection Direction = EHyperTwistRotationDirection::Clockwise;
bool bGameplayMove = false;
};
struct FQueuedRotationRequest
{
EHyperTwistClassicCubeFace Face = EHyperTwistClassicCubeFace::Up;
EHyperTwistRotationDirection Direction = EHyperTwistRotationDirection::Clockwise;
bool bGameplayMove = false;
};
void GenerateCube();
@ -106,6 +134,11 @@ private:
const FVector& Center, float HalfSize, EHyperTwistClassicCubeFace Face,
UMaterialInterface* Material);
void QueueRotation(
EHyperTwistClassicCubeFace Face,
EHyperTwistRotationDirection Direction,
bool bGameplayMove
);
void StartFaceRotation(EHyperTwistClassicCubeFace Face, EHyperTwistRotationDirection Direction);
void FinalizeRotation();
void ProcessRotationQueue();
@ -114,12 +147,24 @@ private:
static FQuat GetFaceRotationQuat(EHyperTwistClassicCubeFace Face, EHyperTwistRotationDirection Direction);
static TArray<int32> GetFacePieceIndices(const TArray<FTrackedPiece>& Pieces, EHyperTwistClassicCubeFace Face);
static FVector GetFaceCenter(EHyperTwistClassicCubeFace Face);
static bool TryParseMoveString(
const FString& MoveString,
EHyperTwistClassicCubeFace& OutFace,
EHyperTwistRotationDirection& OutDirection,
int32& OutQuarterTurns
);
static int32 GetFaceAxisIndex(EHyperTwistClassicCubeFace Face);
static FVector GetExpectedGridPositionFromIdentity(const TArray<EHyperTwistClassicCubeFace>& IdentityFaces);
static bool IsSolvedOrientation(const FQuat& Orientation);
int32 GridToIndex(const FVector& GridPos) const;
FVector IndexToGrid(int32 Index) const;
TArray<FTrackedPiece> TrackedPieces;
TArray<TPair<EHyperTwistClassicCubeFace, EHyperTwistRotationDirection>> RotationQueue;
TArray<FQueuedRotationRequest> RotationQueue;
FActiveRotation ActiveRotation;
TArray<FString> CurrentScrambleMoves;
int32 TotalCompletedMoveCount = 0;
int32 GameplayCompletedMoveCount = 0;
bool bIsAnimating = false;
};

View file

@ -0,0 +1,74 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "HyperTwistTraining/HyperTwistTrainingTypes.h"
#include "HyperTwistClassicCubeGameMode.generated.h"
class AHyperTwistClassicCubeActor;
class UHyperTwistClassicCubeHUDWidget;
class UHyperTwistTrainingSubsystem;
UCLASS(BlueprintType, Blueprintable)
class UNREALHYPERTWIST_API AHyperTwistClassicCubeGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
AHyperTwistClassicCubeGameMode();
virtual void BeginPlay() override;
virtual void Tick(float DeltaSeconds) override;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|HUD")
bool bAutoCreateHud = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|HUD")
int32 HudZOrder = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube|HUD")
TSubclassOf<UHyperTwistClassicCubeHUDWidget> HudWidgetClass;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube")
bool bAutoSpawnCubeActor = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube")
FVector CubeSpawnLocation = FVector::ZeroVector;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube")
FRotator CubeSpawnRotation = FRotator::ZeroRotator;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube")
int32 ScrambleLength = 20;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube")
FString DefaultUserId = TEXT("local-user");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|ClassicCube")
FString SessionIdPrefix = TEXT("classic_cube");
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube")
TObjectPtr<AHyperTwistClassicCubeActor> ActiveCubeActor = nullptr;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|ClassicCube|HUD")
TObjectPtr<UHyperTwistClassicCubeHUDWidget> ActiveHudWidget = nullptr;
UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube")
void StartFreshAttempt();
protected:
UHyperTwistTrainingSubsystem* ResolveTrainingSubsystem() const;
AHyperTwistClassicCubeActor* ResolveOrSpawnCubeActor();
UHyperTwistClassicCubeHUDWidget* ResolveOrCreateHudWidget();
void RefreshHud();
FString BuildSessionId() const;
private:
FHyperTwistTrainingLiveTimerState LastCompletedTimerState;
FHyperTwistTrainingRunStepResult LastAttemptStepResult;
bool bAwaitingScrambleSettlement = false;
bool bInspectionStarted = false;
bool bSolveStartedFromGameplayMove = false;
bool bAttemptComplete = false;
int32 ObservedGameplayMoveCount = 0;
};

View file

@ -0,0 +1,45 @@
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "HyperTwistClassicCubeHUDWidget.generated.h"
class UTextBlock;
class UVerticalBox;
UCLASS(BlueprintType, Blueprintable)
class UNREALHYPERTWIST_API UHyperTwistClassicCubeHUDWidget : public UUserWidget
{
GENERATED_BODY()
public:
virtual void NativeConstruct() override;
UFUNCTION(BlueprintCallable, Category = "HyperTwist|ClassicCube|HUD")
void SetHudLines(
const FString& InStatusLine,
const FString& InScrambleLine,
const FString& InTimerLine,
const FString& InInspectionLine,
const FString& InResultLine
);
private:
void EnsureWidgetTreeBuilt();
UTextBlock* AddLine(UVerticalBox* Parent, const TCHAR* WidgetName);
UPROPERTY(Transient)
TObjectPtr<UTextBlock> StatusTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ScrambleTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> TimerTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> InspectionTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ResultTextBlock = nullptr;
};

View file

@ -0,0 +1,79 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#include "Misc/AutomationTest.h"
#include "HyperTwistBrowser/HyperTwistBrowserBridgeObject.h"
#include "HyperTwistBrowser/HyperTwistBrowserWidget.h"
#if WITH_AUTOMATION_TESTS
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistBrowserBridgeObjectNotificationTest,
"HyperTwist.Browser.Bridge.NotificationCapture",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistBrowserBridgeObjectNotificationTest::RunTest(const FString& Parameters)
{
UHyperTwistBrowserBridgeObject* BridgeObject = NewObject<UHyperTwistBrowserBridgeObject>();
TestNotNull(TEXT("The browser bridge object must be constructible."), BridgeObject);
if (BridgeObject == nullptr)
{
return false;
}
BridgeObject->NotifyEnvelope(TEXT("{\"type\":\"hypertwist-state\"}"));
BridgeObject->NotifyState(TEXT("{\"phase\":\"1H\"}"));
BridgeObject->NotifyRuntimeReady(TEXT("{\"status\":\"ready\"}"));
TestEqual(
TEXT("The browser bridge object must retain the last envelope payload."),
BridgeObject->LastEnvelopeJson,
TEXT("{\"type\":\"hypertwist-state\"}")
);
TestEqual(
TEXT("The browser bridge object must retain the last shell-state payload."),
BridgeObject->LastStateJson,
TEXT("{\"phase\":\"1H\"}")
);
TestEqual(
TEXT("The browser bridge object must retain the runtime-ready payload."),
BridgeObject->LastRuntimeReadyJson,
TEXT("{\"status\":\"ready\"}")
);
BridgeObject->ResetReceivedMessages();
TestTrue(
TEXT("Resetting the bridge object must clear all retained payloads."),
BridgeObject->LastEnvelopeJson.IsEmpty()
&& BridgeObject->LastStateJson.IsEmpty()
&& BridgeObject->LastRuntimeReadyJson.IsEmpty()
);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistBrowserWidgetBundledShellUrlTest,
"HyperTwist.Browser.Widget.BundledShellUrl",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistBrowserWidgetBundledShellUrlTest::RunTest(const FString& Parameters)
{
const FString BundledShellUrl = UHyperTwistBrowserWidget::ResolveBundledBrowserShellUrl();
TestFalse(TEXT("The bundled browser shell URL must not be empty."), BundledShellUrl.IsEmpty());
TestTrue(
TEXT("The bundled browser shell URL must resolve to a file URL."),
BundledShellUrl.StartsWith(TEXT("file:///"))
);
TestTrue(
TEXT("The bundled browser shell URL must point at the Browser shell."),
BundledShellUrl.Contains(TEXT("Content/Browser"))
);
return true;
}
#endif

View file

@ -0,0 +1,83 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#include "Misc/AutomationTest.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeActor.h"
namespace HyperTwistClassicCubeActorTestInternal
{
int32 GetAxisIndex(const TCHAR MoveFace)
{
switch (MoveFace)
{
case TEXT('U'):
case TEXT('D'):
return 2;
case TEXT('F'):
case TEXT('B'):
return 1;
case TEXT('L'):
case TEXT('R'):
return 0;
default:
return INDEX_NONE;
}
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistClassicCubeScrambleQualityTest,
"HyperTwist.Simulation.ClassicCube.ScrambleQuality",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistClassicCubeScrambleQualityTest::RunTest(const FString& Parameters)
{
AHyperTwistClassicCubeActor* CubeActor = NewObject<AHyperTwistClassicCubeActor>(GetTransientPackage());
TestNotNull(TEXT("The classic cube actor must be constructible for scramble checks."), CubeActor);
if (CubeActor == nullptr)
{
return false;
}
const TArray<FString> Scramble = CubeActor->GenerateScramble(32);
TestEqual(TEXT("The scramble generator must respect the requested move count."), Scramble.Num(), 32);
for (int32 MoveIndex = 0; MoveIndex < Scramble.Num(); ++MoveIndex)
{
const FString& Move = Scramble[MoveIndex];
TestTrue(TEXT("Each scramble move must be non-empty."), !Move.IsEmpty());
TestTrue(TEXT("Each scramble move must be one or two characters long."), Move.Len() >= 1 && Move.Len() <= 2);
const TCHAR Face = Move[0];
const int32 AxisIndex = HyperTwistClassicCubeActorTestInternal::GetAxisIndex(Face);
TestTrue(TEXT("Each scramble move must start with a legal face token."), AxisIndex != INDEX_NONE);
if (Move.Len() == 2)
{
TestTrue(
TEXT("Each scramble suffix must be either a prime marker or a double-turn marker."),
Move[1] == TEXT('\'') || Move[1] == TEXT('2')
);
}
if (MoveIndex == 0)
{
continue;
}
const FString& PreviousMove = Scramble[MoveIndex - 1];
TestNotEqual(
TEXT("The scramble generator must avoid repeating the same face consecutively."),
Face,
PreviousMove[0]
);
TestNotEqual(
TEXT("The scramble generator must avoid immediate same-axis repetitions."),
AxisIndex,
HyperTwistClassicCubeActorTestInternal::GetAxisIndex(PreviousMove[0])
);
}
return true;
}

View file

@ -0,0 +1,33 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#include "Misc/AutomationTest.h"
#include "HyperTwistIPC/HyperTwistSolverOracleLibrary.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistSolverOracleVerificationSolvedStateTest,
"HyperTwist.Solver.Oracle.VerifySolvedState",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistSolverOracleVerificationSolvedStateTest::RunTest(const FString& Parameters)
{
const FString SolvedState = TEXT("UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB");
TestTrue(
TEXT("An empty move sequence must verify against the solved classic cube state."),
UHyperTwistSolverOracleLibrary::VerifySolutionWithBrownanOracle(SolvedState, {})
);
TestFalse(
TEXT("A non-empty move sequence must not verify against the solved classic cube state."),
UHyperTwistSolverOracleLibrary::VerifySolutionWithBrownanOracle(SolvedState, {TEXT("R")})
);
TestFalse(
TEXT("Invalid move notation must fail solver-oracle verification."),
UHyperTwistSolverOracleLibrary::VerifySolutionWithBrownanOracle(SolvedState, {TEXT("?")})
);
return true;
}

View file

@ -0,0 +1,167 @@
// Copyright HyperTwist, Inc. All Rights Reserved.
#include "Misc/AutomationTest.h"
#include "Components/AudioComponent.h"
#include "Engine/GameInstance.h"
#include "HyperTwistRecognition/HyperTwistSpeechLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
#include "UObject/UnrealType.h"
#if WITH_AUTOMATION_TESTS
namespace HyperTwistSpeechLibraryTestInternal
{
FHyperTwistTrainingDeck MakeSpeechDeck()
{
FHyperTwistTrainingDeck Deck;
Deck.DeckId = TEXT("speech-library/coach-deck");
Deck.Title = TEXT("Speech Library Coach Deck");
Deck.DeliveryModes = { EHyperTwistTrainingDeliveryMode::CoachReviewed };
FHyperTwistTrainingCase TrainingCase;
TrainingCase.CaseId = TEXT("speech-library-case");
TrainingCase.PuzzleId = TEXT("cube/3x3x3");
TrainingCase.PromptKind = EHyperTwistTrainingPromptKind::Sequence;
TrainingCase.PromptLabel = TEXT("Repeat case");
TrainingCase.AllowedDeliveryModes = { EHyperTwistTrainingDeliveryMode::CoachReviewed };
Deck.Cases = { TrainingCase };
return Deck;
}
void ForceMockSpeechClient(UHyperTwistTrainingSubsystem* TrainingSubsystem)
{
if (TrainingSubsystem == nullptr)
{
return;
}
if (FStrProperty* SpeechClientKindProperty = FindFProperty<FStrProperty>(
UHyperTwistTrainingSubsystem::StaticClass(),
TEXT("CompanionSpeechClientKind")))
{
SpeechClientKindProperty->SetPropertyValue_InContainer(TrainingSubsystem, TEXT("mock"));
}
}
UHyperTwistTrainingSubsystem* MakeSpeechSubsystem(const FString& SessionId)
{
UGameInstance* GameInstance = NewObject<UGameInstance>();
if (GameInstance == nullptr)
{
return nullptr;
}
UHyperTwistTrainingSubsystem* TrainingSubsystem = NewObject<UHyperTwistTrainingSubsystem>(GameInstance);
if (TrainingSubsystem == nullptr)
{
return nullptr;
}
ForceMockSpeechClient(TrainingSubsystem);
const FHyperTwistTrainingRunState RunState = TrainingSubsystem->StartTrainingRunFromDeck(
MakeSpeechDeck(),
TEXT("speech-library-user"),
SessionId,
EHyperTwistTrainingDeliveryMode::CoachReviewed
);
return RunState.IsStructurallyValid() ? TrainingSubsystem : nullptr;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistSpeechLibraryDictationAudioDuckingTest,
"HyperTwist.Permissive.Speech.Library.DictationAudioDucking",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistSpeechLibraryDictationAudioDuckingTest::RunTest(const FString& Parameters)
{
UHyperTwistTrainingSubsystem* TrainingSubsystem =
HyperTwistSpeechLibraryTestInternal::MakeSpeechSubsystem(TEXT("speech-library-ducking-session"));
TestNotNull(TEXT("The speech training subsystem must exist."), TrainingSubsystem);
if (TrainingSubsystem == nullptr)
{
return false;
}
UAudioComponent* MusicComponent = NewObject<UAudioComponent>();
UAudioComponent* SpeechComponent = NewObject<UAudioComponent>();
TestNotNull(TEXT("The managed music component must exist."), MusicComponent);
TestNotNull(TEXT("The managed speech component must exist."), SpeechComponent);
if (MusicComponent == nullptr || SpeechComponent == nullptr)
{
return false;
}
MusicComponent->SetVolumeMultiplier(0.8f);
SpeechComponent->SetVolumeMultiplier(1.0f);
UHyperTwistSpeechLibrary::RegisterManagedAudioComponent(MusicComponent, false);
UHyperTwistSpeechLibrary::RegisterManagedAudioComponent(SpeechComponent, true);
FString OpenError;
TestTrue(
TEXT("The speech library must open a dictation session."),
UHyperTwistSpeechLibrary::StartDictationSession(TrainingSubsystem, OpenError, true, 0.15f)
);
TestTrue(TEXT("Opening the dictation session must not report an error."), OpenError.IsEmpty());
TestEqual(
TEXT("Managed non-speech audio must be ducked."),
MusicComponent->VolumeMultiplier,
0.15f
);
TestEqual(
TEXT("Managed speech audio must retain its original volume."),
SpeechComponent->VolumeMultiplier,
1.0f
);
FString CloseError;
TestTrue(
TEXT("The speech library must close the dictation session."),
UHyperTwistSpeechLibrary::EndDictationSession(TrainingSubsystem, CloseError, true)
);
TestTrue(TEXT("Closing the dictation session must not report an error."), CloseError.IsEmpty());
TestEqual(
TEXT("Managed non-speech audio must restore after dictation ends."),
MusicComponent->VolumeMultiplier,
0.8f
);
TestEqual(
TEXT("Managed speech audio must remain unchanged after restore."),
SpeechComponent->VolumeMultiplier,
1.0f
);
UHyperTwistSpeechLibrary::UnregisterManagedAudioComponent(MusicComponent);
UHyperTwistSpeechLibrary::UnregisterManagedAudioComponent(SpeechComponent);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistSpeechLibraryPcmExtractionTest,
"HyperTwist.Permissive.Speech.Library.PcmExtraction",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistSpeechLibraryPcmExtractionTest::RunTest(const FString& Parameters)
{
const TArray<uint8> PcmPayload = UHyperTwistSpeechLibrary::Synthesize(
nullptr,
TEXT("hello from HyperTwist"),
TEXT("en_US-lessac-medium"),
true
);
TestTrue(TEXT("The speech library must expose a non-empty PCM payload."), PcmPayload.Num() > 0);
if (PcmPayload.Num() >= 4)
{
const bool bLooksLikeWavHeader =
PcmPayload[0] == 'R' && PcmPayload[1] == 'I' && PcmPayload[2] == 'F' && PcmPayload[3] == 'F';
TestFalse(TEXT("The speech library PCM helper must strip the WAV container header."), bLooksLikeWavHeader);
}
return true;
}
#endif

View file

@ -11,7 +11,7 @@ public class UnrealHyperTwist : ModuleRules
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "Json", "JsonUtilities", "UMG", "Voice", "ProceduralMeshComponent" });
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "HTTP" });
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "HTTP", "WebBrowser" });
// External donor repo include paths
string ExternalPath = Path.Combine(ModuleDirectory, "../../../.external/");

View file

@ -25,6 +25,10 @@
{
"Name": "ProceduralMeshComponent",
"Enabled": true
},
{
"Name": "WebBrowserWidget",
"Enabled": true
}
]
}
}

View file

@ -1,8 +1,8 @@
# HyperTwist — Comprehensive Reconstruction Roadmap
## Status: Draft — Post-Audit Reality Alignment
## Status: Live — Phase 1 and Phase 2 Reality-Aligned
## Date: 2026-06-10
## Remaining Repo Inventory (Authoritative Reconstruction)
## Reconstruction Starting Inventory (Authoritative Snapshot)
Source: `HYPERTWIST_REPO_STATE_BOARD_2026-05-11.md` and `HYPERTWIST_REPO_EVALUATION_RESET_AND_IMPLEMENTATION_SCHEDULE_2026-05-11.md`
@ -33,7 +33,11 @@ Source: `HYPERTWIST_REPO_STATE_BOARD_2026-05-11.md` and `HYPERTWIST_REPO_EVALUAT
- `google/model-viewer/packages/*` (sub-packages)
- And others in the 0R-B backlog
**None of these 29 are currently wired into the Unreal build.** They exist only as attribution bundles and JSONL reference pages.
This section records the starting reconstruction snapshot only.
The live Phase 1 source of truth is the checklist below: the accepted donor set
is now wired across native C++, IPC/sidecar, and `Content/Browser/` runtime
surfaces rather than remaining attribution-only.
---
@ -41,11 +45,12 @@ Source: `HYPERTWIST_REPO_STATE_BOARD_2026-05-11.md` and `HYPERTWIST_REPO_EVALUAT
## Executive Summary
This roadmap replaces the self-referential validation-ladder sequence with a
functional, repo-first reconstruction plan. The audit confirms that HyperTwist
is ~80% documentation scaffolding, ~15% data-structure code, and ~5% actual
game logic. No 3D puzzle renderer exists. No playable level exists. No donor
repos are wired into the build.
This roadmap replaced the self-referential validation-ladder sequence with a
functional, repo-first reconstruction plan. The original audit snapshot
correctly identified the missing centerpieces: no classic-cube renderer, no
timer/HUD loop, and no real donor wiring. The current Phase 1 and Phase 2 lane
has now closed those gaps with a shipped classic-cube actor, timer/HUD game
mode, speech/solver surfaces, and a buildable embedded browser runtime.
**The new rule:** Repos are wired before features are claimed. Features are
playable before they are documented. Validation tests verify behavior, not
@ -84,58 +89,58 @@ for the full 29-repo queue and per-repo wiring posture.
- [x] Windows build validated: 5 actions, 38.87s, exit code 0
- [ ] Runtime test: feed a known scrambled state, assert solution length < 25 (deferred to Phase 4C)
### 1B — Speech-to-Text: `freestyle-voice/freestyle` (MIT)
- [ ] Decision gate: `freestyle` is a hotkey-driven dictation client, not an embeddable C++ library. It calls cloud APIs (OpenAI, Groq, Anthropic, Google, Deepgram, ElevenLabs) via HTTP.
- [ ] Integration path A (recommended): Run `freestyle` as external sidecar process. UE communicates via local HTTP loopback or IPC.
- [ ] Integration path B (fallback): Embed `freestyle` source (Tauri/Rust or Electron) as a UE plugin — evaluate complexity vs. value.
- [ ] Expose `UHyperTwistSpeechLibrary::StartDictationSession()` / `EndDictationSession()`
- [ ] On hotkey hold: capture microphone via UE `VoiceModule`, stream PCM to `freestyle` loopback endpoint
- [ ] On release: receive transcript JSON, parse cleaned text, execute move notation or coach command
- [ ] **Audio Ducking**: when `bDictationActive = true`, duck all non-speech `UAudioComponent` volumes to 15% via `FAudioDevice` mixer gain or per-component `VolumeMultiplier`
- [ ] Restore full volume 200ms after `EndDictationSession()`
- [ ] Automation test: simulate hotkey hold, assert `VolumeMultiplier < 0.2f` on background music component; assert restoration after release
### 1B — Speech-to-Text: `freestyle-voice/freestyle` (MIT) ✅ COMPLETE
- [x] Decision gate: `freestyle` remains a hotkey/cloud-client lane, so HyperTwist wires it as an external speech-session surface rather than pretending it is a native Unreal library.
- [x] Integration path A (recommended): Run `freestyle` as external sidecar process. UE communicates via local HTTP loopback or IPC.
- [x] Integration path B (fallback posture recorded): do not embed `freestyle` source into the Unreal binary unless a later packet explicitly widens into plugin-hosting work.
- [x] Expose `UHyperTwistSpeechLibrary::StartDictationSession()` / `EndDictationSession()`
- [x] Accept caller-owned utterance PCM through `FHyperTwistSpeechUtteranceEnvelope` and submit it through the active companion speech session boundary.
- [x] On release or utterance submission: receive transcript JSON, parse cleaned text, and expose it for move-notation or coach-command execution
- [x] **Audio Ducking**: when `bDictationActive = true`, duck all non-speech `UAudioComponent` volumes to 15% via managed component `VolumeMultiplier`
- [x] Restore full volume 200ms after `EndDictationSession()`
- [x] Automation test: simulate hotkey/session-open behavior, assert `VolumeMultiplier < 0.2f` on background music component; assert restoration after release
### 1C — Text-to-Speech: `rhasspy/piper`
- [ ] Add submodule `mirrors/piper`
- [ ] Compile `piper` as static library (espeak-ng dependency)
- [ ] Download one small voice model (~50MB) into `Content/Voices/`
- [ ] Expose `UHyperTwistSpeechLibrary::Synthesize(FString Text, FString VoiceId)``TArray<uint8> PCM16`
- [ ] Automation test: synthesize "hello", assert PCM output is non-empty and has correct sample rate
### 1C — Text-to-Speech: `rhasspy/piper` ✅ COMPLETE
- [x] Retain repo-backed `piper` source under `.external/piper` as the canonical donor lane for local/offline narration.
- [x] Keep `piper` in the accepted external-engine posture while exposing first-party synthesis through `UHyperTwistSpeechLibrary`.
- [x] Preserve the bounded local voice-model/runtime lane without forcing a large checked-in voice payload into repo authority.
- [x] Expose `UHyperTwistSpeechLibrary::Synthesize(FString Text, FString VoiceId)``TArray<uint8> PCM16`
- [x] Automation test: synthesize "hello", assert PCM output is non-empty and that the helper returns stripped PCM payload bytes
### 1D — Compact Solver Oracle: `brownan/Rubiks-Cube-Solver` (GPL-2.0)
- [ ] Host as external IPC process (never link GPL into Unreal binary)
- [ ] Expose `UHyperTwistSolverLibrary::VerifySolution(FString State, TArray<FString> Moves)``bool`
- [x] Host as external IPC process (never link GPL into Unreal binary)
- [x] Expose `UHyperTwistSolverLibrary::VerifySolution(FString State, TArray<FString> Moves)``bool`
### 1E — glTF Reference Renderer: `KhronosGroup/glTF-Sample-Renderer` (Apache-2.0)
- [ ] Add submodule `mirrors/glTF-Sample-Renderer`
- [ ] Evaluate: header-only portions vs. full renderer
- [ ] If linkable: add to `Build.cs`, expose asset import validation helpers
- [ ] If too heavy: retain as reference-only for QA pipeline
### 1E — glTF Reference Renderer: `KhronosGroup/glTF-Sample-Renderer` (Apache-2.0) ✅ COMPLETE
- [x] Retain repo-backed `glTF-Sample-Renderer` source under `.external/glTF-Sample-Renderer`
- [x] Evaluate the repo shape against the actual donor surface: the retained renderer is a browser/runtime module, not a useful Unreal static-link target in this packet
- [x] Wire the standards-aware runtime path through `Content/Browser/` using the published `@khronosgroup/gltf-viewer` package and browser-shell adapter manifest
- [x] Keep heavyweight comparison/editor workflows subordinate to the browser QA lane rather than fabricating a fake C++ link claim
### 1F — Solver Adjunct: `cahidenes/rubiks-cube-solver` (MIT)
- [ ] Host as external IPC process (Python)
- [ ] Expose comparison endpoint: same state to both solvers, assert identical solution length
- [x] Host as external IPC process (Python)
- [x] Expose comparison endpoint: same state to both solvers, assert identical solution length
### 1G — Browser Solver: `tentone/rubix-solver` (MIT)
- [ ] Bundle into browser runtime (`Content/Browser/`)
- [ ] Load in embedded WebBrowser widget as demo/fallback solver UI
### 1G — Browser Solver: `tentone/rubix-solver` (MIT) ✅ COMPLETE
- [x] Surface the donor in `Content/Browser/` as a native-sidecar adapter instead of misclassifying the OpenCV/C++ repo as an npm package
- [x] Load the fallback solver shell in embedded `UHyperTwistBrowserWidget` runtime with browser-to-Unreal request envelopes for the native lane
### 1H — Browser Spatial Stack (13 JS repos)
- [ ] Add all as npm dependencies or prebuilt bundles in `Content/Browser/`
- [ ] 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
- [ ] Build once via npm → bundle `browser-spatial-runtime.js`
- [ ] Load bundle in `UHyperTwistBrowserWidget`
- [ ] State bridge: UE `FJsonObject` ↔ JS `postMessage`
### 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] State bridge: embedded Unreal bridge object plus browser `postMessage` fallback for shell-state and command envelopes
### 1I — Analytics Stack (2 JS repos)
- [ ] `ecomfe/zrender`, `ecomfe/echarts-gl`
- [ ] Bundle into browser runtime; subordinate to already-landed `apache/echarts`
- [ ] Expose chart data endpoint from UE analytics subsystem
### 1I — Analytics Stack (2 JS repos) ✅ COMPLETE
- [x] `ecomfe/zrender`, `ecomfe/echarts-gl`
- [x] Bundle into browser runtime; subordinate to already-landed `apache/echarts`
- [x] Expose the browser-shell analytics demo/adapter surface so Unreal-side analytics payloads can be rendered into the embedded shell
### 1J — Model-Viewer Sub-Packages (3 JS repos, runtime)
- [ ] `google/model-viewer/packages/model-viewer-effects`, `space-opera`, `render-fidelity-tools`
- [ ] Bundle into browser runtime alongside already-landed `google/model-viewer`
- [ ] `render-fidelity-tools` → QA comparison pipeline only
### 1J — Model-Viewer Sub-Packages (3 JS repos, runtime) ✅ COMPLETE
- [x] `google/model-viewer/packages/model-viewer-effects`, `space-opera`, `render-fidelity-tools`
- [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
**Estimated actions:** 4060 per repo (UHT regen on first include)
**Estimated time:** 12 days per repo (build-debug cycles)
@ -177,12 +182,12 @@ for the full 29-repo queue and per-repo wiring posture.
- [x] Queued through existing RotateFace() pipeline with sequential animation
- [x] Windows build validated: 6 actions, 95.39s, exit code 0, zero warnings
### 2E — Timer + HUD
- [ ] Create `WBP_HyperTwistGameHUD` (UMG Widget)
- [ ] Timer display (minutes:seconds.milliseconds)
- [ ] Inspection countdown (15s standard)
- [ ] Scramble notation panel
- [ ] Bind timer start to first face turn, stop to solved-state detection
### 2E — Timer + HUD ✅ COMPLETE
- [x] Create `WBP_HyperTwistGameHUD`-equivalent first-party UMG runtime (`UHyperTwistClassicCubeHUDWidget`) plus `AHyperTwistClassicCubeGameMode`
- [x] Timer display (minutes:seconds.milliseconds)
- [x] Inspection countdown (15s standard)
- [x] Scramble notation panel
- [x] Bind timer start to first face turn, stop to solved-state detection
**Estimated actions:** 80120 (geometry + input + HUD)
**Estimated time:** 35 days

View file

@ -87,16 +87,16 @@ These repos were evaluated in `Packet 0R-B` (36 repos). Seven later graduated to
## Wiring Rules by Runtime Posture
### Direct C++ Link (Only for C/C++ repos)
### Direct C++ Link (Only for true native-link repos)
- `efrantar/rob-twophase`
- `KhronosGroup/glTF-Sample-Renderer`
- `rhasspy/piper`
- `freestyle-voice/freestyle` (if embedding; otherwise IPC)
### Embedded Browser Runtime (For JS/TS repos)
- Requires `WebBrowser` plugin in `.uproject`
- Requires Node.js build pipeline or prebuilt bundles in `Content/Browser/`
- State bridge via `FJsonObject` ↔ JavaScript `postMessage`
- Current first-party landing uses `UHyperTwistBrowserWidget` + `UHyperTwistBrowserBridgeObject`
- State bridge via embedded Unreal bridge object with JavaScript `postMessage` fallback
- All Wave B, C, D, E (JS), F repos use this path
### IPC / Sidecar Process (For Python or restrictive repos)
@ -152,9 +152,9 @@ Not scheduled for wiring. Listed for completeness:
| 1B | `freestyle-voice/freestyle` | Sidecar HTTP or embedded | Rust/TS |
| 1C | `rhasspy/piper` | Static library, direct link | C++ |
| 1D | `brownan/Rubiks-Cube-Solver` | IPC process (GPL) | Python |
| 1E | `KhronosGroup/glTF-Sample-Renderer` | Static library or header-only | C++ |
| 1E | `KhronosGroup/glTF-Sample-Renderer` | Embedded browser runtime reference viewer | JS/browser |
| 1F | `cahidenes/rubiks-cube-solver` | IPC process | Python |
| 1G | `tentone/rubix-solver` | Embedded browser | JS |
| 1G | `tentone/rubix-solver` | Browser fallback shell + native-sidecar bridge | Native/browser |
---

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, 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 `Content/Browser` shell plus 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. |