Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Stack-chan Blockly blocks and JavaScript code generation.
*
* The generated program is a Stack-chan MOD module: the workspace code becomes
* the body of `export async function onContextCreated(robot)`. Event blocks
* (start / button / interval) register handlers on the robot context.
*/
import { t } from '../i18n.mjs'
export const VISUAL_RUNTIME_RESERVED_WORDS = Object.freeze([
'robot',
'Timer',
'Emotion',
'wait',
'randomBetween',
'hexToRgb',
'trace',
'createVisualLoopGuard',
'visualLoopGuard',
'reportVisualError',
'createVisualRuntime',
'runtime',
'onButton',
'onImu',
'onHeadTouch',
'SINGING_MORA_TO_KOE',
'STACKCHAN_VOICE_MAX_KOE_LENGTH',
'katakanaToHiragana',
'singingMoraToKoe',
'songDurationMilliseconds',
'singingScoreToKoe',
'singScore',
'event',
'_StackchanVisualShapeFace',
])
const HELPER_HEX_TO_RGB = `function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}`
const HELPER_VISUAL_RUNTIME = `function createVisualLoopGuard() {
let visualLoopBudget = 10000
return function visualLoopGuard(blockId) {
if (--visualLoopBudget <= 0) {
const error = new Error('ループの実行上限を超えました')
error.visualBlockId = blockId
throw error
}
}
}
function reportVisualError(errorCode, blockId, error, context = '') {
trace('#stackchan ' + JSON.stringify({
schema_version: 1,
component: 'visual-programming',
event: 'error',
error_code: errorCode,
block_id: error?.visualBlockId || blockId,
message: (context ? context + ': ' : '') + String(error),
}) + '\\n')
}
function createVisualRuntime(robot) {
robot.__visualProgram?.dispose?.()
const disposers = []
const runtime = {
add(disposer) {
if (typeof disposer === 'function') disposers.push(disposer)
return disposer
},
addTimer(timer) {
runtime.add(() => Timer.clear(timer))
return timer
},
dispose() {
while (disposers.length) {
try { disposers.pop()() } catch (error) { reportVisualError('VP_LIFECYCLE_CLEANUP', '', error) }
}
},
}
robot.__visualProgram = runtime
return runtime
}`
// Event dispatch helpers. A driver exposes a single \`onEvent\`, so multiple
// blocks for the same driver (e.g. button press + release, or several IMU
// motions) must share one handler. Each helper stores per-key handlers on the
// driver object and installs one dispatching \`onEvent\` the first time.
const HELPER_ON_BUTTON = `function onButton(robot, name, edge, handler) {
const button = robot.input.button?.[name]
if (!button) return undefined
const handlers = (button.__handlers ??= {})
const callbacks = (handlers[edge] ??= new Set())
callbacks.add(handler)
if (!button.__visualWired) {
button.__visualWired = true
button.__visualPrevious = button.onEvent
button.onEvent = (event) => {
for (const callback of [...(button.__handlers?.[event.pressed ? 'press' : 'release'] ?? [])]) callback(event)
button.__visualPrevious?.(event)
}
}
return () => {
callbacks.delete(handler)
if (callbacks.size === 0) delete handlers[edge]
if (Object.keys(button.__handlers ?? {}).length === 0) {
button.onEvent = button.__visualPrevious
delete button.__visualPrevious
button.__visualWired = false
}
}
}`
const HELPER_ON_IMU = `function onImu(robot, motion, handler) {
const imu = robot.input.imu
if (!imu) return undefined
const handlers = (imu.__handlers ??= {})
const callbacks = (handlers[motion] ??= new Set())
callbacks.add(handler)
if (!imu.__visualWired) {
imu.__visualWired = true
imu.__visualPrevious = imu.onEvent
imu.onEvent = (event) => {
for (const callback of [...(imu.__handlers?.[event.motion] ?? [])]) callback(event)
imu.__visualPrevious?.(event)
}
imu.start?.()
}
return () => {
callbacks.delete(handler)
if (callbacks.size === 0) delete handlers[motion]
if (Object.keys(imu.__handlers ?? {}).length === 0) {
imu.onEvent = imu.__visualPrevious
delete imu.__visualPrevious
imu.__visualWired = false
}
}
}`
const HELPER_ON_HEAD_TOUCH = `function onHeadTouch(robot, gesture, handler) {
const panel = robot.input.touchPanel
if (!panel) return undefined
const pettingWindowMs = 1500
const handlers = (panel.__handlers ??= {})
const callbacks = (handlers[gesture] ??= new Set())
callbacks.add(handler)
if (!panel.__visualWired) {
panel.__visualWired = true
panel.__visualPrevious = panel.onEvent
panel.onEvent = (event) => {
for (const callback of [...(panel.__handlers?.[event.gesture] ?? [])]) callback(event)
if (event.gesture === 'forwardSwipe') panel.__visualLastForwardSwipeTicks = event.ticks
if (event.gesture === 'backwardSwipe') panel.__visualLastBackwardSwipeTicks = event.ticks
if (
(event.gesture === 'forwardSwipe' || event.gesture === 'backwardSwipe') &&
Number.isFinite(panel.__visualLastForwardSwipeTicks) &&
Number.isFinite(panel.__visualLastBackwardSwipeTicks) &&
Math.abs(panel.__visualLastForwardSwipeTicks - panel.__visualLastBackwardSwipeTicks) <= pettingWindowMs
) {
const pettingEvent = { ...event, gesture: 'petting' }
delete panel.__visualLastForwardSwipeTicks
delete panel.__visualLastBackwardSwipeTicks
for (const callback of [...(panel.__handlers?.petting ?? [])]) callback(pettingEvent)
}
panel.__visualPrevious?.(event)
}
}
return () => {
callbacks.delete(handler)
if (callbacks.size === 0) delete handlers[gesture]
if (Object.keys(panel.__handlers ?? {}).length === 0) {
panel.onEvent = panel.__visualPrevious
delete panel.__visualPrevious
delete panel.__visualLastForwardSwipeTicks
delete panel.__visualLastBackwardSwipeTicks
panel.__visualWired = false
}
}
}`
/**
* Wrap generated workspace code into a complete mod.js source.
* Imports and helpers are included only when the body references them.
*/
export function assembleModSource(body) {
const imports = []
if (/\bTimer\s*\./.test(body)) imports.push("import Timer from 'timer'")
const utilNames = ['randomBetween', 'wait'].filter((name) => new RegExp(`\\b${name}\\s*\\(`).test(body))
if (utilNames.length) imports.push(`import { ${utilNames.join(', ')} } from 'stackchan-util'`)
if (/\bEmotion\s*\./.test(body)) imports.push("import { Emotion } from 'face-state'")
const helpers = [HELPER_VISUAL_RUNTIME]
if (/\bhexToRgb\s*\(/.test(body)) helpers.push(HELPER_HEX_TO_RGB)
if (/\bonButton\s*\(/.test(body)) helpers.push(HELPER_ON_BUTTON)
if (/\bonImu\s*\(/.test(body)) helpers.push(HELPER_ON_IMU)
if (/\bonHeadTouch\s*\(/.test(body)) helpers.push(HELPER_ON_HEAD_TOUCH)
if (/\bsingScore\s*\(/.test(body)) helpers.push(HELPER_SING_SCORE)
const indentedBody = body
.split('\n')
.map((line) => (line.length ? ` ${line}` : line))
.join('\n')
.replace(/\s+$/, '')
const sections = []
if (imports.length) sections.push(imports.join('\n'))
if (helpers.length) sections.push(helpers.join('\n\n'))
sections.push(
`export async function onContextCreated(robot) {\n const runtime = createVisualRuntime(robot)\n const visualLoopGuard = createVisualLoopGuard()\n${indentedBody}\n}`
)
return `${sections.join('\n\n')}\n`
}
export const EMOTION_OPTIONS = [
['ふつう', 'NEUTRAL'],
['うれしい', 'HAPPY'],
['おこった', 'ANGRY'],
['かなしい', 'SAD'],
['ねむい', 'SLEEPY'],
['こまった', 'DOUBTFUL'],
['さむい', 'COLD'],
['あつい', 'HOT'],
]
export const COLOR_OPTIONS = [
['白', '#ffffff'],
['黒', '#202020'],
['赤', '#ff4040'],
['オレンジ', '#ff9900'],
['黄', '#ffe040'],
['緑', '#40c040'],
['水色', '#30e0ff'],
['青', '#3060ff'],
['紫', '#9040ff'],
['ピンク', '#ff70d8'],
]
// Musical notes (C major, 2 octaves + top C) as dropdown label -> frequency Hz.
// robot.audio.tone() takes Hz, so the value is the frequency directly.
export const NOTE_OPTIONS = [
['ド4', '262'],
['レ4', '294'],
['ミ4', '330'],
['ファ4', '349'],
['ソ4', '392'],
['ラ4', '440'],
['シ4', '494'],
['ド5', '523'],
['レ5', '587'],
['ミ5', '659'],
['ファ5', '698'],
['ソ5', '784'],
['ラ5', '880'],
['シ5', '988'],
['ド6', '1047'],
]
// stackchan-voice singing notes use equal-tempered note names rather than Hz.
// Keep the beginner-facing labels aligned with the tone block while exposing
// chromatic pitches for melodies that leave C major.
export const SINGING_NOTE_OPTIONS = [
['ド4', 'C4'],
['ド♯4', 'C+4'],
['レ4', 'D4'],
['レ♯4', 'D+4'],
['ミ4', 'E4'],
['ファ4', 'F4'],
['ファ♯4', 'F+4'],
['ソ4', 'G4'],
['ソ♯4', 'G+4'],
['ラ4', 'A4'],
['ラ♯4', 'A+4'],
['シ4', 'B4'],
['ド5', 'C5'],
['ド♯5', 'C+5'],
['レ5', 'D5'],
['レ♯5', 'D+5'],
['ミ5', 'E5'],
['ファ5', 'F5'],
['ファ♯5', 'F+5'],
['ソ5', 'G5'],
['ソ♯5', 'G+5'],
['ラ5', 'A5'],
['ラ♯5', 'A+5'],
['シ5', 'B5'],
['ド6', 'C6'],
]
const SINGING_MORA_TO_KOE = Object.freeze({
あ: 'a',
い: 'i',
う: 'u',
え: 'e',
お: 'o',
ぁ: 'a',
ぃ: 'i',
ぅ: 'u',
ぇ: 'e',
ぉ: 'o',
か: 'ka',
き: 'ki',
く: 'ku',
け: 'ke',
こ: 'ko',
が: 'ga',
ぎ: 'gi',
ぐ: 'gu',
げ: 'ge',
ご: 'go',
さ: 'sa',
し: 'shi',
す: 'su',
せ: 'se',
そ: 'so',
ざ: 'za',
じ: 'ji',
ず: 'zu',
ぜ: 'ze',
ぞ: 'zo',
た: 'ta',
ち: 'chi',
つ: 'tsu',
て: 'te',
と: 'to',
だ: 'da',
ぢ: 'ji',
づ: 'zu',
で: 'de',
ど: 'do',
な: 'na',
に: 'ni',
ぬ: 'nu',
ね: 'ne',
の: 'no',
は: 'ha',
ひ: 'hi',
ふ: 'fu',
へ: 'he',
ほ: 'ho',
ば: 'ba',
び: 'bi',
ぶ: 'bu',
べ: 'be',
ぼ: 'bo',
ぱ: 'pa',
ぴ: 'pi',
ぷ: 'pu',
ぺ: 'pe',
ぽ: 'po',
ま: 'ma',
み: 'mi',
む: 'mu',
め: 'me',
も: 'mo',
や: 'ya',
ゆ: 'yu',
よ: 'yo',
ら: 'ra',
り: 'ri',
る: 'ru',
れ: 're',
ろ: 'ro',
わ: 'wa',
ゐ: 'i',
ゑ: 'e',
を: 'o',
ん: 'n',
きゃ: 'kya',
きゅ: 'kyu',
きょ: 'kyo',
ぎゃ: 'gya',
ぎゅ: 'gyu',
ぎょ: 'gyo',
しゃ: 'sha',
しゅ: 'shu',
しょ: 'sho',
じゃ: 'ja',
じゅ: 'ju',
じょ: 'jo',
ちゃ: 'cha',
ちゅ: 'chu',
ちょ: 'cho',
にゃ: 'nya',
にゅ: 'nyu',
にょ: 'nyo',
ひゃ: 'hya',
ひゅ: 'hyu',
ひょ: 'hyo',
びゃ: 'bya',
びゅ: 'byu',
びょ: 'byo',
ぴゃ: 'pya',
ぴゅ: 'pyu',
ぴょ: 'pyo',
みゃ: 'mya',
みゅ: 'myu',
みょ: 'myo',
りゃ: 'rya',
りゅ: 'ryu',
りょ: 'ryo',
ふぁ: 'fa',
ふぃ: 'fi',
ふぇ: 'fe',
ふぉ: 'fo',
てぃ: 'ti',
とぅ: 'tu',
でぃ: 'di',
どぅ: 'du',
しぇ: 'she',
ちぇ: 'che',
じぇ: 'je',
うぃ: 'wi',
うぇ: 'we',
うぉ: 'o',
ゔ: 'vu',
ゔぁ: 'va',
ゔぃ: 'vi',
ゔぇ: 've',
ゔぉ: 'vo',
きぇ: 'kye',
ぎぇ: 'gye',
いぇ: 'ye',
ひぇ: 'hye',
びぇ: 'bye',
ぴぇ: 'pye',
みぇ: 'mye',
にぇ: 'nye',
りぇ: 'rye',
てゅ: 'tyu',
でゅ: 'dyu',
でゃ: 'dya',
でょ: 'dyo',
てゃ: 'tya',
てょ: 'tyo',
つぁ: 'tsa',
つぃ: 'tsi',
つぇ: 'tse',
つぉ: 'tso',
すぃ: 'si',
ずぃ: 'zi',
ふゅ: 'fyu',
ゔゅ: 'vyu',
ゕ: 'ka',
ゖ: 'ke',
ゎ: 'wa',
})
const STACKCHAN_VOICE_MAX_KOE_LENGTH = 2047
function katakanaToHiragana(value) {
return [...value]
.map((character) => {
const code = character.charCodeAt(0)
return code >= 0x30a1 && code <= 0x30f6 ? String.fromCharCode(code - 0x60) : character
})
.join('')
}
/** Convert exactly one kana mora from a singing block into raw koe notation. */
export function singingMoraToKoe(value, previousMora = '') {
const input = String(value).trim()
const mora = katakanaToHiragana(input)
if (mora === 'ー') {
const previousVowel = /[aiueo]$/.exec(previousMora)?.[0]
if (previousVowel) return previousVowel
throw new RangeError('長音「ー」の前には母音を持つ歌詞が必要です')
}
const koe = SINGING_MORA_TO_KOE[mora]
if (koe) return koe
throw new RangeError(`歌詞「${input || '(空)'}」は、かな1モーラで入力してください`)
}
const BLOCK_STYLE = {
event: 290,
face: 20,
speech: 160,
motion: 230,
light: 60,
ui: 200,
util: 330,
}
const BLOCK_DEFINITIONS = [
{
type: 'stackchan_on_start',
message0: 'スタートしたとき %1 %2',
args0: [{ type: 'input_dummy' }, { type: 'input_statement', name: 'DO' }],
colour: BLOCK_STYLE.event,
tooltip: 'MODが起動したときに実行します',
},
{
type: 'stackchan_on_button',
message0: 'ボタン %1 が %2 とき %3 %4',
args0: [
{
type: 'field_dropdown',
name: 'BUTTON',
options: [
['A', 'a'],
['B', 'b'],
['C', 'c'],
],
},
{
type: 'field_dropdown',
name: 'EDGE',
options: [
['押された', 'press'],
['離された', 'release'],
],
},
{ type: 'input_dummy' },
{ type: 'input_statement', name: 'DO' },
],
colour: BLOCK_STYLE.event,
tooltip: '本体のボタンが押された/離されたときに実行します',
},
{
type: 'stackchan_on_imu',
message0: '本体が %1 とき %2 %3',
args0: [
{
type: 'field_dropdown',
name: 'MOTION',
options: [
['ゆさぶられた', 'shake'],
['前に倒れた', 'fallenForward'],
['後ろに倒れた', 'fallenBackward'],
['左に倒れた', 'fallenLeft'],
['右に倒れた', 'fallenRight'],
['さかさまになった', 'upsideDown'],
],
},
{ type: 'input_dummy' },
{ type: 'input_statement', name: 'DO' },
],
colour: BLOCK_STYLE.event,
tooltip: '本体を動かしたとき(加速度センサー)に実行します',
},
{
type: 'stackchan_on_head_touch',
message0: '頭部タッチセンサが %1 とき %2 %3',
args0: [
{
type: 'field_dropdown',
name: 'GESTURE',
options: [
['タッチされた', 'press'],
['はなされた', 'release'],
['前方へスワイプされた', 'forwardSwipe'],
['後方へスワイプされた', 'backwardSwipe'],
['なでられた', 'petting'],
],
},
{ type: 'input_dummy' },
{ type: 'input_statement', name: 'DO' },
],
colour: BLOCK_STYLE.event,
tooltip: '頭上の静電容量式タッチセンサを操作したときに実行します',
},
{
type: 'stackchan_on_drawer_button',
message0: 'ドロワーに %1 ボタンをつくって 押されたら %2 %3',
args0: [
{ type: 'field_input', name: 'LABEL', text: 'ボタン' },
{ type: 'input_dummy' },
{ type: 'input_statement', name: 'DO' },
],
colour: BLOCK_STYLE.event,
tooltip: '画面のドロワー(引き出しメニュー)にボタンを追加し、押されたときに実行します',
},
{
type: 'stackchan_every',
message0: '%1 秒ごとに %2 %3',
args0: [
{ type: 'field_number', name: 'SECONDS', value: 5, min: 0.1, precision: 0.1 },
{ type: 'input_dummy' },
{ type: 'input_statement', name: 'DO' },
],
colour: BLOCK_STYLE.event,
tooltip: '一定時間ごとにくり返し実行します',
},
{
type: 'stackchan_set_emotion',
message0: '表情を %1 にする',
args0: [{ type: 'field_dropdown', name: 'EMOTION', options: EMOTION_OPTIONS }],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.face,
tooltip: 'スタックチャンの表情を変えます',
},
{
type: 'stackchan_set_color',
message0: '顔の %1 の色を %2 にする',
args0: [
{
type: 'field_dropdown',
name: 'KEY',
options: [
['線', 'primary'],
['背景', 'secondary'],
],
},
{ type: 'field_dropdown', name: 'COLOR', options: COLOR_OPTIONS },
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.face,
tooltip: '顔の色を変えます',
},
{
type: 'stackchan_set_mouth',
message0: '口を %1 ひらく (0〜1)',
args0: [{ type: 'field_number', name: 'VALUE', value: 0.5, min: 0, max: 1, precision: 0.1 }],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.face,
tooltip: '口のひらき具合を変えます',
},
{
type: 'stackchan_say',
message0: '%1 としゃべる',
args0: [{ type: 'input_value', name: 'TEXT' }],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.speech,
tooltip: '音声合成でしゃべります(しゃべり終わるまで待ちます)',
},
{
type: 'stackchan_sing_score',
message0: 'テンポ %1 で %2 を歌う',
args0: [
{ type: 'field_number', name: 'BPM', value: 120, min: 20, max: 300, precision: 1 },
{ type: 'input_value', name: 'SCORE', check: 'Array' },
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.speech,
tooltip: '音階、拍、歌詞のトリプルを並べたリストをstackchan-voiceで歌います',
},
{
type: 'stackchan_song_note_tuple',
message0: '音符 %1 を %2 拍で「%3」と歌う',
args0: [
{ type: 'field_dropdown', name: 'NOTE', options: SINGING_NOTE_OPTIONS },
{ type: 'field_number', name: 'BEATS', value: 1, min: 0.125, max: 16, precision: 0.125 },
{ type: 'field_input', name: 'LYRIC', text: 'き' },
],
output: 'Array',
colour: BLOCK_STYLE.speech,
tooltip: '歌唱リストへ入れる[音階、拍、かな1モーラ]のトリプルです',
},
{
type: 'stackchan_song_rest_tuple',
message0: '%1 拍の休符',
args0: [{ type: 'field_number', name: 'BEATS', value: 1, min: 0.125, max: 16, precision: 0.125 }],
output: 'Array',
colour: BLOCK_STYLE.speech,
tooltip: '歌唱リストへ入れる[R、拍、空の歌詞]のトリプルです',
},
{
type: 'stackchan_sing',
message0: 'テンポ %1 で歌う %2 %3',
args0: [
{ type: 'field_number', name: 'BPM', value: 120, min: 20, max: 300, precision: 1 },
{ type: 'input_dummy' },
{ type: 'input_statement', name: 'SCORE', check: 'StackchanSongEvent' },
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.speech,
tooltip: '音符ごとの歌詞をstackchan-voiceで歌います',
},
{
type: 'stackchan_song_note',
message0: '音符 %1 を %2 拍で「%3」と歌う',
args0: [
{ type: 'field_dropdown', name: 'NOTE', options: SINGING_NOTE_OPTIONS },
{ type: 'field_number', name: 'BEATS', value: 1, min: 0.125, max: 16, precision: 0.125 },
{ type: 'field_input', name: 'LYRIC', text: 'き' },
],
previousStatement: 'StackchanSongEvent',
nextStatement: 'StackchanSongEvent',
colour: BLOCK_STYLE.speech,
tooltip: 'かな1モーラを指定した音高と長さで歌います(例: き、きゃ、ん、ー)',
},
{
type: 'stackchan_song_rest',
message0: '%1 拍 休む',
args0: [{ type: 'field_number', name: 'BEATS', value: 1, min: 0.125, max: 16, precision: 0.125 }],
previousStatement: 'StackchanSongEvent',
nextStatement: 'StackchanSongEvent',
colour: BLOCK_STYLE.speech,
tooltip: '指定した拍数だけ休符を入れます',
},
{
type: 'stackchan_show_balloon',
message0: 'ふきだしで %1 を表示',
args0: [{ type: 'input_value', name: 'TEXT' }],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.speech,
tooltip: 'ふきだしにテキストを表示します',
},
{
type: 'stackchan_hide_balloon',
message0: 'ふきだしを消す',
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.speech,
tooltip: 'ふきだしを消します',
},
{
type: 'stackchan_tone',
message0: '音 %1 を %2 ミリ秒ならす',
args0: [
{ type: 'field_dropdown', name: 'NOTE', options: NOTE_OPTIONS },
{ type: 'field_number', name: 'DURATION', value: 300, min: 1 },
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.speech,
tooltip: 'ドレミの音をならします',
},
{
type: 'stackchan_look_at',
message0: '前 %1 左 %2 上 %3 の方を見る',
args0: [
{ type: 'field_number', name: 'X', value: 1 },
{ type: 'field_number', name: 'Y', value: 0 },
{ type: 'field_number', name: 'Z', value: 0 },
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.motion,
tooltip: '指定した方向(メートル)に顔と視線を向けます',
},
{
type: 'stackchan_look_away',
message0: 'よそ見をやめる',
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.motion,
tooltip: '視線追従をやめて正面にもどります',
},
{
type: 'stackchan_set_torque',
message0: 'サーボの力を %1',
args0: [
{
type: 'field_dropdown',
name: 'TORQUE',
options: [
['入れる', 'true'],
['抜く', 'false'],
],
},
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.motion,
tooltip: 'サーボモーターのトルクを切り替えます',
},
{
type: 'stackchan_set_pose',
message0: '頭を 上下 %1 度 左右 %2 度 に向ける %3 秒かけて',
args0: [
{ type: 'field_number', name: 'PITCH', value: 0, min: -60, max: 60 },
{ type: 'field_number', name: 'YAW', value: 0, min: -60, max: 60 },
{ type: 'field_number', name: 'TIME', value: 0.5, min: 0, precision: 0.1 },
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.motion,
tooltip: '頭の向きを指定した角度に動かします(動き終わるまで待ちます)',
},
{
type: 'stackchan_light_on',
message0: 'LED %1 を %2 で点ける',
args0: [
{ type: 'field_input', name: 'NAME', text: 'a' },
{ type: 'field_dropdown', name: 'COLOR', options: COLOR_OPTIONS },
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.light,
tooltip: 'LEDを点灯します(LEDがある機種のみ)',
},
{
type: 'stackchan_light_off',
message0: 'LED %1 を消す',
args0: [{ type: 'field_input', name: 'NAME', text: 'a' }],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.light,
tooltip: 'LEDを消灯します',
},
{
type: 'stackchan_light_rainbow',
message0: 'LED %1 を虹色に光らせる',
args0: [{ type: 'field_input', name: 'NAME', text: 'a' }],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.light,
tooltip: 'LEDをレインボー点灯します',
},
{
type: 'stackchan_light_blink',
message0: 'LED %1 を %2 で %3 ミリ秒ごとに点滅',
args0: [
{ type: 'field_input', name: 'NAME', text: 'a' },
{ type: 'field_dropdown', name: 'COLOR', options: COLOR_OPTIONS },
{ type: 'field_number', name: 'INTERVAL', value: 250, min: 1 },
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.light,
tooltip: 'LEDを点滅させます(LEDがある機種のみ)',
},
{
type: 'stackchan_drawer_control',
message0: 'ドロワーを %1',
args0: [
{
type: 'field_dropdown',
name: 'ACTION',
options: [
['開く', 'openDrawer'],
['閉じる', 'closeDrawer'],
['切り替える', 'toggleDrawer'],
],
},
],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.ui,
tooltip: '画面のドロワー(引き出しメニュー)を開閉します',
},
{
type: 'stackchan_show_face',
message0: 'かおにもどす',
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.ui,
tooltip: 'メイン画面をかおの表示にもどします',
},
{
type: 'stackchan_wait',
message0: '%1 ミリ秒まつ',
args0: [{ type: 'field_number', name: 'DURATION', value: 1000, min: 0 }],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.util,
tooltip: '指定した時間だけ待ちます',
},
{
type: 'stackchan_trace',
message0: 'ログに %1 を出す',
args0: [{ type: 'input_value', name: 'TEXT' }],
previousStatement: null,
nextStatement: null,
colour: BLOCK_STYLE.util,
tooltip: 'デバッグログに出力します',
},
{
type: 'stackchan_random',
message0: '%1 から %2 のランダムな数',
args0: [
{ type: 'field_number', name: 'MIN', value: 0 },
{ type: 'field_number', name: 'MAX', value: 1 },
],
output: 'Number',
colour: BLOCK_STYLE.util,
tooltip: '範囲内のランダムな数を返します',
},
]
function localizeBlocklyData(value, key = '') {
if (Array.isArray(value)) {
if (value.length === 2 && typeof value[0] === 'string' && typeof value[1] === 'string') {
return [t(value[0]), value[1]]
}
return value.map((item) => localizeBlocklyData(item))
}
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.entries(value).map(([childKey, childValue]) => {
if (typeof childValue === 'string' && ['message0', 'tooltip', 'text', 'name'].includes(childKey)) {
return [childKey, t(childValue)]
}
return [childKey, localizeBlocklyData(childValue, childKey)]
})
)
}
// Escape a value for embedding inside a single-quoted JS string literal. Used
// for `field_input` values (e.g. the LED NAME) so a name containing ' or \
// cannot break the generated source.
export function escapeSingleQuoted(value) {
return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
}
function songDurationMilliseconds(beatsValue, bpm) {
const beats = Number(beatsValue)
if (!Number.isFinite(beats) || beats <= 0) throw new RangeError('音符と休符の拍数は0より大きくしてください')
const duration = Math.round((60_000 * beats) / bpm)
if (duration < 20 || duration > 8000) {
throw new RangeError(`テンポ${bpm}では${beats}拍が${duration}ミリ秒になります(20〜8000ミリ秒にしてください)`)
}
return duration
}
/** Convert a score of [note, beats, lyric] triples into raw stackchan-voice koe notation. */
export function singingScoreToKoe(bpmValue, scoreValue) {
const bpm = Number(bpmValue)
if (!Number.isFinite(bpm) || bpm < 20 || bpm > 300) {
throw new RangeError('歌うテンポは20〜300 BPMにしてください')
}
if (!Array.isArray(scoreValue)) throw new TypeError('歌唱データは音符と休符を並べたリストにしてください')
if (scoreValue.length === 0) throw new RangeError('歌唱リストに音符または休符を追加してください')
if (scoreValue.length > 256) throw new RangeError('1つの歌唱リストには音符と休符を256個まで置けます')
let koe = ''
let previousMora = ''
for (let index = 0; index < scoreValue.length; index += 1) {
const event = scoreValue[index]
if (!Array.isArray(event) || event.length !== 3) {
throw new TypeError(`${index + 1}番目の歌唱データは[音階、拍、歌詞]の3項目にしてください`)
}
const note = String(event[0] ?? '')
.trim()
.toUpperCase()
const duration = songDurationMilliseconds(event[1], bpm)
const lyric = String(event[2] ?? '').trim()
if (note === 'R') {
if (lyric) throw new RangeError(`${index + 1}番目の休符には歌詞を指定できません`)
koe += `#R,${duration}`
} else {
if (!/^[A-G](?:[+-])?[0-8]$/.test(note)) {
throw new RangeError(`${index + 1}番目の歌唱音符「${note || '(空)'}」が不正です`)
}
const mora = singingMoraToKoe(lyric, previousMora)
koe += `#${note},${duration}${mora}`
previousMora = mora
}
if (koe.length > STACKCHAN_VOICE_MAX_KOE_LENGTH) {
throw new RangeError('歌が長すぎます。歌唱リストを複数の歌うブロックに分けてください')
}
}
return koe
}
async function singScore(robot, bpm, score) {
const koe = singingScoreToKoe(bpm, score)
const result = await robot.audio.sing(koe)
if (!result?.success) throw new Error(result?.reason || '歌唱に失敗しました')
return result
}
const HELPER_SING_SCORE = `const SINGING_MORA_TO_KOE = Object.freeze(${JSON.stringify(SINGING_MORA_TO_KOE)})
const STACKCHAN_VOICE_MAX_KOE_LENGTH = ${STACKCHAN_VOICE_MAX_KOE_LENGTH}
${katakanaToHiragana.toString()}
${singingMoraToKoe.toString()}
${songDurationMilliseconds.toString()}
${singingScoreToKoe.toString()}
${singScore.toString()}`
function singingKoeFromBlock(block) {
const bpm = Number(block.getFieldValue('BPM'))
if (!Number.isFinite(bpm) || bpm < 20 || bpm > 300) {
throw new RangeError('歌うテンポは20〜300 BPMにしてください')
}
let event = block.getInputTargetBlock?.('SCORE')
let eventCount = 0
let koe = ''
let previousMora = ''
while (event) {
eventCount += 1
if (eventCount > 256) throw new RangeError('1つの歌うブロックには音符と休符を256個まで置けます')
const duration = songDurationMilliseconds(event.getFieldValue('BEATS'), bpm)
if (event.type === 'stackchan_song_note') {
const note = String(event.getFieldValue('NOTE'))
if (!/^[A-G](?:[+-])?[0-8]$/.test(note)) throw new RangeError(`歌唱音符「${note}」が不正です`)
const mora = singingMoraToKoe(event.getFieldValue('LYRIC'), previousMora)
koe += `#${note},${duration}${mora}`
previousMora = mora
} else if (event.type === 'stackchan_song_rest') {
koe += `#R,${duration}`
} else {
throw new TypeError(`歌うブロック内に未対応のブロック「${event.type}」があります`)
}
if (koe.length > STACKCHAN_VOICE_MAX_KOE_LENGTH) {
throw new RangeError('歌が長すぎます。1つの歌うブロックを複数に分けてください')
}
event = event.getNextBlock?.()
}
if (eventCount === 0) throw new RangeError('歌うブロックに音符または休符を追加してください')
return koe
}
function legacySongEventCode(block) {
let parent = block.getParent?.()
while (parent) {
if (parent.type === 'stackchan_sing') return ''
parent = parent.getParent?.()
}
throw new Error('旧形式の音符・休符ブロックは直接実行できません。トリプル形式の音符・休符をリストへ入れてください')
}
function asyncHandlerBody(generator, block) {
const body = generator.statementToCode(block, 'DO')
return body.replace(/\s+$/, '')
}
// Build an event-handler arrow function that runs a statement body inside a
// fire-and-forget async IIFE (so `await` works and errors are traced).
// `param` is the callback parameter name ('event' for input events, '' for the
// drawer button which takes none).
function eventHandler(body, errorTag, blockId, param = 'event') {
const indented = body.replace(/^/gm, ' ')
return (
`(${param}) => {\n` +
` void (async () => {\n` +
` const visualLoopGuard = createVisualLoopGuard()\n${indented}\n` +
` })().catch((error) => reportVisualError('VP_RUNTIME_HANDLER', '${escapeSingleQuoted(blockId)}', error, '${escapeSingleQuoted(errorTag)}'))\n` +
`}`
)
}
/** Refresh the localized block definitions without re-registering generators. */
export function defineStackchanBlocks(Blockly) {
Blockly.defineBlocksWithJsonArray(localizeBlocklyData(BLOCK_DEFINITIONS))
}
/**
* Register Stack-chan blocks and their JavaScript generators.
* `Blockly` is the UMD global; `generator` is javascript.javascriptGenerator.
*/
export function registerStackchanBlocks(Blockly, generator, Order) {
configureVisualGenerator(generator)
defineStackchanBlocks(Blockly)
const forBlock = generator.forBlock ?? generator
forBlock['stackchan_on_start'] = (block, gen) => {
const body = asyncHandlerBody(gen, block)
return `;(async () => {\n const visualLoopGuard = createVisualLoopGuard()\n${body}\n})().catch((error) => reportVisualError('VP_RUNTIME_START', '${escapeSingleQuoted(block.id)}', error))\n`
}
forBlock['stackchan_on_button'] = (block, gen) => {
const button = block.getFieldValue('BUTTON')
const edge = block.getFieldValue('EDGE')
const body = asyncHandlerBody(gen, block)
return `runtime.add(onButton(robot, '${button}', '${edge}', ${eventHandler(body, `button ${button}`, block.id)}))\n`
}
forBlock['stackchan_on_imu'] = (block, gen) => {
const motion = block.getFieldValue('MOTION')
const body = asyncHandlerBody(gen, block)
return `runtime.add(onImu(robot, '${motion}', ${eventHandler(body, 'imu', block.id)}))\n`
}
forBlock['stackchan_on_head_touch'] = (block, gen) => {
const gesture = block.getFieldValue('GESTURE')
const body = asyncHandlerBody(gen, block)
return `runtime.add(onHeadTouch(robot, '${gesture}', ${eventHandler(body, 'head touch', block.id)}))\n`
}
forBlock['stackchan_on_drawer_button'] = (block, gen) => {
const label = escapeSingleQuoted(block.getFieldValue('LABEL'))
const key = escapeSingleQuoted(block.id)
const body = asyncHandlerBody(gen, block)
return (
`robot.ui.drawer?.addDrawerButton({ key: '${key}', label: '${label}', callback: ${eventHandler(body, 'drawer', block.id, '')} })\n` +
`runtime.add(() => robot.ui.drawer?.removeDrawerButton?.('${key}'))\n`
)
}
forBlock['stackchan_every'] = (block, gen) => {
const seconds = Number(block.getFieldValue('SECONDS'))
const interval = Math.max(1, Math.round(seconds * 1000))
const body = asyncHandlerBody(gen, block)
return (
`runtime.addTimer(Timer.repeat(() => {\n` +
` void (async () => {\n` +
` const visualLoopGuard = createVisualLoopGuard()\n${body.replace(/^/gm, ' ')}\n` +
` })().catch((error) => reportVisualError('VP_RUNTIME_TIMER', '${escapeSingleQuoted(block.id)}', error))\n` +
`}, ${interval}))\n`
)
}
forBlock['stackchan_set_emotion'] = (block) => {
return `robot.face.setEmotion(Emotion.${block.getFieldValue('EMOTION')})\n`
}
forBlock['stackchan_set_color'] = (block) => {
const key = block.getFieldValue('KEY')
const color = block.getFieldValue('COLOR')
return `robot.face.setColor('${key}', ...hexToRgb('${color}'))\n`
}
forBlock['stackchan_set_mouth'] = (block) => {
return `robot.face.setMouthOpen(${Number(block.getFieldValue('VALUE'))})\n`
}
forBlock['stackchan_say'] = (block, gen) => {
const text = gen.valueToCode(block, 'TEXT', Order.NONE) || "''"
return `await robot.audio.say(String(${text}))\n`
}
forBlock['stackchan_sing'] = (block) => {
return `await robot.audio.sing('${escapeSingleQuoted(singingKoeFromBlock(block))}')\n`
}
forBlock['stackchan_sing_score'] = (block, gen) => {
const bpm = Number(block.getFieldValue('BPM'))
const score = gen.valueToCode(block, 'SCORE', Order.NONE) || '[]'
return `await singScore(robot, ${bpm}, ${score})\n`
}
forBlock['stackchan_song_note_tuple'] = (block) => {
const note = escapeSingleQuoted(block.getFieldValue('NOTE'))
const beats = Number(block.getFieldValue('BEATS'))
const lyric = escapeSingleQuoted(block.getFieldValue('LYRIC'))
return [`['${note}', ${beats}, '${lyric}']`, Order.ATOMIC ?? Order.NONE]
}
forBlock['stackchan_song_rest_tuple'] = (block) => {
const beats = Number(block.getFieldValue('BEATS'))
return [`['R', ${beats}, '']`, Order.ATOMIC ?? Order.NONE]
}
// Keep the original statement-score blocks loadable for projects created
// during PR development. They remain valid only inside the original parent;
// a detached block now fails instead of silently generating an empty handler.
forBlock['stackchan_song_note'] = legacySongEventCode
forBlock['stackchan_song_rest'] = legacySongEventCode
forBlock['stackchan_show_balloon'] = (block, gen) => {
const text = gen.valueToCode(block, 'TEXT', Order.NONE) || "''"
return `robot.ui.showBalloon(String(${text}))\n`
}
forBlock['stackchan_hide_balloon'] = () => `robot.ui.hideBalloon()\n`
forBlock['stackchan_tone'] = (block) => {
const note = Number(block.getFieldValue('NOTE'))
const duration = Number(block.getFieldValue('DURATION'))
return `await robot.audio.tone(${note}, ${duration})\n`
}
forBlock['stackchan_look_at'] = (block) => {
const x = Number(block.getFieldValue('X'))
const y = Number(block.getFieldValue('Y'))
const z = Number(block.getFieldValue('Z'))
return `robot.motion.lookAt([${x}, ${y}, ${z}])\n`
}
forBlock['stackchan_look_away'] = () => `robot.motion.lookAway()\n`
forBlock['stackchan_set_torque'] = (block) => {
return `await robot.motion.setTorque(${block.getFieldValue('TORQUE')})\n`
}
forBlock['stackchan_set_pose'] = (block) => {
const pitch = Number(block.getFieldValue('PITCH'))
const yaw = Number(block.getFieldValue('YAW'))
const time = Number(block.getFieldValue('TIME'))
return `await robot.motion.setPose({ rotation: { p: (${pitch} * Math.PI) / 180, y: (${yaw} * Math.PI) / 180, r: 0 } }, ${time})\n`
}
forBlock['stackchan_light_on'] = (block) => {
const name = escapeSingleQuoted(block.getFieldValue('NAME'))
const color = block.getFieldValue('COLOR')
return `robot.lighting.lightOn('${name}', ...hexToRgb('${color}'))\n`
}
forBlock['stackchan_light_off'] = (block) => {
return `robot.lighting.lightOff('${escapeSingleQuoted(block.getFieldValue('NAME'))}')\n`
}
forBlock['stackchan_light_rainbow'] = (block) => {
return `robot.lighting.lightRainbow('${escapeSingleQuoted(block.getFieldValue('NAME'))}')\n`
}
forBlock['stackchan_light_blink'] = (block) => {
const name = escapeSingleQuoted(block.getFieldValue('NAME'))
const color = block.getFieldValue('COLOR')
const interval = Number(block.getFieldValue('INTERVAL'))
return `robot.lighting.lightBlink('${name}', ...hexToRgb('${color}'), ${interval})\n`
}
forBlock['stackchan_drawer_control'] = (block) => {
return `robot.ui.${block.getFieldValue('ACTION')}()\n`
}
forBlock['stackchan_show_face'] = () => `robot.ui.showFace()\n`
forBlock['stackchan_wait'] = (block) => {
return `await wait(${Number(block.getFieldValue('DURATION'))})\n`
}
forBlock['stackchan_trace'] = (block, gen) => {
const text = gen.valueToCode(block, 'TEXT', Order.NONE) || "''"
return `trace(String(${text}) + '\\n')\n`
}
forBlock['stackchan_random'] = (block) => {
const min = Number(block.getFieldValue('MIN'))
const max = Number(block.getFieldValue('MAX'))
return [`randomBetween(${min}, ${max})`, Order.FUNCTION_CALL]
}
registerAsyncProcedureGenerators(generator, Order)
}
export function configureVisualGenerator(generator) {
generator.INFINITE_LOOP_TRAP = 'visualLoopGuard(%1);\n'
generator.addReservedWords?.(VISUAL_RUNTIME_RESERVED_WORDS.join(','))
}
export function registerAsyncProcedureGenerators(generator, Order) {
const forBlock = generator.forBlock ?? generator
const definition = (block, gen) => {
const functionName = gen.getProcedureName(block.getFieldValue('NAME'))
let prefix = ''
if (gen.STATEMENT_PREFIX) prefix += gen.injectId(gen.STATEMENT_PREFIX, block)
if (gen.STATEMENT_SUFFIX) prefix += gen.injectId(gen.STATEMENT_SUFFIX, block)
if (prefix) prefix = gen.prefixLines(prefix, gen.INDENT)
let loopTrap = ''
if (gen.INFINITE_LOOP_TRAP) {
loopTrap = gen.prefixLines(gen.injectId(gen.INFINITE_LOOP_TRAP, block), gen.INDENT)
}
const branch = block.getInput('STACK') ? gen.statementToCode(block, 'STACK') : ''
let returnValue = block.getInput('RETURN') ? gen.valueToCode(block, 'RETURN', Order.NONE) || '' : ''
const suffixBeforeReturn = branch && returnValue ? prefix : ''
if (returnValue) returnValue = `${gen.INDENT}return ${returnValue};\n`
const args = ['visualLoopGuard', ...block.getVars().map((variable) => gen.getVariableName(variable))]
let code =
`async function ${functionName}(${args.join(', ')}) {\n` +
prefix +
loopTrap +
branch +
suffixBeforeReturn +
returnValue +
'}'
code = gen.scrub_(block, code)
gen.definitions_[`%${functionName}`] = code
return null
}
forBlock.procedures_defreturn = definition
forBlock.procedures_defnoreturn = definition
forBlock.procedures_callreturn = (block, gen) => {
const functionName = gen.getProcedureName(block.getFieldValue('NAME'))
const args = [
'visualLoopGuard',
...block.getVars().map((_variable, index) => gen.valueToCode(block, `ARG${index}`, Order.NONE) || 'null'),
]
return [`await ${functionName}(${args.join(', ')})`, Order.AWAIT ?? Order.FUNCTION_CALL]
}
forBlock.procedures_callnoreturn = (block, gen) => {
const [code] = forBlock.procedures_callreturn(block, gen)
return `${code};\n`
}
}
export const TOOLBOX = {
kind: 'categoryToolbox',
contents: [
{
kind: 'category',
name: 'イベント',
colour: `${BLOCK_STYLE.event}`,
contents: [
{ kind: 'block', type: 'stackchan_on_start' },
{ kind: 'block', type: 'stackchan_on_button' },
{ kind: 'block', type: 'stackchan_every' },
{ kind: 'block', type: 'stackchan_on_imu' },
{ kind: 'block', type: 'stackchan_on_head_touch' },
{ kind: 'block', type: 'stackchan_on_drawer_button' },
],
},
{
kind: 'category',
name: 'かお',
colour: `${BLOCK_STYLE.face}`,
contents: [
{ kind: 'block', type: 'stackchan_set_emotion' },
{ kind: 'block', type: 'stackchan_set_color' },
{ kind: 'block', type: 'stackchan_set_mouth' },
],
},
{
kind: 'category',
name: 'おしゃべり',
colour: `${BLOCK_STYLE.speech}`,
contents: [
{
kind: 'block',
type: 'stackchan_say',
inputs: { TEXT: { shadow: { type: 'text', fields: { TEXT: 'こんにちは' } } } },
},
{
kind: 'block',
type: 'stackchan_sing_score',
inputs: {
SCORE: {
block: {
type: 'lists_create_with',
extraState: { itemCount: 4 },
inputs: {
ADD0: {
block: {
type: 'stackchan_song_note_tuple',
fields: { NOTE: 'C4', BEATS: 1, LYRIC: 'き' },
},
},
ADD1: {
block: {
type: 'stackchan_song_note_tuple',
fields: { NOTE: 'C4', BEATS: 1, LYRIC: 'ら' },
},
},
ADD2: {
block: {
type: 'stackchan_song_note_tuple',
fields: { NOTE: 'G4', BEATS: 1, LYRIC: 'き' },
},
},
ADD3: {
block: {
type: 'stackchan_song_note_tuple',
fields: { NOTE: 'G4', BEATS: 1, LYRIC: 'ら' },
},
},
},
},
},
},
},
{ kind: 'block', type: 'stackchan_song_note_tuple' },
{ kind: 'block', type: 'stackchan_song_rest_tuple' },
{
kind: 'block',
type: 'stackchan_show_balloon',
inputs: { TEXT: { shadow: { type: 'text', fields: { TEXT: 'やあ!' } } } },
},
{ kind: 'block', type: 'stackchan_hide_balloon' },
{ kind: 'block', type: 'stackchan_tone' },
],
},
{
kind: 'category',
name: 'うごき',
colour: `${BLOCK_STYLE.motion}`,
contents: [
{ kind: 'block', type: 'stackchan_look_at' },
{ kind: 'block', type: 'stackchan_look_away' },
{ kind: 'block', type: 'stackchan_set_pose' },
{ kind: 'block', type: 'stackchan_set_torque' },
],
},
{
kind: 'category',
name: 'LED',
colour: `${BLOCK_STYLE.light}`,
contents: [
{ kind: 'block', type: 'stackchan_light_on' },
{ kind: 'block', type: 'stackchan_light_off' },
{ kind: 'block', type: 'stackchan_light_rainbow' },
{ kind: 'block', type: 'stackchan_light_blink' },
],
},
{
kind: 'category',
name: 'がめん',
colour: `${BLOCK_STYLE.ui}`,
contents: [
{ kind: 'block', type: 'stackchan_drawer_control' },
{ kind: 'block', type: 'stackchan_show_face' },
],
},
{
kind: 'category',
name: 'どうぐ',
colour: `${BLOCK_STYLE.util}`,
contents: [
{ kind: 'block', type: 'stackchan_wait' },
{
kind: 'block',
type: 'stackchan_trace',
inputs: { TEXT: { shadow: { type: 'text', fields: { TEXT: 'debug' } } } },
},
{ kind: 'block', type: 'stackchan_random' },
],
},
{ kind: 'sep' },
{
kind: 'category',
name: 'ロジック',
categorystyle: 'logic_category',
contents: [
{ kind: 'block', type: 'controls_if' },
{ kind: 'block', type: 'logic_compare' },
{ kind: 'block', type: 'logic_operation' },
{ kind: 'block', type: 'logic_negate' },
{ kind: 'block', type: 'logic_boolean' },
],
},
{
kind: 'category',
name: 'くり返し',
categorystyle: 'loop_category',
contents: [
{
kind: 'block',
type: 'controls_repeat_ext',
inputs: { TIMES: { shadow: { type: 'math_number', fields: { NUM: 3 } } } },
},
{ kind: 'block', type: 'controls_whileUntil' },
],
},
{
kind: 'category',
name: '数',
categorystyle: 'math_category',
contents: [
{ kind: 'block', type: 'math_number' },
{ kind: 'block', type: 'math_arithmetic' },
{ kind: 'block', type: 'math_round' },
],
},
{
kind: 'category',
name: 'テキスト',
categorystyle: 'text_category',
contents: [
{ kind: 'block', type: 'text' },
{ kind: 'block', type: 'text_join' },
],
},
{
kind: 'category',
name: 'リスト',
categorystyle: 'list_category',
contents: [
{ kind: 'block', type: 'lists_create_with' },
{ kind: 'block', type: 'lists_length' },
{ kind: 'block', type: 'lists_getIndex' },
{ kind: 'block', type: 'lists_setIndex' },
],
},
{ kind: 'category', name: '変数', categorystyle: 'variable_category', custom: 'VARIABLE' },
{ kind: 'category', name: '関数', categorystyle: 'procedure_category', custom: 'PROCEDURE' },
],
}
export function localizedToolbox() {
return localizeBlocklyData(TOOLBOX)
}
/**
* Generate the complete mod.js source from a Blockly workspace.
*/
export function generateModSource(generator, workspace) {
const body = generator.workspaceToCode(workspace)
return assembleModSource(body.replace(/\s+$/, ''))
}
c26313eeaThis file is pinned to the selected release. Download the original to open it in your design tool.