Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
0b6762a64This file is pinned to the selected release. Download the original to open it in your design tool.
import * as THREE from 'three'
import { OrbitControls } from 'https://unpkg.com/three@0.164.1/examples/jsm/controls/OrbitControls.js'
import { RoundedBoxGeometry } from 'https://unpkg.com/three@0.164.1/examples/jsm/geometries/RoundedBoxGeometry.js'
import { STLLoader } from 'https://unpkg.com/three@0.164.1/examples/jsm/loaders/STLLoader.js'
import {
clientPointFromTouch,
createHostAudioInBridge,
createHostAudioOutBridge,
createHostButtonBridge,
createHostCameraBridge,
createHostDriverBridge,
installModArchiveIntoWasm,
summarizeImageData,
} from './bridge.mjs'
import {
SCREEN_CANVAS,
STACKCHAN_FACE_MM,
STACKCHAN_FOOT_MM,
STACKCHAN_SIMULATOR_COLORS,
STACKCHAN_SHELL_STL,
computeFaceLayerDepths,
computeFaceModulePlacement,
computeFootPlacements,
computeGeometryTuning,
computeScreenFrame,
computeScreenPlane,
computeShellPlacementFromBounds,
computeStackchanKinematics,
createRoundedRectPath,
screenPointFromUv,
stepRotationToward,
} from './geometry.mjs'
import { createModStorage, formatByteSize } from './mod-storage.mjs'
const DRIVER_MAX_ANGULAR_SPEED = 2.4
class StackchanScene {
constructor({ viewport, screen }) {
this.viewport = viewport
this.screen = screen
this.driverRotation = { y: 0, p: 0, r: 0 }
this.targetDriverRotation = { y: 0, p: 0, r: 0 }
this.lastDriverUpdateMs = undefined
this.torqueEnabled = true
this.geometryTuning = computeGeometryTuning()
this.raycaster = new THREE.Raycaster()
this.pointerNdc = new THREE.Vector2()
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0x10141c)
this.camera = new THREE.PerspectiveCamera(35, 1, 0.1, 1000)
this.camera.position.set(42, 28, 155)
this.renderer = new THREE.WebGLRenderer({
canvas: viewport,
antialias: true,
alpha: false,
})
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enableDamping = true
this.controls.dampingFactor = 0.08
this.controls.target.set(0, -6, 0)
this.controls.minDistance = 80
this.controls.maxDistance = 260
this.controls.update()
this.root = new THREE.Group()
this.scene.add(this.root)
this.panGroup = new THREE.Group()
this.tiltGroup = new THREE.Group()
this.headGroup = new THREE.Group()
this.feetGroup = new THREE.Group()
this.root.add(this.panGroup)
this.panGroup.add(this.tiltGroup)
this.tiltGroup.add(this.headGroup)
this.root.add(this.feetGroup)
this.#createLights()
this.#createBody()
this.#createFeet()
this.#createScreen()
this.#resize()
this.resizeTarget = this.viewport.parentElement ?? this.viewport
this.resizeObserver = new ResizeObserver(() => this.#resize())
this.resizeObserver.observe(this.resizeTarget)
}
dispose() {
this.resizeObserver?.disconnect()
this.controls?.dispose()
this.renderer?.dispose()
}
#createLights() {
this.scene.add(new THREE.HemisphereLight(0xffefe0, 0x223355, 2.6))
const key = new THREE.DirectionalLight(0xffffff, 3)
key.position.set(30, 40, 80)
this.scene.add(key)
const rim = new THREE.DirectionalLight(0x77bbff, 1.4)
rim.position.set(-50, 20, -40)
this.scene.add(rim)
}
#createBody() {
this.shellMaterial = new THREE.MeshStandardMaterial({
color: STACKCHAN_SIMULATOR_COLORS.shell,
roughness: 0.54,
metalness: 0.02,
})
this.m5stackSideMaterial = new THREE.MeshStandardMaterial({
color: STACKCHAN_SIMULATOR_COLORS.m5stackSide,
roughness: 0.58,
metalness: 0.02,
})
this.m5stackFrontMaterial = new THREE.MeshStandardMaterial({
color: STACKCHAN_SIMULATOR_COLORS.m5stackFront,
roughness: 0.62,
metalness: 0.02,
})
this.footMaterial = new THREE.MeshStandardMaterial({
color: STACKCHAN_SIMULATOR_COLORS.feet,
roughness: 0.6,
metalness: 0.02,
})
this.#createShell()
this.#createFaceModule()
}
#createShell() {
const loader = new STLLoader()
loader.load(
STACKCHAN_SHELL_STL.url,
(geometry) => {
geometry.computeVertexNormals()
const placement = computeShellPlacementFromBounds(STACKCHAN_SHELL_STL.sourceBoundsMm, {
tuning: this.geometryTuning,
})
this.shell = new THREE.Mesh(geometry, this.shellMaterial)
this.shell.position.set(placement.position.x, placement.position.y, placement.position.z)
this.shell.rotation.set(placement.rotation.x, placement.rotation.y, placement.rotation.z)
this.shell.scale.setScalar(placement.scale)
this.headGroup.add(this.shell)
const outline = new THREE.LineSegments(
new THREE.EdgesGeometry(geometry, 24),
new THREE.LineBasicMaterial({
color: 0x3d3128,
transparent: true,
opacity: 0.1,
})
)
outline.position.copy(this.shell.position)
outline.rotation.copy(this.shell.rotation)
outline.scale.copy(this.shell.scale)
this.headGroup.add(outline)
},
undefined,
(error) => {
console.warn('[simulator] failed to load shell STL; using generated face module only', error)
}
)
}
#createFaceModule() {
const facePlacement = computeFaceModulePlacement()
const faceDepth = facePlacement.depth
const shape = new THREE.Shape()
const points = createRoundedRectPath(STACKCHAN_FACE_MM)
shape.moveTo(points[0].x, points[0].y)
for (const point of points.slice(1)) shape.lineTo(point.x, point.y)
shape.closePath()
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: faceDepth,
bevelEnabled: true,
bevelSize: 1.4,
bevelThickness: STACKCHAN_FACE_MM.bevelThickness,
bevelSegments: 5,
})
geometry.center()
geometry.translate(0, 0, facePlacement.z)
this.faceModule = new THREE.Mesh(geometry, this.m5stackSideMaterial)
this.headGroup.add(this.faceModule)
const layers = computeFaceLayerDepths()
const frontPanelGeometry = new THREE.ShapeGeometry(shape)
frontPanelGeometry.translate(0, 0, layers.frontPanelZ)
this.faceFrontPanel = new THREE.Mesh(frontPanelGeometry, this.m5stackFrontMaterial)
this.headGroup.add(this.faceFrontPanel)
const outline = new THREE.LineSegments(
new THREE.EdgesGeometry(geometry, 24),
new THREE.LineBasicMaterial({
color: 0x3d3128,
transparent: true,
opacity: 0.18,
})
)
this.headGroup.add(outline)
}
#createFeet() {
const geometry = new RoundedBoxGeometry(
STACKCHAN_FOOT_MM.width,
STACKCHAN_FOOT_MM.height,
STACKCHAN_FOOT_MM.depth,
5,
STACKCHAN_FOOT_MM.radius
)
const outlineGeometry = new THREE.EdgesGeometry(geometry, 24)
for (const placement of computeFootPlacements({
tuning: this.geometryTuning,
})) {
const foot = new THREE.Mesh(geometry, this.footMaterial)
foot.position.set(placement.x, placement.y, placement.z)
this.feetGroup.add(foot)
const outline = new THREE.LineSegments(
outlineGeometry,
new THREE.LineBasicMaterial({
color: 0x3d3128,
transparent: true,
opacity: 0.16,
})
)
outline.position.copy(foot.position)
this.feetGroup.add(outline)
}
}
#createScreen() {
this.screenTexture = new THREE.CanvasTexture(this.screen)
this.screenTexture.colorSpace = THREE.SRGBColorSpace
this.screenTexture.minFilter = THREE.LinearFilter
this.screenTexture.magFilter = THREE.NearestFilter
const plane = computeScreenPlane({ margin: 5 })
const geometry = new THREE.PlaneGeometry(plane.width, plane.height)
const material = new THREE.MeshBasicMaterial({
map: this.screenTexture,
toneMapped: false,
})
this.screenMesh = new THREE.Mesh(geometry, material)
this.screenMesh.position.set(plane.x, plane.y, plane.z)
this.headGroup.add(this.screenMesh)
const framePlacement = computeScreenFrame({ margin: 5, border: 1.2 })
const frameShape = new THREE.Shape()
const outerHalfWidth = framePlacement.outer.width / 2
const outerHalfHeight = framePlacement.outer.height / 2
frameShape.moveTo(-outerHalfWidth, -outerHalfHeight)
frameShape.lineTo(outerHalfWidth, -outerHalfHeight)
frameShape.lineTo(outerHalfWidth, outerHalfHeight)
frameShape.lineTo(-outerHalfWidth, outerHalfHeight)
frameShape.closePath()
const screenHole = new THREE.Path()
const innerHalfWidth = framePlacement.inner.width / 2
const innerHalfHeight = framePlacement.inner.height / 2
screenHole.moveTo(-innerHalfWidth, -innerHalfHeight)
screenHole.lineTo(-innerHalfWidth, innerHalfHeight)
screenHole.lineTo(innerHalfWidth, innerHalfHeight)
screenHole.lineTo(innerHalfWidth, -innerHalfHeight)
screenHole.closePath()
frameShape.holes.push(screenHole)
const frame = new THREE.Mesh(new THREE.ShapeGeometry(frameShape), new THREE.MeshBasicMaterial({ color: 0x211a17 }))
frame.position.set(framePlacement.x, framePlacement.y, framePlacement.z)
this.headGroup.add(frame)
this.screenMesh.renderOrder = 1
}
#resize() {
const { width, height } = this.resizeTarget?.getBoundingClientRect() ?? this.viewport.getBoundingClientRect()
if (width <= 0 || height <= 0) return
this.renderer.setSize(width, height, false)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
}
applyDriverRotation(rotation) {
this.targetDriverRotation = { ...this.targetDriverRotation, ...rotation }
}
setTorqueEnabled(enabled) {
this.torqueEnabled = enabled
}
setViewportControlsEnabled(enabled) {
this.controls.enabled = enabled
}
markScreenDirty() {
this.screenTexture.needsUpdate = true
}
updateDriverRotation(timeMs) {
if (this.lastDriverUpdateMs === undefined) {
this.lastDriverUpdateMs = timeMs
return
}
const deltaSeconds = Math.max(0, Math.min((timeMs - this.lastDriverUpdateMs) / 1000, 0.1))
this.lastDriverUpdateMs = timeMs
this.driverRotation = stepRotationToward(
this.driverRotation,
this.targetDriverRotation,
deltaSeconds,
DRIVER_MAX_ANGULAR_SPEED
)
}
screenPointFromViewportEvent(event) {
const bounds = this.viewport.getBoundingClientRect()
this.pointerNdc.set(
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
-((event.clientY - bounds.top) / bounds.height) * 2 + 1
)
this.raycaster.setFromCamera(this.pointerNdc, this.camera)
const [hit] = this.raycaster.intersectObject(this.screenMesh, false)
return screenPointFromUv(hit?.uv, {
width: this.screen.width,
height: this.screen.height,
})
}
render(timeMs) {
this.updateDriverRotation(timeMs)
const transforms = computeStackchanKinematics(timeMs, {
driverRotation: this.driverRotation,
})
this.panGroup.position.set(transforms.pan.pivot.x, transforms.pan.pivot.y, transforms.pan.pivot.z)
this.panGroup.rotation.set(transforms.pan.rotation.x, transforms.pan.rotation.y, transforms.pan.rotation.z)
this.tiltGroup.position.set(transforms.tilt.pivot.x, transforms.tilt.pivot.y, transforms.tilt.pivot.z)
this.tiltGroup.rotation.set(transforms.tilt.rotation.x, transforms.tilt.rotation.y, transforms.tilt.rotation.z)
this.headGroup.rotation.set(transforms.head.rotation.x, transforms.head.rotation.y, transforms.head.rotation.z)
this.headGroup.scale.set(transforms.head.scale.x, transforms.head.scale.y, transforms.head.scale.z)
this.feetGroup.rotation.set(transforms.feet.rotation.x, transforms.feet.rotation.y, transforms.feet.rotation.z)
this.feetGroup.scale.set(transforms.feet.scale.x, transforms.feet.scale.y, transforms.feet.scale.z)
this.markScreenDirty()
this.controls.update()
this.renderer.render(this.scene, this.camera)
}
}
class WasmView {
constructor({
scene,
screen,
info,
traceLog,
modStorage,
onModInstallStatus = () => {},
onReady = () => {},
onError = () => {},
}) {
this.scene = scene
this.screen = screen
this.info = info
this.traceLog = traceLog
this.traceLines = []
this.modStorage = modStorage
this.onModInstallStatus = onModInstallStatus
this.onReady = onReady
this.onError = onError
this.runCount = 0
this.interval = 0
this.tracking = 0
this.when = 0
this.image = null
this.bufferChangeCount = 0
this.pendingReadyInstallation = null
this.readyTimeout = 0
this.#bindTouches()
}
start() {
return this.#loadWasm()
}
#bindTouches() {
this.screen.addEventListener('mousedown', (event) => this.#mouse(event, 0))
this.screen.addEventListener('mousemove', (event) => this.tracking && this.#mouse(event, 3))
this.screen.addEventListener('mouseup', (event) => this.#mouse(event, 2))
this.screen.addEventListener('touchstart', (event) => this.#touches(event, 0), { passive: false })
this.screen.addEventListener('touchmove', (event) => this.#touches(event, 3), { passive: false })
this.screen.addEventListener('touchend', (event) => this.#touches(event, 2), { passive: false })
this.screen.addEventListener('touchcancel', (event) => this.#touches(event, 1), { passive: false })
}
async #loadWasm() {
try {
console.log('[bridge] importing mc.js')
const wasmCacheKey = Date.now()
const ns = await import(`./mc.js?v=${wasmCacheKey}`)
this.mc = await ns.default({
locateFile: () => `./mc.wasm?v=${wasmCacheKey}`,
print: (text) => this.#handleFirmwarePrint(text),
printErr: (text) => this.#handleFirmwareError(text),
})
console.log('[bridge] mc.js module ready')
this.fxMainIdle = this.mc._fxMainIdle
this.fxMainLaunch = this.mc._fxMainLaunch
this.fxMainQuit = this.mc._fxMainQuit
this.fxMainTouch = this.mc._fxMainTouch
const installation = await this.installSavedModArchive()
this.#awaitFirmwareReady(installation.result)
this.launch(installation.pointer)
} catch (error) {
this.#clearPendingReady()
console.error('[bridge] WASM load failed', error)
this.info.textContent = 'WASMを読み込めませんでした'
this.#drawFallbackFace()
this.onError(error)
}
}
async installSavedModArchive() {
try {
const installedMod = await this.modStorage.loadInstalledMod()
const result = installModArchiveIntoWasm(this.mc, installedMod)
console.log('[bridge] MOD archive install', result)
this.onModInstallStatus(result, installedMod)
if (installedMod && result.status !== 'prepared' && result.status !== 'installed') {
throw new Error(`MODアーカイブを起動できません (${result.status})`)
}
return { pointer: result.pointer ?? undefined, result }
} catch (error) {
const result = { status: 'error', error: error.message }
console.error('[bridge] MOD archive install failed', error)
this.onModInstallStatus(result)
throw error
}
}
#reportReady(installation) {
this.#clearPendingReady()
this.runCount += 1
this.onReady({ runCount: this.runCount, installation })
}
#awaitFirmwareReady(installation) {
this.#clearPendingReady()
this.pendingReadyInstallation = installation
this.readyTimeout = window.setTimeout(() => {
if (!this.pendingReadyInstallation) return
this.#clearPendingReady()
const error = new Error('ファームウェアの起動準備がタイムアウトしました')
this.info.textContent = error.message
this.onError(error)
}, 30_000)
}
#clearPendingReady() {
if (this.readyTimeout) window.clearTimeout(this.readyTimeout)
this.readyTimeout = 0
this.pendingReadyInstallation = null
}
#handleFirmwarePrint(text) {
this.#applyFirmwareDriverTrace(text)
this.#appendTrace(text)
console.log(`[firmware] ${text}`)
if (String(text).includes('[main] app behaviors ready') && this.pendingReadyInstallation) {
this.#reportReady(this.pendingReadyInstallation)
}
}
#handleFirmwareError(text) {
this.#appendTrace(`[err] ${text}`)
console.error(`[firmware:err] ${text}`)
}
#appendTrace(text) {
if (!this.traceLog) return
this.traceLines.push(String(text))
if (this.traceLines.length > 120) {
this.traceLines.splice(0, this.traceLines.length - 120)
}
this.traceLog.textContent = this.traceLines.join('\n')
this.traceLog.scrollTop = this.traceLog.scrollHeight
notifyEditor({ type: 'stackchan-simulator-trace', text: String(text) })
}
#applyFirmwareDriverTrace(text) {
if (typeof text !== 'string' || !text.startsWith('[WasmDriver] ')) return
const rotation = text.match(/^\[WasmDriver\] applyRotation y=([^ ]+) p=([^ ]+) r=([^ ]+) time=([^ ]*)/)
if (rotation) {
const [, y, p, r] = rotation
this.scene.applyDriverRotation({
y: Number(y),
p: Number(p),
r: Number(r),
})
return
}
const torque = text.match(/^\[WasmDriver\] setTorque torque=([01])/)
if (torque) {
this.scene.setTorqueEnabled(torque[1] === '1')
}
}
#drawFallbackFace() {
const ctx = this.screen.getContext('2d')
ctx.fillStyle = '#fff5e8'
ctx.fillRect(0, 0, this.screen.width, this.screen.height)
ctx.fillStyle = '#1f1a17'
ctx.beginPath()
ctx.arc(108, 96, 18, 0, Math.PI * 2)
ctx.arc(212, 96, 18, 0, Math.PI * 2)
ctx.fill()
ctx.lineWidth = 8
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(130, 154)
ctx.quadraticCurveTo(160, 178, 194, 154)
ctx.stroke()
this.scene.markScreenDirty()
}
launch(archive) {
console.log('[bridge] launch', {
width: this.screen.width,
height: this.screen.height,
hasArchive: Boolean(archive),
})
const pointer = this.fxMainLaunch(this.screen.width, this.screen.height, archive)
console.log('[bridge] fxMainLaunch returned', { pointer })
const array = new Uint8ClampedArray(this.mc.HEAP8.buffer, pointer, this.screen.width * this.screen.height * 4)
this.image = new ImageData(array, this.screen.width, this.screen.height)
}
async restart() {
if (!this.mc || !this.fxMainLaunch) {
throw new Error('WASM is not ready')
}
console.log('[bridge] restart simulator')
this.fxMainQuit?.()
this.interval = 0
this.when = 0
this.image = null
this.bufferChangeCount = 0
this.screen.getContext('2d').clearRect(0, 0, this.screen.width, this.screen.height)
this.scene.markScreenDirty()
const installation = await this.installSavedModArchive()
this.#awaitFirmwareReady(installation.result)
try {
this.launch(installation.pointer)
} catch (error) {
this.#clearPendingReady()
throw error
}
}
idle(timeStamp) {
if (this.fxMainIdle && this.when <= timeStamp) {
this.when = timeStamp + this.interval
this.fxMainIdle()
}
}
onBufferChanged() {
if (!this.image) {
console.warn('[bridge] onBufferChanged before image allocation')
return
}
this.bufferChangeCount += 1
this.screen.getContext('2d').putImageData(this.image, 0, 0)
this.scene.markScreenDirty()
if (this.bufferChangeCount <= 3) {
console.log('[bridge] onBufferChanged', {
count: this.bufferChangeCount,
...summarizeImageData(this.image),
})
}
}
onFormatChanged(which, major, minor, patch) {
const pixelFormats = [
'16-bit RGB 565 Little Endian',
'16-bit RGB 565 Big Endian',
'8-bit Gray',
'8-bit RGB 332',
'4-bit Gray',
'4-bit Color Look-up Table',
]
const format = pixelFormats[which] ?? `format ${which}`
console.log('[bridge] onFormatChanged', {
which,
format,
xs: `${major}.${minor}.${patch}`,
})
this.info.textContent = '準備完了'
}
onStart(interval) {
console.log('[bridge] onStart', { interval })
this.interval = interval
this.when = performance.now()
}
onStop() {
console.log('[bridge] onStop')
this.interval = 0
}
#mouse(event, kind) {
event.preventDefault()
if (kind === 0) this.tracking++
if (kind === 1 || kind === 2) this.tracking--
this.#touch(kind, 0, event.clientX, event.clientY, event.timeStamp)
}
#touches(event, kind) {
event.preventDefault()
if (kind === 0) this.tracking += event.changedTouches.length
if (kind === 1 || kind === 2) this.tracking -= event.changedTouches.length
for (const touch of event.changedTouches) {
const point = clientPointFromTouch(touch)
this.#touch(kind, touch.identifier, point.x, point.y, event.timeStamp)
}
}
#touch(kind, index, x, y, when) {
if (!this.image || !this.fxMainTouch) return
const bounds = this.screen.getBoundingClientRect()
this.touchScreenPoint(kind, index, x - bounds.left, y - bounds.top, when)
}
touchScreenPoint(kind, index, x, y, when) {
if (!this.image || !this.fxMainTouch) return
this.fxMainTouch(kind, index, x, y, when)
}
}
const viewport = document.getElementById('stackchan-viewport')
const screen = document.getElementById('simulator-screen')
const info = document.getElementById('simulator-info')
const modArchiveInput = document.getElementById('mod-archive-input')
const modRestartButton = document.getElementById('simulator-restart-button')
const modClearButton = document.getElementById('mod-clear-button')
const modInstallStatus = document.getElementById('mod-install-status')
const traceLog = document.getElementById('trace-log')
const modStorage = createModStorage()
const browserCameraButton = document.getElementById('browser-camera-button')
const browserCameraStatus = document.getElementById('browser-camera-status')
const buttonBridge = createHostButtonBridge({
logger: (message) => console.log(message),
})
const audioOutBridge = createHostAudioOutBridge()
const audioInBridge = createHostAudioInBridge()
const cameraBridge = createHostCameraBridge()
if (browserCameraButton) {
browserCameraButton.addEventListener('click', async () => {
browserCameraButton.disabled = true
if (browserCameraStatus) browserCameraStatus.textContent = '接続中'
try {
await cameraBridge.start({ useBrowserCamera: true })
if (browserCameraStatus) {
browserCameraStatus.textContent = cameraBridge.isBrowserCameraStarted()
? '接続済み'
: '利用できません · 合成映像'
}
} catch (error) {
console.warn('[bridge] browser camera button failed', error)
if (browserCameraStatus) browserCameraStatus.textContent = '接続失敗 · 合成映像'
} finally {
browserCameraButton.disabled = false
}
})
}
globalThis.Host = {
Button: buttonBridge.Button,
AudioOut: audioOutBridge,
AudioIn: audioInBridge,
Camera: cameraBridge,
}
console.log('[bridge] global Host.Button/Audio/Camera constructors installed')
function describeModStatus(result, installedMod = null) {
if (result?.status === 'prepared') {
return `${result.name} · ${formatByteSize(result.size)} · 起動準備済み`
}
if (result?.status === 'installed') {
return `${result.name} · ${formatByteSize(result.size)} · 適用済み`
}
if (result?.status === 'unsupported') {
return `${result.name} · ${formatByteSize(result.size)} · 保存済み`
}
if (result?.status === 'error') {
return `MODエラー · ${result.error}`
}
if (result?.status === 'saved' && installedMod) {
const lifetime = installedMod.storage === 'memory' ? ' · セッション保存' : ' · 保存済み'
return `${installedMod.name} · ${formatByteSize(installedMod.size)}${lifetime}`
}
if (installedMod) {
const lifetime = installedMod.storage === 'memory' ? ' · セッション保存' : ' · 保存済み'
return `${installedMod.name} · ${formatByteSize(installedMod.size)}${lifetime}`
}
return 'MODなし'
}
async function refreshSavedModStatus() {
try {
const installedMod = await modStorage.loadInstalledMod()
modInstallStatus.textContent = describeModStatus({ status: installedMod ? 'saved' : 'empty' }, installedMod)
} catch (error) {
modInstallStatus.textContent = describeModStatus({
status: 'error',
error: error.message,
})
}
}
const scene = new StackchanScene({ viewport, screen })
window.addEventListener('pagehide', () => scene.dispose(), { once: true })
const driverBridge = createHostDriverBridge({
onRotation: (rotation, time) => {
console.log('[bridge] Host.Driver.applyRotation', { rotation, time })
scene.applyDriverRotation(rotation)
},
onTorque: (torque) => {
console.log('[bridge] Host.Driver.setTorque', { torque })
scene.setTorqueEnabled(torque)
},
})
globalThis.Host.Driver = driverBridge
console.log('[bridge] global Host.Driver bridge installed')
globalThis.Host.Camera = cameraBridge
console.log('[bridge] global Host.Camera bridge installed')
const wasmView = new WasmView({
scene,
screen,
info,
traceLog,
modStorage,
onModInstallStatus: (result, installedMod) => {
modInstallStatus.textContent = describeModStatus(result, installedMod)
},
onReady: ({ runCount, installation }) => {
if (installation?.status === 'prepared') {
modInstallStatus.textContent = describeModStatus({ ...installation, status: 'installed' })
}
notifyEditor({
type: 'stackchan-simulator-ready',
runCount,
installationStatus: installation.status,
})
},
onError: (error) => {
notifyEditor({
type: 'stackchan-simulator-status',
status: 'error',
error: String(error.message ?? error),
})
},
})
globalThis.gxView = wasmView
console.log('[bridge] global gxView installed')
function notifyEditor(message) {
const target = window.opener ?? (window.parent !== window ? window.parent : null)
target?.postMessage(message, location.origin)
}
window.addEventListener('message', async (event) => {
if (event.origin !== location.origin || event.data?.type !== 'stackchan-editor-command') return
try {
if (event.data.command === 'restart') await wasmView.restart()
if (event.data.command === 'button') buttonBridge.push(event.data.name)
} catch (error) {
notifyEditor({
type: 'stackchan-simulator-status',
status: 'error',
error: String(error.message ?? error),
})
}
})
refreshSavedModStatus().finally(() => wasmView.start())
modArchiveInput.addEventListener('change', async (event) => {
const input = event.currentTarget
const file = input.files?.[0]
if (!file) return
try {
const bytes = new Uint8Array(await file.arrayBuffer())
const installedMod = await modStorage.saveInstalledMod({
name: file.name,
bytes,
})
modInstallStatus.textContent = `${describeModStatus({ status: 'saved' }, installedMod)} · 再起動中`
await wasmView.restart()
} catch (error) {
console.error('[bridge] MOD archive save failed', error)
modInstallStatus.textContent = describeModStatus({
status: 'error',
error: error.message,
})
} finally {
input.value = ''
}
})
modRestartButton.addEventListener('click', async () => {
try {
modInstallStatus.textContent = '再起動中'
await wasmView.restart()
} catch (error) {
console.error('[bridge] simulator restart failed', error)
modInstallStatus.textContent = describeModStatus({
status: 'error',
error: error.message,
})
}
})
modClearButton.addEventListener('click', async () => {
try {
await modStorage.clearInstalledMod()
modInstallStatus.textContent = describeModStatus({ status: 'empty' })
} catch (error) {
console.error('[bridge] MOD archive clear failed', error)
modInstallStatus.textContent = describeModStatus({
status: 'error',
error: error.message,
})
}
})
bindViewportScreenTouches({ viewport, scene, wasmView })
function animate(timeMs) {
wasmView.idle(timeMs)
scene.render(timeMs)
window.requestAnimationFrame(animate)
}
window.requestAnimationFrame(animate)
function bindViewportScreenTouches({ viewport, scene, wasmView }) {
const touchId = 0
let activePointerId = undefined
let lastPoint = undefined
const consume = (event) => {
event.preventDefault()
event.stopImmediatePropagation()
}
const finish = (event, kind) => {
if (event.pointerId !== activePointerId) return
consume(event)
if (lastPoint) {
wasmView.touchScreenPoint(kind, touchId, lastPoint.x, lastPoint.y, event.timeStamp)
}
try {
viewport.releasePointerCapture(event.pointerId)
} catch {
// The browser may already have released capture after cancellation.
}
activePointerId = undefined
lastPoint = undefined
scene.setViewportControlsEnabled(true)
}
viewport.addEventListener(
'pointerdown',
(event) => {
if (activePointerId !== undefined) return
const point = scene.screenPointFromViewportEvent(event)
if (!point) return
consume(event)
activePointerId = event.pointerId
lastPoint = point
scene.setViewportControlsEnabled(false)
viewport.setPointerCapture(event.pointerId)
wasmView.touchScreenPoint(0, touchId, point.x, point.y, event.timeStamp)
},
{ capture: true }
)
viewport.addEventListener(
'pointermove',
(event) => {
if (event.pointerId !== activePointerId) return
consume(event)
const point = scene.screenPointFromViewportEvent(event)
if (!point) return
lastPoint = point
wasmView.touchScreenPoint(3, touchId, point.x, point.y, event.timeStamp)
},
{ capture: true }
)
viewport.addEventListener('pointerup', (event) => finish(event, 2), {
capture: true,
})
viewport.addEventListener('pointercancel', (event) => finish(event, 1), {
capture: true,
})
}