hikkaku 0.1.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.
@@ -0,0 +1,77 @@
1
+ import { fromPrimitiveSource, fromSoundSource } from '../compiler/block-helper'
2
+ import { block, valueBlock } from '../compiler/composer'
3
+ import type { PrimitiveSource, SoundSource } from '../compiler/types'
4
+
5
+ export type SoundEffect = 'pitch' | 'pan'
6
+
7
+ export const playSound = (sound: SoundSource) => {
8
+ return block('sound_play', {
9
+ inputs: {
10
+ SOUND_MENU: fromSoundSource(sound),
11
+ },
12
+ })
13
+ }
14
+
15
+ export const playSoundUntilDone = (sound: SoundSource) => {
16
+ return block('sound_playuntildone', {
17
+ inputs: {
18
+ SOUND_MENU: fromSoundSource(sound),
19
+ },
20
+ })
21
+ }
22
+
23
+ export const stopAllSounds = () => {
24
+ return block('sound_stopallsounds', {})
25
+ }
26
+
27
+ export const setSoundEffectTo = (
28
+ effect: SoundEffect,
29
+ value: PrimitiveSource<number>,
30
+ ) => {
31
+ return block('sound_seteffectto', {
32
+ inputs: {
33
+ VALUE: fromPrimitiveSource(value),
34
+ },
35
+ fields: {
36
+ EFFECT: [effect, null],
37
+ },
38
+ })
39
+ }
40
+
41
+ export const changeSoundEffectBy = (
42
+ effect: SoundEffect,
43
+ value: PrimitiveSource<number>,
44
+ ) => {
45
+ return block('sound_changeeffectby', {
46
+ inputs: {
47
+ VALUE: fromPrimitiveSource(value),
48
+ },
49
+ fields: {
50
+ EFFECT: [effect, null],
51
+ },
52
+ })
53
+ }
54
+
55
+ export const clearEffects = () => {
56
+ return block('sound_cleareffects', {})
57
+ }
58
+
59
+ export const setVolumeTo = (value: PrimitiveSource<number>) => {
60
+ return block('sound_setvolumeto', {
61
+ inputs: {
62
+ VOLUME: fromPrimitiveSource(value),
63
+ },
64
+ })
65
+ }
66
+
67
+ export const changeVolumeBy = (value: PrimitiveSource<number>) => {
68
+ return block('sound_changevolumeby', {
69
+ inputs: {
70
+ VOLUME: fromPrimitiveSource(value),
71
+ },
72
+ })
73
+ }
74
+
75
+ export const getVolume = () => {
76
+ return valueBlock('sound_volume', {})
77
+ }
@@ -0,0 +1,110 @@
1
+ import type { ScratchVM } from './types'
2
+
3
+ export const findDOMAppRoot = () => {
4
+ const probably = [
5
+ document.getElementById('app'), // Scratch WWW: #app is the root element
6
+ document.querySelector('[class^=index_app]'), // Playground code of Scratch GUI
7
+ ]
8
+ for (const el of probably) {
9
+ if (el && '_reactRootContainer' in el) {
10
+ return el as ScratchRoot
11
+ }
12
+ }
13
+
14
+ throw new Error(
15
+ 'Could not find root DOM node. Make sure you are running this in a Scratch environment.',
16
+ )
17
+ }
18
+
19
+ interface FiberNode {
20
+ child: ScratchAppFiberNode | FiberNode | null
21
+ sibling: ScratchAppFiberNode | FiberNode | null
22
+ type: string | null
23
+ elementType?: {
24
+ propTypes?: Record<string, unknown>
25
+ }
26
+ }
27
+ interface ScratchGUIReduxStoreType {
28
+ vm: ScratchVM
29
+ }
30
+ interface ScratchInternalReduxStoreType {
31
+ scratchGui: ScratchGUIReduxStoreType
32
+ }
33
+ interface ScratchAppFiberNode extends FiberNode {
34
+ memoizedProps: {
35
+ store: {
36
+ getState: () => ScratchInternalReduxStoreType
37
+ }
38
+ }
39
+ }
40
+ type ScratchRoot = {
41
+ _reactRootContainer: {
42
+ _internalRoot: {
43
+ current: FiberNode
44
+ }
45
+ }
46
+ } & Element
47
+
48
+ export function getSpecifiedFiber(
49
+ root: FiberNode,
50
+ cond: (fiber: FiberNode) => boolean,
51
+ ) {
52
+ const stack = [root]
53
+ while (true) {
54
+ const fiber = stack.pop()
55
+ if (!fiber) {
56
+ return null
57
+ }
58
+
59
+ if (cond(fiber)) {
60
+ return fiber
61
+ }
62
+ if (fiber.child) {
63
+ stack.push(fiber.child)
64
+ }
65
+ if (fiber.sibling) {
66
+ stack.push(fiber.sibling)
67
+ }
68
+ }
69
+ }
70
+
71
+ const getAppFiberNode = (root: FiberNode): ScratchAppFiberNode => {
72
+ let cur = root.child
73
+ while (cur) {
74
+ if ('memoizedProps' in cur && 'store' in cur.memoizedProps) {
75
+ return cur
76
+ }
77
+ cur = cur.child
78
+ }
79
+ throw new Error('Could not find app fiber node.')
80
+ }
81
+ export const getScratchInternalStates = (root: ScratchRoot) => {
82
+ const rootContainer = root._reactRootContainer
83
+ const rootFiberNode = rootContainer._internalRoot.current
84
+ const appFiberNode = getAppFiberNode(rootFiberNode)
85
+
86
+ const reduxState = appFiberNode.memoizedProps.store.getState()
87
+ const vm = reduxState.scratchGui.vm
88
+
89
+ const scratchBlocksFiber = getSpecifiedFiber(rootFiberNode, (fiber) => {
90
+ if (typeof fiber.type === 'function') {
91
+ const propTypes = fiber.elementType?.propTypes
92
+ if (propTypes && 'toolboxXML' in propTypes) {
93
+ return true
94
+ }
95
+ }
96
+ return false
97
+ })
98
+ // @ts-expect-error scratchBlocksFiber existence checked
99
+ const scratchBlocks = scratchBlocksFiber.stateNode.ScratchBlocks as {
100
+ getMainWorkspace: () => {
101
+ cleanUp: () => void
102
+ }
103
+ }
104
+
105
+ return {
106
+ reduxState,
107
+ vm,
108
+ scratchBlocks,
109
+ }
110
+ }
@@ -0,0 +1,26 @@
1
+ /// <reference types="vite/client" />
2
+ // find root DOM node
3
+
4
+ import type * as sb3 from '@pnsk-lab/sb3-types'
5
+ import { findDOMAppRoot, getScratchInternalStates } from './fiber'
6
+ import type { getModeForResolutionAtIndex } from 'typescript'
7
+
8
+ const root = findDOMAppRoot()
9
+ const state = getScratchInternalStates(root)
10
+
11
+ console.log('Scratch root element:', state)
12
+
13
+ // @ts-expect-error helpers for devtools
14
+ globalThis.hk = {
15
+ root,
16
+ vm: state.vm,
17
+ getModeForResolutionAtIndex: state.reduxState,
18
+ getJSON: () => state.vm.toJSON(),
19
+ }
20
+
21
+ import.meta.hot?.on('hikkaku:project', (project: sb3.ScratchProject) => {
22
+ state.vm.loadProject(project)
23
+ setTimeout(() => {
24
+ state.scratchBlocks.getMainWorkspace().cleanUp()
25
+ }, 1000)
26
+ })
@@ -0,0 +1,7 @@
1
+ import type * as sb3 from '@pnsk-lab/sb3-types'
2
+
3
+ export interface ScratchVM {
4
+ blockListener: () => void
5
+ loadProject: (project: sb3.ScratchProject | string) => Promise<void>
6
+ toJSON: () => sb3.ScratchProject
7
+ }
@@ -0,0 +1,49 @@
1
+ import type * as sb3 from '@pnsk-lab/sb3-types'
2
+ import type {
3
+ CostumeSource,
4
+ PrimitiveAvailableOnScratch,
5
+ PrimitiveSource,
6
+ SoundSource,
7
+ } from './types'
8
+
9
+ export const fromPrimitiveSource = <T extends PrimitiveAvailableOnScratch>(
10
+ source: PrimitiveSource<T>,
11
+ ): sb3.Input => {
12
+ if (typeof source === 'number') {
13
+ return [1, [4, source]]
14
+ }
15
+ if (typeof source === 'boolean') {
16
+ return [1, [6, source ? 1 : 0]]
17
+ }
18
+ if (typeof source === 'string') {
19
+ return [1, [10, source]]
20
+ }
21
+
22
+ return [1, source.id]
23
+ }
24
+
25
+ export const fromCostumeSource = (source: CostumeSource): sb3.Input => {
26
+ if (
27
+ typeof source === 'object' &&
28
+ source !== null &&
29
+ 'type' in source &&
30
+ source.type === 'costume'
31
+ ) {
32
+ return fromPrimitiveSource(source.name)
33
+ }
34
+
35
+ return fromPrimitiveSource(source as PrimitiveSource<string>)
36
+ }
37
+
38
+ export const fromSoundSource = (source: SoundSource): sb3.Input => {
39
+ if (
40
+ typeof source === 'object' &&
41
+ source !== null &&
42
+ 'type' in source &&
43
+ source.type === 'sound'
44
+ ) {
45
+ return fromPrimitiveSource(source.name)
46
+ }
47
+
48
+ return fromPrimitiveSource(source as PrimitiveSource<string>)
49
+ }
@@ -0,0 +1,177 @@
1
+ import type * as sb3 from '@pnsk-lab/sb3-types'
2
+ import type { HikkakuBlock } from './types'
3
+
4
+ export type Handler = () => void
5
+
6
+ let id = 0
7
+ const nextId = () => (++id).toString(16)
8
+
9
+ interface RootContext {
10
+ blocks: Record<string, sb3.Block>
11
+ adder?: (id: string, block: sb3.Block) => void
12
+ usedAsValueSet: WeakSet<sb3.Block>
13
+ valueBlockSet: WeakSet<sb3.Block>
14
+ blockToId: WeakMap<sb3.Block, string>
15
+ }
16
+ let rootContext: RootContext | null = null
17
+ const getRootContext = () => {
18
+ if (!rootContext) {
19
+ throw new Error('Root context is not initialized. Call createBlocks first.')
20
+ }
21
+ return rootContext
22
+ }
23
+
24
+ export interface BlockInit {
25
+ inputs?: Record<string, sb3.Input>
26
+ fields?: Record<string, sb3.Fields>
27
+ topLevel?: boolean
28
+ mutation?: sb3.Mutation
29
+ isShadow?: boolean
30
+ isValue?: boolean
31
+ }
32
+ export const block = (opcode: string, init: BlockInit): HikkakuBlock => {
33
+ const ctx = getRootContext()
34
+ const id = nextId()
35
+ const block = {
36
+ opcode,
37
+ inputs: init.inputs ?? {},
38
+ fields: init.fields ?? {},
39
+ mutation: init.mutation,
40
+ shadow: init.isShadow ?? false,
41
+ topLevel: init.topLevel ?? false,
42
+ x: 0,
43
+ y: 0,
44
+ next: null,
45
+ parent: null,
46
+ }
47
+ ctx.blocks[id] = block
48
+ ctx.blockToId.set(block, id)
49
+ if (init.isValue) {
50
+ ctx.valueBlockSet.add(block)
51
+ }
52
+
53
+ if (init.inputs) {
54
+ for (const [_key, value] of Object.entries(init.inputs)) {
55
+ if (typeof value[1] === 'string') {
56
+ const valueBlockId = value[1]
57
+ const valueBlock = ctx.blocks[valueBlockId]
58
+ if (valueBlock) {
59
+ valueBlock.parent = id
60
+ ctx.usedAsValueSet.add(valueBlock)
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ if (ctx.adder) {
67
+ ctx.adder(id, block)
68
+ }
69
+
70
+ return {
71
+ isBlock: true,
72
+ id,
73
+ }
74
+ }
75
+
76
+ export const valueBlock = (opcode: string, init: BlockInit): HikkakuBlock => {
77
+ return block(opcode, { ...init, isValue: true })
78
+ }
79
+
80
+ const applyNextAndParent = (blocks: sb3.Block[]) => {
81
+ const ctx = getRootContext()
82
+ let lastBlock: [string, sb3.Block] | null = null
83
+ for (let i = 0; i < blocks.length; i++) {
84
+ const block = blocks[i]
85
+ if (!block || ctx.usedAsValueSet.has(block)) {
86
+ continue
87
+ }
88
+ const blockId = ctx.blockToId.get(block)
89
+ if (!blockId) {
90
+ throw new Error(`Block ${JSON.stringify(block)} does not have an ID`)
91
+ }
92
+ if (block.topLevel) {
93
+ lastBlock = null
94
+ block.parent = null
95
+ }
96
+
97
+ if (lastBlock) {
98
+ lastBlock[1].next = blockId
99
+ block.parent ??= lastBlock[0]
100
+ }
101
+
102
+ lastBlock = [blockId, block]
103
+ }
104
+ }
105
+ const catchNewBlocks = (
106
+ handler: Handler,
107
+ catched: (id: string, block: sb3.Block) => void,
108
+ ) => {
109
+ const ctx = getRootContext()
110
+ const oldAdder = ctx.adder
111
+ ctx.adder = (id: string, block: sb3.Block) => {
112
+ catched(id, block)
113
+ }
114
+ handler()
115
+ ctx.adder = oldAdder
116
+ }
117
+
118
+ export const substack = (handler: Handler) => {
119
+ const ctx = getRootContext()
120
+ const blocks: sb3.Block[] = []
121
+
122
+ catchNewBlocks(handler, (id, block) => {
123
+ ctx.blocks[id] = block
124
+ blocks.push(block)
125
+ })
126
+ applyNextAndParent(blocks)
127
+
128
+ // Pick the first executable block (skip value blocks used as inputs).
129
+ for (const block of blocks) {
130
+ if (!block || ctx.usedAsValueSet.has(block)) {
131
+ continue
132
+ }
133
+ const blockId = ctx.blockToId.get(block)
134
+ if (blockId) {
135
+ return blockId
136
+ }
137
+ }
138
+
139
+ return null
140
+ }
141
+
142
+ export const createBlocks = (handler: Handler) => {
143
+ const blocks: Record<string, sb3.Block> = {}
144
+ rootContext = {
145
+ blocks: blocks,
146
+ usedAsValueSet: new WeakSet(),
147
+ valueBlockSet: new WeakSet(),
148
+ blockToId: new WeakMap(),
149
+ }
150
+
151
+ const blocksForAddingNext: sb3.Block[] = []
152
+ catchNewBlocks(handler, (id, block) => {
153
+ blocks[id] = block
154
+ blocksForAddingNext.push(block)
155
+ })
156
+ applyNextAndParent(blocksForAddingNext)
157
+
158
+ const unconnectedValueBlocks: Array<{ id: string; opcode: string }> = []
159
+ for (const [blockId, block] of Object.entries(blocks)) {
160
+ if (
161
+ rootContext.valueBlockSet.has(block) &&
162
+ !rootContext.usedAsValueSet.has(block)
163
+ ) {
164
+ unconnectedValueBlocks.push({ id: blockId, opcode: block.opcode })
165
+ }
166
+ }
167
+ if (unconnectedValueBlocks.length > 0) {
168
+ const formatted = unconnectedValueBlocks
169
+ .map(({ id, opcode }) => `${opcode} (${id})`)
170
+ .join(', ')
171
+ rootContext = null
172
+ throw new Error(`Unconnected value block(s): ${formatted}`)
173
+ }
174
+
175
+ rootContext = null
176
+ return blocks
177
+ }
@@ -0,0 +1,6 @@
1
+ export class Target {
2
+ onFlagClicked() {}
3
+ }
4
+ export class Compiler {
5
+ createTarget(_name: string) {}
6
+ }
@@ -0,0 +1,138 @@
1
+ import type * as sb3 from '@pnsk-lab/sb3-types'
2
+ import { createBlocks } from './composer'
3
+ import type {
4
+ CostumeReference,
5
+ ListReference,
6
+ SoundReference,
7
+ VariableReference,
8
+ } from './types'
9
+
10
+ let nextAssetId = 0
11
+ const createAssetId = () => `asset-${(++nextAssetId).toString(16)}`
12
+
13
+ export class Target<IsStage extends boolean = boolean> {
14
+ readonly isStage: IsStage
15
+ readonly name: IsStage extends true ? 'Stage' : string
16
+ currentCostume = 0
17
+
18
+ #blocks: Record<string, sb3.Block> = {}
19
+ #variables: Record<string, sb3.ScalarVariable> = {}
20
+ #lists: Record<string, sb3.List> = {}
21
+ #costumes: sb3.Costume[] = []
22
+ #sounds: sb3.Sound[] = []
23
+ constructor(isStage: IsStage, name: IsStage extends true ? 'Stage' : string) {
24
+ this.isStage = isStage
25
+ this.name = name
26
+ }
27
+
28
+ run(handler: (target: Target<IsStage>) => void): void {
29
+ const blocks = createBlocks(() => {
30
+ handler(this)
31
+ })
32
+ this.#blocks = {
33
+ ...this.#blocks,
34
+ ...blocks,
35
+ }
36
+ }
37
+
38
+ createVariable(
39
+ name: string,
40
+ defaultValue: sb3.ScalarVal = 0,
41
+ isCloudVariable?: boolean,
42
+ ): VariableReference {
43
+ const id = createAssetId()
44
+ this.#variables[id] = isCloudVariable
45
+ ? [name, defaultValue, true]
46
+ : [name, defaultValue]
47
+ return {
48
+ id,
49
+ name,
50
+ type: 'variable',
51
+ }
52
+ }
53
+
54
+ createList(name: string, defaultValue: sb3.ScalarVal[] = []): ListReference {
55
+ const id = createAssetId()
56
+ this.#lists[id] = [name, defaultValue]
57
+ return {
58
+ id,
59
+ name,
60
+ type: 'list',
61
+ }
62
+ }
63
+
64
+ addCostume(costume: sb3.Costume): CostumeReference {
65
+ this.#costumes.push(costume)
66
+ return {
67
+ name: costume.name,
68
+ type: 'costume' as const,
69
+ }
70
+ }
71
+
72
+ addSound(sound: sb3.Sound): SoundReference {
73
+ this.#sounds.push(sound)
74
+ return {
75
+ name: sound.name,
76
+ type: 'sound' as const,
77
+ }
78
+ }
79
+
80
+ toScratch(): IsStage extends true ? sb3.Stage : sb3.Sprite {
81
+ const costumes =
82
+ this.#costumes.length > 0
83
+ ? this.#costumes
84
+ : [
85
+ {
86
+ name: this.name,
87
+ assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
88
+ dataFormat: 'svg' as const,
89
+ },
90
+ ]
91
+ const target: sb3.Target = {
92
+ blocks: this.#blocks,
93
+ broadcasts: {},
94
+ variables: this.#variables,
95
+ lists: this.#lists,
96
+ sounds: this.#sounds,
97
+ currentCostume: this.currentCostume,
98
+ costumes,
99
+ }
100
+ if (this.isStage) {
101
+ return {
102
+ ...target,
103
+ isStage: true,
104
+ name: 'Stage',
105
+ } satisfies sb3.Stage as IsStage extends true ? sb3.Stage : sb3.Sprite
106
+ }
107
+ return {
108
+ ...target,
109
+ isStage: false,
110
+ name: this.name,
111
+ visible: true,
112
+ } satisfies sb3.Sprite as IsStage extends true ? sb3.Stage : sb3.Sprite
113
+ }
114
+ }
115
+
116
+ export class Project {
117
+ readonly stage: Target<true>
118
+ #targets: Target[] = []
119
+ constructor() {
120
+ const target = new Target(true, 'Stage')
121
+ this.stage = target
122
+ this.#targets.push(target)
123
+ }
124
+ createSprite(name: string): Target<false> {
125
+ const sprite = new Target(false, name)
126
+ this.#targets.push(sprite)
127
+ return sprite
128
+ }
129
+ toScratch(): sb3.ScratchProject {
130
+ return {
131
+ targets: this.#targets.map((target) => target.toScratch()),
132
+ meta: {
133
+ semver: '3.0.0',
134
+ agent: `Hikkaku | ${globalThis.navigator ? navigator.userAgent : 'unknown'}`,
135
+ },
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,37 @@
1
+ export type PrimitiveAvailableOnScratch = number | boolean | string
2
+
3
+ export type PrimitiveSource<T extends PrimitiveAvailableOnScratch> =
4
+ | T
5
+ | HikkakuBlock
6
+
7
+ export interface VariableBase {
8
+ id: string
9
+ name: string
10
+ }
11
+
12
+ export interface VariableReference extends VariableBase {
13
+ type: 'variable'
14
+ }
15
+
16
+ export interface ListReference extends VariableBase {
17
+ type: 'list'
18
+ }
19
+
20
+ export interface CostumeReference {
21
+ name: string
22
+ type: 'costume'
23
+ }
24
+
25
+ export type CostumeSource = PrimitiveSource<string> | CostumeReference
26
+
27
+ export interface SoundReference {
28
+ name: string
29
+ type: 'sound'
30
+ }
31
+
32
+ export type SoundSource = PrimitiveSource<string> | SoundReference
33
+
34
+ export interface HikkakuBlock {
35
+ isBlock: true
36
+ id: string
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './compiler/block-helper'
2
+ export * from './compiler/composer'
3
+ export * from './compiler/project'
4
+ export * from './compiler/types'
5
+ export * from './utils/assets'
@@ -0,0 +1,4 @@
1
+ export const ASSET_BLANK_SVG = 'cd21514d0531fdffb22204e0ec5ed84a'
2
+
3
+ export const ASSET_CAT1 = 'bcf454acf82e4504149f7ffe07081dbc'
4
+ export const ASSET_CAT2 = '0fb9be3e8397c983338cb71dc84d0b25'
@@ -0,0 +1,46 @@
1
+ import {
2
+ DevEnvironment,
3
+ type HotChannel,
4
+ type ResolvedConfig,
5
+ type WebSocketServer,
6
+ } from 'vite'
7
+ import {
8
+ ESModulesEvaluator,
9
+ ModuleRunner,
10
+ type ModuleRunnerTransport,
11
+ } from 'vite/module-runner'
12
+
13
+ export function createHikkakuEnvironment(
14
+ name: string,
15
+ config: ResolvedConfig,
16
+ _context: {
17
+ ws: WebSocketServer
18
+ },
19
+ ): DevEnvironment {
20
+ const transport: ModuleRunnerTransport = {
21
+ send: (data) => {
22
+ console.log('sent', data)
23
+ },
24
+ connect(_handlers) {},
25
+ }
26
+
27
+ const env = new (class extends DevEnvironment {
28
+ runner: ModuleRunner
29
+ constructor(
30
+ name: string,
31
+ config: ResolvedConfig,
32
+ context: { hot: boolean; transport?: HotChannel },
33
+ ) {
34
+ super(name, config, context)
35
+ this.runner = new ModuleRunner(
36
+ { transport: this.hot },
37
+ new ESModulesEvaluator(),
38
+ )
39
+ }
40
+ })(name, config, {
41
+ transport,
42
+ hot: true,
43
+ })
44
+
45
+ return env
46
+ }