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.
Files changed (151) hide show
  1. package/.agents/skills/moonbit-agent-guide/LICENSE +202 -0
  2. package/.agents/skills/moonbit-agent-guide/SKILL.mbt.md +1126 -0
  3. package/.agents/skills/moonbit-agent-guide/SKILL.md +1126 -0
  4. package/.agents/skills/moonbit-agent-guide/ide.md +116 -0
  5. package/.agents/skills/moonbit-agent-guide/references/advanced-moonbit-build.md +106 -0
  6. package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.mbt.md +422 -0
  7. package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.md +422 -0
  8. package/.agents/skills/moonbit-practice/SKILL.md +258 -0
  9. package/.agents/skills/moonbit-practice/assets/ci.yaml +25 -0
  10. package/.agents/skills/moonbit-practice/reference/agents.md +1469 -0
  11. package/.agents/skills/moonbit-practice/reference/configuration.md +228 -0
  12. package/.agents/skills/moonbit-practice/reference/ffi.md +229 -0
  13. package/.agents/skills/moonbit-practice/reference/ide.md +189 -0
  14. package/.agents/skills/moonbit-practice/reference/performance.md +217 -0
  15. package/.agents/skills/moonbit-practice/reference/refactor.md +154 -0
  16. package/.agents/skills/moonbit-practice/reference/stdlib.md +351 -0
  17. package/.agents/skills/moonbit-practice/reference/testing.md +228 -0
  18. package/.agents/skills/moonbit-refactoring/LICENSE +21 -0
  19. package/.agents/skills/moonbit-refactoring/SKILL.md +323 -0
  20. package/.githooks/README.md +23 -0
  21. package/.githooks/pre-commit +3 -0
  22. package/.github/workflows/copilot-setup-steps.yml +40 -0
  23. package/.turbo/turbo-typecheck.log +2 -0
  24. package/AGENTS.md +91 -0
  25. package/LICENSE +21 -0
  26. package/PLAN.md +64 -0
  27. package/README.mbt.md +77 -0
  28. package/README.md +84 -0
  29. package/TODO.md +120 -0
  30. package/a.png +0 -0
  31. package/benchmarks/calc.bench.ts +144 -0
  32. package/benchmarks/draw.bench.ts +215 -0
  33. package/benchmarks/load.bench.ts +28 -0
  34. package/benchmarks/render.bench.ts +53 -0
  35. package/benchmarks/run.bench.ts +8 -0
  36. package/benchmarks/types.d.ts +15 -0
  37. package/docs/scratch-vm-specs/eventloop.md +103 -0
  38. package/docs/scratch-vm-specs/moonscratch-time-separation.md +50 -0
  39. package/index.html +91 -0
  40. package/js/AGENTS.md +5 -0
  41. package/js/a.ts +52 -0
  42. package/js/assets/AGENTS.md +5 -0
  43. package/js/assets/base64.test.ts +14 -0
  44. package/js/assets/base64.ts +21 -0
  45. package/js/assets/build-asset.test.ts +26 -0
  46. package/js/assets/build-asset.ts +28 -0
  47. package/js/assets/create.test.ts +142 -0
  48. package/js/assets/create.ts +122 -0
  49. package/js/assets/index.test.ts +15 -0
  50. package/js/assets/index.ts +2 -0
  51. package/js/assets/types.ts +26 -0
  52. package/js/assets/validation.test.ts +34 -0
  53. package/js/assets/validation.ts +25 -0
  54. package/js/assets.test.ts +14 -0
  55. package/js/assets.ts +1 -0
  56. package/js/index.test.ts +26 -0
  57. package/js/index.ts +3 -0
  58. package/js/render/index.test.ts +65 -0
  59. package/js/render/index.ts +13 -0
  60. package/js/render/sharp.ts +87 -0
  61. package/js/render/svg.ts +68 -0
  62. package/js/render/types.ts +35 -0
  63. package/js/render/utils.ts +108 -0
  64. package/js/render/webgl.ts +274 -0
  65. package/js/sharp-optional.d.ts +16 -0
  66. package/js/test/helpers.ts +116 -0
  67. package/js/test/hikkaku-sample.test.ts +37 -0
  68. package/js/test/rubik-components.input-motion.test.ts +60 -0
  69. package/js/test/rubik-components.lists.test.ts +49 -0
  70. package/js/test/rubik-components.operators.test.ts +104 -0
  71. package/js/test/rubik-components.pen.test.ts +112 -0
  72. package/js/test/rubik-components.procedures-loops.test.ts +72 -0
  73. package/js/test/rubik-components.variables-branches.test.ts +57 -0
  74. package/js/test/rubik-components.visibility-entry.test.ts +31 -0
  75. package/js/test/test-projects.ts +598 -0
  76. package/js/test/variable.ts +200 -0
  77. package/js/test/warp.test.ts +59 -0
  78. package/js/vm/AGENTS.md +6 -0
  79. package/js/vm/README.md +183 -0
  80. package/js/vm/bindings.test.ts +13 -0
  81. package/js/vm/bindings.ts +5 -0
  82. package/js/vm/compare-operators.test.ts +145 -0
  83. package/js/vm/constants.test.ts +11 -0
  84. package/js/vm/constants.ts +4 -0
  85. package/js/vm/effect-guards.test.ts +68 -0
  86. package/js/vm/effect-guards.ts +44 -0
  87. package/js/vm/factory.test.ts +486 -0
  88. package/js/vm/factory.ts +615 -0
  89. package/js/vm/headless-vm.test.ts +131 -0
  90. package/js/vm/headless-vm.ts +342 -0
  91. package/js/vm/index.test.ts +28 -0
  92. package/js/vm/index.ts +5 -0
  93. package/js/vm/internal-types.ts +32 -0
  94. package/js/vm/json.test.ts +40 -0
  95. package/js/vm/json.ts +273 -0
  96. package/js/vm/normalize.test.ts +48 -0
  97. package/js/vm/normalize.ts +65 -0
  98. package/js/vm/options.test.ts +30 -0
  99. package/js/vm/options.ts +55 -0
  100. package/js/vm/pen-transparency.test.ts +115 -0
  101. package/js/vm/program-wasm.ts +322 -0
  102. package/js/vm/scheduler-render.test.ts +401 -0
  103. package/js/vm/scratch-assets.test.ts +136 -0
  104. package/js/vm/scratch-assets.ts +202 -0
  105. package/js/vm/types.ts +358 -0
  106. package/js/vm/value-guards.test.ts +25 -0
  107. package/js/vm/value-guards.ts +18 -0
  108. package/moon.mod.json +10 -0
  109. package/package.json +33 -0
  110. package/scripts/preinstall.ts +4 -0
  111. package/src/AGENTS.md +6 -0
  112. package/src/api.mbt +161 -0
  113. package/src/api_aot_commands.mbt +184 -0
  114. package/src/api_effects_json.mbt +72 -0
  115. package/src/api_options.mbt +60 -0
  116. package/src/api_program_wasm.mbt +1647 -0
  117. package/src/api_program_wat.mbt +2206 -0
  118. package/src/api_snapshot_json.mbt +44 -0
  119. package/src/cmd/AGENTS.md +5 -0
  120. package/src/cmd/main/AGENTS.md +5 -0
  121. package/src/cmd/main/main.mbt +29 -0
  122. package/src/cmd/main/moon.pkg +7 -0
  123. package/src/cmd/main/pkg.generated.mbti +13 -0
  124. package/src/json_helpers.mbt +176 -0
  125. package/src/moon.pkg +65 -0
  126. package/src/moonscratch.mbt +3 -0
  127. package/src/moonscratch_wbtest.mbt +40 -0
  128. package/src/parser_sb3.mbt +890 -0
  129. package/src/pkg.generated.mbti +479 -0
  130. package/src/runtime_eval.mbt +2844 -0
  131. package/src/runtime_exec.mbt +3850 -0
  132. package/src/runtime_render.mbt +2550 -0
  133. package/src/runtime_state.mbt +870 -0
  134. package/src/test/AGENTS.md +3 -0
  135. package/src/test/projects/AGENTS.md +6 -0
  136. package/src/test/projects/moon.pkg +4 -0
  137. package/src/test/projects/moonscratch_compat_test.mbt +642 -0
  138. package/src/test/projects/moonscratch_core_test.mbt +1332 -0
  139. package/src/test/projects/moonscratch_runtime_test.mbt +1087 -0
  140. package/src/test/projects/pkg.generated.mbti +13 -0
  141. package/src/test/projects/test_support.mbt +35 -0
  142. package/src/types_effects.mbt +20 -0
  143. package/src/types_error.mbt +4 -0
  144. package/src/types_options.mbt +31 -0
  145. package/src/types_runtime_structs.mbt +254 -0
  146. package/src/types_vm.mbt +109 -0
  147. package/tsconfig.json +29 -0
  148. package/viewer/index.ts +399 -0
  149. package/viewer/vite.d.ts +1 -0
  150. package/viewer/worker.ts +161 -0
  151. package/vite.config.ts +11 -0
@@ -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
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -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
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vite-plus'
2
+
3
+ export default defineConfig({
4
+ pack: {
5
+ entry: 'js/index.ts',
6
+ dts: true,
7
+ },
8
+ test: {
9
+ include: ['./js/**/*.test.ts'],
10
+ },
11
+ })