moonscratch 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/moonbit-agent-guide/LICENSE +202 -0
- package/.agents/skills/moonbit-agent-guide/SKILL.mbt.md +1126 -0
- package/.agents/skills/moonbit-agent-guide/SKILL.md +1126 -0
- package/.agents/skills/moonbit-agent-guide/ide.md +116 -0
- package/.agents/skills/moonbit-agent-guide/references/advanced-moonbit-build.md +106 -0
- package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.mbt.md +422 -0
- package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.md +422 -0
- package/.agents/skills/moonbit-practice/SKILL.md +258 -0
- package/.agents/skills/moonbit-practice/assets/ci.yaml +25 -0
- package/.agents/skills/moonbit-practice/reference/agents.md +1469 -0
- package/.agents/skills/moonbit-practice/reference/configuration.md +228 -0
- package/.agents/skills/moonbit-practice/reference/ffi.md +229 -0
- package/.agents/skills/moonbit-practice/reference/ide.md +189 -0
- package/.agents/skills/moonbit-practice/reference/performance.md +217 -0
- package/.agents/skills/moonbit-practice/reference/refactor.md +154 -0
- package/.agents/skills/moonbit-practice/reference/stdlib.md +351 -0
- package/.agents/skills/moonbit-practice/reference/testing.md +228 -0
- package/.agents/skills/moonbit-refactoring/LICENSE +21 -0
- package/.agents/skills/moonbit-refactoring/SKILL.md +323 -0
- package/.githooks/README.md +23 -0
- package/.githooks/pre-commit +3 -0
- package/.github/workflows/copilot-setup-steps.yml +40 -0
- package/.turbo/turbo-typecheck.log +2 -0
- package/AGENTS.md +91 -0
- package/LICENSE +21 -0
- package/PLAN.md +64 -0
- package/README.mbt.md +77 -0
- package/README.md +84 -0
- package/TODO.md +120 -0
- package/a.png +0 -0
- package/benchmarks/calc.bench.ts +144 -0
- package/benchmarks/draw.bench.ts +215 -0
- package/benchmarks/load.bench.ts +28 -0
- package/benchmarks/render.bench.ts +53 -0
- package/benchmarks/run.bench.ts +8 -0
- package/benchmarks/types.d.ts +15 -0
- package/docs/scratch-vm-specs/eventloop.md +103 -0
- package/docs/scratch-vm-specs/moonscratch-time-separation.md +50 -0
- package/index.html +91 -0
- package/js/AGENTS.md +5 -0
- package/js/a.ts +52 -0
- package/js/assets/AGENTS.md +5 -0
- package/js/assets/base64.test.ts +14 -0
- package/js/assets/base64.ts +21 -0
- package/js/assets/build-asset.test.ts +26 -0
- package/js/assets/build-asset.ts +28 -0
- package/js/assets/create.test.ts +142 -0
- package/js/assets/create.ts +122 -0
- package/js/assets/index.test.ts +15 -0
- package/js/assets/index.ts +2 -0
- package/js/assets/types.ts +26 -0
- package/js/assets/validation.test.ts +34 -0
- package/js/assets/validation.ts +25 -0
- package/js/assets.test.ts +14 -0
- package/js/assets.ts +1 -0
- package/js/index.test.ts +26 -0
- package/js/index.ts +3 -0
- package/js/render/index.test.ts +65 -0
- package/js/render/index.ts +13 -0
- package/js/render/sharp.ts +87 -0
- package/js/render/svg.ts +68 -0
- package/js/render/types.ts +35 -0
- package/js/render/utils.ts +108 -0
- package/js/render/webgl.ts +274 -0
- package/js/sharp-optional.d.ts +16 -0
- package/js/test/helpers.ts +116 -0
- package/js/test/hikkaku-sample.test.ts +37 -0
- package/js/test/rubik-components.input-motion.test.ts +60 -0
- package/js/test/rubik-components.lists.test.ts +49 -0
- package/js/test/rubik-components.operators.test.ts +104 -0
- package/js/test/rubik-components.pen.test.ts +112 -0
- package/js/test/rubik-components.procedures-loops.test.ts +72 -0
- package/js/test/rubik-components.variables-branches.test.ts +57 -0
- package/js/test/rubik-components.visibility-entry.test.ts +31 -0
- package/js/test/test-projects.ts +598 -0
- package/js/test/variable.ts +200 -0
- package/js/test/warp.test.ts +59 -0
- package/js/vm/AGENTS.md +6 -0
- package/js/vm/README.md +183 -0
- package/js/vm/bindings.test.ts +13 -0
- package/js/vm/bindings.ts +5 -0
- package/js/vm/compare-operators.test.ts +145 -0
- package/js/vm/constants.test.ts +11 -0
- package/js/vm/constants.ts +4 -0
- package/js/vm/effect-guards.test.ts +68 -0
- package/js/vm/effect-guards.ts +44 -0
- package/js/vm/factory.test.ts +486 -0
- package/js/vm/factory.ts +615 -0
- package/js/vm/headless-vm.test.ts +131 -0
- package/js/vm/headless-vm.ts +342 -0
- package/js/vm/index.test.ts +28 -0
- package/js/vm/index.ts +5 -0
- package/js/vm/internal-types.ts +32 -0
- package/js/vm/json.test.ts +40 -0
- package/js/vm/json.ts +273 -0
- package/js/vm/normalize.test.ts +48 -0
- package/js/vm/normalize.ts +65 -0
- package/js/vm/options.test.ts +30 -0
- package/js/vm/options.ts +55 -0
- package/js/vm/pen-transparency.test.ts +115 -0
- package/js/vm/program-wasm.ts +322 -0
- package/js/vm/scheduler-render.test.ts +401 -0
- package/js/vm/scratch-assets.test.ts +136 -0
- package/js/vm/scratch-assets.ts +202 -0
- package/js/vm/types.ts +358 -0
- package/js/vm/value-guards.test.ts +25 -0
- package/js/vm/value-guards.ts +18 -0
- package/moon.mod.json +10 -0
- package/package.json +33 -0
- package/scripts/preinstall.ts +4 -0
- package/src/AGENTS.md +6 -0
- package/src/api.mbt +161 -0
- package/src/api_aot_commands.mbt +184 -0
- package/src/api_effects_json.mbt +72 -0
- package/src/api_options.mbt +60 -0
- package/src/api_program_wasm.mbt +1647 -0
- package/src/api_program_wat.mbt +2206 -0
- package/src/api_snapshot_json.mbt +44 -0
- package/src/cmd/AGENTS.md +5 -0
- package/src/cmd/main/AGENTS.md +5 -0
- package/src/cmd/main/main.mbt +29 -0
- package/src/cmd/main/moon.pkg +7 -0
- package/src/cmd/main/pkg.generated.mbti +13 -0
- package/src/json_helpers.mbt +176 -0
- package/src/moon.pkg +65 -0
- package/src/moonscratch.mbt +3 -0
- package/src/moonscratch_wbtest.mbt +40 -0
- package/src/parser_sb3.mbt +890 -0
- package/src/pkg.generated.mbti +479 -0
- package/src/runtime_eval.mbt +2844 -0
- package/src/runtime_exec.mbt +3850 -0
- package/src/runtime_render.mbt +2550 -0
- package/src/runtime_state.mbt +870 -0
- package/src/test/AGENTS.md +3 -0
- package/src/test/projects/AGENTS.md +6 -0
- package/src/test/projects/moon.pkg +4 -0
- package/src/test/projects/moonscratch_compat_test.mbt +642 -0
- package/src/test/projects/moonscratch_core_test.mbt +1332 -0
- package/src/test/projects/moonscratch_runtime_test.mbt +1087 -0
- package/src/test/projects/pkg.generated.mbti +13 -0
- package/src/test/projects/test_support.mbt +35 -0
- package/src/types_effects.mbt +20 -0
- package/src/types_error.mbt +4 -0
- package/src/types_options.mbt +31 -0
- package/src/types_runtime_structs.mbt +254 -0
- package/src/types_vm.mbt +109 -0
- package/tsconfig.json +29 -0
- package/viewer/index.ts +399 -0
- package/viewer/vite.d.ts +1 -0
- package/viewer/worker.ts +161 -0
- package/vite.config.ts +11 -0
package/viewer/index.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/// <reference types="vite-plus/client" />
|
|
2
|
+
|
|
3
|
+
import type { RenderFrame, VMInputEvent } from '../js/index.ts'
|
|
4
|
+
import VMWorker from './worker.ts?worker'
|
|
5
|
+
|
|
6
|
+
type ExampleProject = {
|
|
7
|
+
id: string
|
|
8
|
+
label: string
|
|
9
|
+
projectJson: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type ProjectModule = {
|
|
13
|
+
default?: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ViewerWorkerRequest =
|
|
17
|
+
| {
|
|
18
|
+
type: 'load'
|
|
19
|
+
projectJson: string
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
type: 'input'
|
|
23
|
+
input: VMInputEvent
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ViewerWorkerResponse =
|
|
27
|
+
| {
|
|
28
|
+
type: 'frame'
|
|
29
|
+
frame: RenderFrame
|
|
30
|
+
workerFps: number
|
|
31
|
+
workerOpsPerSecond: number
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
type: 'finished'
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
type: 'warp-exit'
|
|
38
|
+
isInWarp: boolean
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
type: 'error'
|
|
42
|
+
message: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const formatProjectId = (path: string): string => {
|
|
46
|
+
const match = path.match(/([^/]+)\/dist\/project\.json$/)
|
|
47
|
+
return match?.[1] ?? path
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const toProjectJson = (_path: string, raw: unknown): string | null => {
|
|
51
|
+
const rawModule = raw as ProjectModule | string
|
|
52
|
+
const extracted =
|
|
53
|
+
typeof rawModule === 'object' &&
|
|
54
|
+
rawModule !== null &&
|
|
55
|
+
'default' in rawModule
|
|
56
|
+
? rawModule.default
|
|
57
|
+
: rawModule
|
|
58
|
+
if (typeof extracted === 'string') {
|
|
59
|
+
return extracted
|
|
60
|
+
}
|
|
61
|
+
if (extracted === undefined || extracted === null) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return JSON.stringify(extracted)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const loadedModules = import.meta.glob(
|
|
72
|
+
'../../../examples/*/dist/project.json',
|
|
73
|
+
{
|
|
74
|
+
eager: true,
|
|
75
|
+
import: 'default',
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const projects = Object.entries(loadedModules)
|
|
80
|
+
.map(([path, raw]) => {
|
|
81
|
+
const projectJson = toProjectJson(path, raw)
|
|
82
|
+
if (!projectJson) {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const id = formatProjectId(path)
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
label: id,
|
|
90
|
+
projectJson,
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.filter(
|
|
94
|
+
(entry): entry is ExampleProject =>
|
|
95
|
+
entry !== null && entry.id.trim().length > 0,
|
|
96
|
+
)
|
|
97
|
+
.sort((left, right) => left.label.localeCompare(right.label))
|
|
98
|
+
|
|
99
|
+
const appElement = document.querySelector<HTMLElement>('#app') ?? document.body
|
|
100
|
+
|
|
101
|
+
const controlPanel = document.createElement('div')
|
|
102
|
+
controlPanel.className = 'controls'
|
|
103
|
+
|
|
104
|
+
const selectLabel = document.createElement('label')
|
|
105
|
+
selectLabel.textContent = 'Project'
|
|
106
|
+
selectLabel.htmlFor = 'example-project-select'
|
|
107
|
+
|
|
108
|
+
const projectSelect = document.createElement('select')
|
|
109
|
+
projectSelect.id = 'example-project-select'
|
|
110
|
+
projectSelect.setAttribute('aria-label', 'Select a project')
|
|
111
|
+
|
|
112
|
+
const status = document.createElement('p')
|
|
113
|
+
status.className = 'status'
|
|
114
|
+
|
|
115
|
+
const fpsLabel = document.createElement('small')
|
|
116
|
+
fpsLabel.style.color = '#facc15'
|
|
117
|
+
|
|
118
|
+
const canvas = document.createElement('canvas')
|
|
119
|
+
canvas.width = 480
|
|
120
|
+
canvas.height = 360
|
|
121
|
+
canvas.tabIndex = 0
|
|
122
|
+
|
|
123
|
+
const statusContainer = document.createElement('div')
|
|
124
|
+
statusContainer.append(status, fpsLabel)
|
|
125
|
+
|
|
126
|
+
if (projects.length === 0) {
|
|
127
|
+
const heading = document.createElement('h1')
|
|
128
|
+
heading.textContent = 'MoonScratch Viewer'
|
|
129
|
+
const errorMessage = document.createElement('p')
|
|
130
|
+
errorMessage.textContent =
|
|
131
|
+
'examples/*/dist/project.json を検出できませんでした。'
|
|
132
|
+
appElement.append(heading, errorMessage)
|
|
133
|
+
} else {
|
|
134
|
+
projects.forEach((project) => {
|
|
135
|
+
const option = document.createElement('option')
|
|
136
|
+
option.value = project.id
|
|
137
|
+
option.textContent = project.label
|
|
138
|
+
projectSelect.append(option)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const heading = document.createElement('h1')
|
|
142
|
+
heading.textContent = 'MoonScratch Viewer'
|
|
143
|
+
controlPanel.append(selectLabel, projectSelect)
|
|
144
|
+
appElement.append(heading, controlPanel, statusContainer, canvas)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
document.body.append(appElement)
|
|
148
|
+
|
|
149
|
+
const context = canvas.getContext('2d')
|
|
150
|
+
if (!context) {
|
|
151
|
+
throw new Error('canvas 2D context is unavailable')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let worker: Worker | null = null
|
|
155
|
+
let fpsFrames = 0
|
|
156
|
+
let fpsStartedAt = 0
|
|
157
|
+
let playbackToken = 0
|
|
158
|
+
let isPointerDown = false
|
|
159
|
+
const keysDown = new Set<string>()
|
|
160
|
+
let workerFps = 0
|
|
161
|
+
let workerOpsPerSecond = 0
|
|
162
|
+
|
|
163
|
+
const postInput = (input: VMInputEvent): void => {
|
|
164
|
+
if (!worker) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
const message: ViewerWorkerRequest = {
|
|
168
|
+
type: 'input',
|
|
169
|
+
input,
|
|
170
|
+
}
|
|
171
|
+
worker.postMessage(message)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const normalizeDomKey = (key: string): string => {
|
|
175
|
+
if (key === ' ') {
|
|
176
|
+
return 'space'
|
|
177
|
+
}
|
|
178
|
+
return key.toLowerCase()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const syncKeysDown = (): void => {
|
|
182
|
+
postInput({
|
|
183
|
+
type: 'keys_down',
|
|
184
|
+
keys: Array.from(keysDown),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const toStageMousePoint = (
|
|
189
|
+
event: Pick<MouseEvent, 'clientX' | 'clientY'>,
|
|
190
|
+
): { x: number; y: number } => {
|
|
191
|
+
const rect = canvas.getBoundingClientRect()
|
|
192
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
193
|
+
return { x: 0, y: 0 }
|
|
194
|
+
}
|
|
195
|
+
const offsetX = (event.clientX - rect.left) / rect.width
|
|
196
|
+
const offsetY = (event.clientY - rect.top) / rect.height
|
|
197
|
+
const rawX = offsetX * canvas.width - canvas.width / 2
|
|
198
|
+
const rawY = canvas.height / 2 - offsetY * canvas.height
|
|
199
|
+
const x = Math.max(-canvas.width / 2, Math.min(canvas.width / 2, rawX))
|
|
200
|
+
const y = Math.max(-canvas.height / 2, Math.min(canvas.height / 2, rawY))
|
|
201
|
+
return {
|
|
202
|
+
x,
|
|
203
|
+
y,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const postMouseState = (
|
|
208
|
+
event: Pick<MouseEvent, 'clientX' | 'clientY'>,
|
|
209
|
+
isDown: boolean,
|
|
210
|
+
): void => {
|
|
211
|
+
const point = toStageMousePoint(event)
|
|
212
|
+
postInput({
|
|
213
|
+
type: 'mouse',
|
|
214
|
+
x: point.x,
|
|
215
|
+
y: point.y,
|
|
216
|
+
isDown,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const postMouseTargets = (): void => {
|
|
221
|
+
postInput({
|
|
222
|
+
type: 'mouse_targets',
|
|
223
|
+
stage: true,
|
|
224
|
+
targets: [],
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const updateFps = (now = performance.now()): void => {
|
|
229
|
+
if (fpsStartedAt === 0) {
|
|
230
|
+
fpsStartedAt = now
|
|
231
|
+
}
|
|
232
|
+
fpsFrames += 1
|
|
233
|
+
const elapsedMs = now - fpsStartedAt
|
|
234
|
+
if (elapsedMs >= 500) {
|
|
235
|
+
const renderFps = (fpsFrames * 1000) / elapsedMs
|
|
236
|
+
fpsLabel.textContent = `${renderFps.toFixed(1)} FPS, worker ${workerFps.toFixed(1)} FPS, ${workerOpsPerSecond.toFixed(0)} ops/s`
|
|
237
|
+
fpsFrames = 0
|
|
238
|
+
fpsStartedAt = now
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const renderFrameToCanvas = (frame: RenderFrame): void => {
|
|
243
|
+
const { width, height, pixels } = frame
|
|
244
|
+
if (width <= 0 || height <= 0) {
|
|
245
|
+
status.textContent = '空のフレームです'
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
250
|
+
canvas.width = width
|
|
251
|
+
canvas.height = height
|
|
252
|
+
}
|
|
253
|
+
const clamped = Uint8ClampedArray.from(pixels)
|
|
254
|
+
const imageData = new ImageData(clamped, width, height)
|
|
255
|
+
context.putImageData(imageData, 0, 0)
|
|
256
|
+
updateFps()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const stopPlayback = (): void => {
|
|
260
|
+
if (worker) {
|
|
261
|
+
worker.terminate()
|
|
262
|
+
worker = null
|
|
263
|
+
}
|
|
264
|
+
keysDown.clear()
|
|
265
|
+
isPointerDown = false
|
|
266
|
+
fpsFrames = 0
|
|
267
|
+
fpsStartedAt = 0
|
|
268
|
+
workerFps = 0
|
|
269
|
+
workerOpsPerSecond = 0
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const startPlayback = async (projectId: string) => {
|
|
273
|
+
const selected = projects.find((project) => project.id === projectId)
|
|
274
|
+
if (!selected) {
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
stopPlayback()
|
|
279
|
+
|
|
280
|
+
status.textContent = `${selected.label} を起動中...`
|
|
281
|
+
fpsLabel.textContent = ''
|
|
282
|
+
try {
|
|
283
|
+
const token = playbackToken + 1
|
|
284
|
+
playbackToken = token
|
|
285
|
+
status.textContent = `${selected.label} を再生中`
|
|
286
|
+
worker = new VMWorker()
|
|
287
|
+
const startMessage: ViewerWorkerRequest = {
|
|
288
|
+
type: 'load',
|
|
289
|
+
projectJson: selected.projectJson,
|
|
290
|
+
}
|
|
291
|
+
worker.postMessage(startMessage)
|
|
292
|
+
worker.onmessage = (event) => {
|
|
293
|
+
if (token !== playbackToken) {
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
const payload = event.data as ViewerWorkerResponse
|
|
297
|
+
if (payload.type === 'frame') {
|
|
298
|
+
workerFps = payload.workerFps
|
|
299
|
+
workerOpsPerSecond = payload.workerOpsPerSecond
|
|
300
|
+
renderFrameToCanvas(payload.frame)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (payload.type === 'finished') {
|
|
304
|
+
status.textContent = `${selected.label} の再生が完了`
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
if (payload.type === 'warp-exit') {
|
|
308
|
+
status.textContent = `${selected.label}: warp-exit (isInWarp=${payload.isInWarp})`
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
if (payload.type === 'error') {
|
|
312
|
+
status.textContent = `読み込みに失敗しました: ${payload.message}`
|
|
313
|
+
stopPlayback()
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
worker.onerror = (event) => {
|
|
317
|
+
if (token !== playbackToken) {
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
status.textContent = `読み込みに失敗しました: ${event.message}`
|
|
321
|
+
stopPlayback()
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
status.textContent = `読み込みに失敗しました: ${
|
|
325
|
+
error instanceof Error ? error.message : String(error)
|
|
326
|
+
}`
|
|
327
|
+
stopPlayback()
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
projectSelect.addEventListener('change', () => {
|
|
332
|
+
startPlayback(projectSelect.value)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
336
|
+
if (!worker) {
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
isPointerDown = true
|
|
340
|
+
postMouseState(event, true)
|
|
341
|
+
postMouseTargets()
|
|
342
|
+
canvas.focus()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
canvas.addEventListener('pointermove', (event) => {
|
|
346
|
+
if (!worker) {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
postMouseState(event, isPointerDown || event.buttons > 0)
|
|
350
|
+
postMouseTargets()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
canvas.addEventListener('pointerup', (event) => {
|
|
354
|
+
if (!worker) {
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
isPointerDown = false
|
|
358
|
+
postMouseState(event, false)
|
|
359
|
+
postMouseTargets()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
canvas.addEventListener('pointercancel', () => {
|
|
363
|
+
isPointerDown = false
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
canvas.addEventListener('keydown', (event) => {
|
|
367
|
+
if (!worker) {
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
const key = normalizeDomKey(event.key)
|
|
371
|
+
if (event.repeat || keysDown.has(key)) {
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
keysDown.add(key)
|
|
375
|
+
syncKeysDown()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
canvas.addEventListener('keyup', (event) => {
|
|
379
|
+
if (!worker) {
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
const key = normalizeDomKey(event.key)
|
|
383
|
+
if (!keysDown.delete(key)) {
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
syncKeysDown()
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
canvas.addEventListener('blur', () => {
|
|
390
|
+
if (!worker || keysDown.size === 0) {
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
keysDown.clear()
|
|
394
|
+
syncKeysDown()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
if (projects.length > 0) {
|
|
398
|
+
startPlayback('rubiks-cube')
|
|
399
|
+
}
|
package/viewer/vite.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/viewer/worker.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHeadlessVM,
|
|
3
|
+
createProgramModuleFromProject,
|
|
4
|
+
type VMInputEvent,
|
|
5
|
+
} from '../js'
|
|
6
|
+
|
|
7
|
+
const FRAME_FORCE_TIMEOUT_OUT_OF_WARP = 1000 / 30 // 30 FPS
|
|
8
|
+
const FRAME_FORCE_TIMEOUT_IN_WARP = 1000 / 5 // 5 FPS
|
|
9
|
+
const TICKS_TIMEOUT = 1
|
|
10
|
+
const WORKER_METRICS_WINDOW_MS = 1000
|
|
11
|
+
|
|
12
|
+
type ViewerWorkerRequest =
|
|
13
|
+
| {
|
|
14
|
+
type: 'load'
|
|
15
|
+
projectJson: string
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
type: 'input'
|
|
19
|
+
input: VMInputEvent
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let vm: ReturnType<typeof createHeadlessVM> | null = null
|
|
23
|
+
let runToken = 0
|
|
24
|
+
const pendingInputs: VMInputEvent[] = []
|
|
25
|
+
|
|
26
|
+
const flushPendingInputs = (): void => {
|
|
27
|
+
if (!vm || pendingInputs.length === 0) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
for (const input of pendingInputs) {
|
|
31
|
+
vm.dispatchInputEvent(input)
|
|
32
|
+
}
|
|
33
|
+
pendingInputs.length = 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const waitForNextFrame = async (): Promise<void> => {
|
|
37
|
+
await new Promise<void>((resolve) => {
|
|
38
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
39
|
+
requestAnimationFrame(() => resolve())
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
setTimeout(() => resolve(), FRAME_FORCE_TIMEOUT_OUT_OF_WARP)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const playbackLoop = async (token: number): Promise<void> => {
|
|
47
|
+
let workerFpsFrames = 0
|
|
48
|
+
let workerOps = 0
|
|
49
|
+
let metricsStartedAt = 0
|
|
50
|
+
let currentWorkerFps = 0
|
|
51
|
+
let currentWorkerOpsPerSecond = 0
|
|
52
|
+
while (true) {
|
|
53
|
+
if (!vm || token !== runToken) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
flushPendingInputs()
|
|
57
|
+
|
|
58
|
+
const frameStart = performance.now()
|
|
59
|
+
vm.setTime(frameStart)
|
|
60
|
+
let frameOps = 0
|
|
61
|
+
let shouldRender = false
|
|
62
|
+
let isFinished = false
|
|
63
|
+
while (true) {
|
|
64
|
+
const frameInfo = vm.stepFrame()
|
|
65
|
+
frameOps += frameInfo.ops
|
|
66
|
+
if (frameInfo.stopReason === 'finished') {
|
|
67
|
+
shouldRender = frameInfo.shouldRender
|
|
68
|
+
isFinished = true
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
if (frameInfo.shouldRender) {
|
|
72
|
+
shouldRender = true
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const frameBudget = frameInfo.isInWarp
|
|
77
|
+
? FRAME_FORCE_TIMEOUT_IN_WARP
|
|
78
|
+
: FRAME_FORCE_TIMEOUT_OUT_OF_WARP
|
|
79
|
+
if (performance.now() - frameStart > frameBudget) {
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
workerFpsFrames += 1
|
|
85
|
+
workerOps += frameOps
|
|
86
|
+
const now = performance.now()
|
|
87
|
+
if (metricsStartedAt === 0) {
|
|
88
|
+
metricsStartedAt = now
|
|
89
|
+
} else {
|
|
90
|
+
const elapsedMs = now - metricsStartedAt
|
|
91
|
+
if (elapsedMs >= WORKER_METRICS_WINDOW_MS) {
|
|
92
|
+
currentWorkerFps = (workerFpsFrames * 1000) / elapsedMs
|
|
93
|
+
currentWorkerOpsPerSecond = (workerOps * 1000) / elapsedMs
|
|
94
|
+
workerFpsFrames = 0
|
|
95
|
+
workerOps = 0
|
|
96
|
+
metricsStartedAt = now
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (shouldRender) {
|
|
101
|
+
const frame = vm.renderFrame()
|
|
102
|
+
postMessage({
|
|
103
|
+
type: 'frame',
|
|
104
|
+
frame,
|
|
105
|
+
workerFps: currentWorkerFps,
|
|
106
|
+
workerOpsPerSecond: currentWorkerOpsPerSecond,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isFinished) {
|
|
111
|
+
postMessage({ type: 'finished' })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await waitForNextFrame()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const toErrorMessage = (error: unknown): string =>
|
|
120
|
+
error instanceof Error ? error.message : String(error)
|
|
121
|
+
|
|
122
|
+
globalThis.onmessage = (event) => {
|
|
123
|
+
const data = event.data as ViewerWorkerRequest
|
|
124
|
+
if (!data || typeof data !== 'object') {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (data.type === 'input') {
|
|
128
|
+
if (!vm) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
pendingInputs.push(data.input)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (data.type !== 'load') {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
pendingInputs.length = 0
|
|
139
|
+
runToken += 1
|
|
140
|
+
const token = runToken
|
|
141
|
+
try {
|
|
142
|
+
const program = createProgramModuleFromProject({
|
|
143
|
+
projectJson: data.projectJson,
|
|
144
|
+
})
|
|
145
|
+
vm = createHeadlessVM({
|
|
146
|
+
program,
|
|
147
|
+
options: {
|
|
148
|
+
stepTimeoutTicks: TICKS_TIMEOUT,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
vm.start()
|
|
152
|
+
vm.greenFlag()
|
|
153
|
+
void playbackLoop(token)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
vm = null
|
|
156
|
+
postMessage({
|
|
157
|
+
type: 'error',
|
|
158
|
+
message: toErrorMessage(error),
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|