この レッスンの ピースだけ みせているよ。「もっと みる」を おすと、ほかの ピースも でてきて つかえるよ。Only showing the pieces for this lesson. Tap “Show more” to see and use the other pieces too.
プログラムProgram
まだ ピースが ないよ。ひだりの ピースを タップして ついかしよう。No pieces yet. Tap a piece on the left to add it.
Running!
MINI SCRATCH
キーボードを タップすると、せんせいの あいことばを きかれるよTap the keyboard and you will be asked for the teacher's password
キャラを えらぶ(1にんめ)Choose a character (1st)
おともだち(2にんめ)Friend (2nd)
あいことばを いれてねType the password
この レッスンの あいことばは、つきの なまえ(えいご)だよ。あたると、レッスンの せいかいが みられるよ。The password for this lesson is the name of the month (in English). Get it right and you can see the lesson's answer.
🎉 Correct!
せいかいの ピースは これだよHere are the answer pieces
"use strict";
/* ================= Characters ================= */
var CHARACTERS = [
{ id: "cat", name: "ねこ", nameEn: "Cat", voice: "にゃーん!", svg:
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' },
{ id: "dog", name: "いぬ", nameEn: "Dog", voice: "わん!", svg:
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' },
{ id: "rabbit", name: "うさぎ", nameEn: "Rabbit", voice: "ぴょん!", svg:
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' },
{ id: "bird", name: "とり", nameEn: "Bird", voice: "ぴよ!", svg:
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' },
{ id: "panda", name: "ぱんだ", nameEn: "Panda", voice: "やっほー!", svg:
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' },
{ id: "robot", name: "ロボット", nameEn: "Robot", voice: "ピピッ!", svg:
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' }
];
function charById(id) { for (var i = 0; i block array */
};
function progKey(lesson, slot) { return lesson + ":" + slot; }
function getProgram(lesson, slot) {
var k = progKey(lesson, slot);
if (!state.programs[k]) state.programs[k] = [];
return state.programs[k];
}
function program() { return getProgram(state.lesson, state.activeChar); }
function currentLesson() { return lessonByN(state.lesson); }
function friendActive() { return !!currentLesson().friend; }
/* ================= DOM refs ================= */
var lessonTabsEl = document.getElementById("lessonTabs");
var lessonTitleEl = document.getElementById("lessonTitle");
var lessonGoalEl = document.getElementById("lessonGoal");
var newPieceEl = document.getElementById("newPiece");
var paletteBody = document.getElementById("paletteBody");
var targetNote = document.getElementById("targetNote");
var listEl = document.getElementById("programList");
var emptyNote = document.getElementById("emptyNote");
var charTabsEl = document.getElementById("charTabs");
var charTab1 = document.getElementById("charTab1");
var charTab2 = document.getElementById("charTab2");
var wsTitle = document.getElementById("wsTitle");
var screenEl = document.getElementById("screen");
var rockEl = document.getElementById("rockEl");
var runBtn = document.getElementById("runBtn");
var toastEl = document.getElementById("toast");
/* ================= Bilingual labels: Japanese (small, semi-transparent) on top, English below ================= */
var TITLE_EN = ["Appear!", "Lots appear", "Spin", "Spin and stop", "Hide and seek", "Go for a walk", "Jump!", "In order", "Won't stop!", "Dance", "Talking", "Drive", "Jump button", "Big and small", "Rainbow", "A friend", "Talk together", "Sound", "Tap trick", "Jump the rock!", "Dance party", "A story", "Challenge", "Show day"];
var GOAL_EN = [
"Put the Show piece and press Start. Your character will appear!",
"Put Show inside Repeat and lots of characters will appear. Try changing the number.",
"Spin turns your character 45 degrees. Put it inside Repeat to turn many times — 8 spins make a full turn!",
"No new piece. Set Repeat to 4 — it turns 4 times, ends upside down, then stops all by itself.",
"Put Hide inside “When X key is pressed.” Press X to hide, press X again to come back.",
"Walk makes your character move forward. Tap the arrow to change direction. Can you go left too?",
"Jump makes your character hop. Put it inside Repeat to jump lots of times.",
"Pieces run in order from top to bottom. Line them up: Walk, then Jump, then Spin.",
"Forever keeps going and won't stop! Make it go back and forth, then press Stop.",
"Put Spin and Jump inside Forever to make a dance. Create your own dance!",
"Say makes a speech bubble appear. Make your character say your favorite words.",
"Change the key in “When key pressed” to the arrows. Move your character with the left and right keys.",
"Make Space jump. Walk with the arrow keys, jump with Space!",
"Size makes your character bigger or smaller.",
"Change color changes your character's color. Put it inside Forever for a rainbow dance!",
"A friend has arrived! There are 2 characters, and each one has its own program. Switch with the tabs on top.",
"When one character talks, the friend answers. Make a little talk for two.",
"Sound plays your character's voice. Try it together with Jump or Spin.",
"Put a trick inside “When tapped.” Tap your character and your trick will happen!",
"There's a rock on the stage. Walk up to it and jump over it!",
"Spin + Color + Sound + Forever. Make a 10-second dance and start the party!",
"Use Say, Walk and Jump to make a story with 3 scenes: beginning, middle, and end.",
"Challenge cards: 1) Use 3 tricks 2) Hide, then jump 3) Make the biggest character. Pick your favorite!",
"Last lesson! Show your program to everyone. You're a programmer now!"
];
function expHTML(jp, en) {
return '' + jp + '' + en + "";
}
var NEWPIECE_EN = {
"でる": "Show", "くりかえす": "Repeat", "まわる": "Spin",
"キーを おしたら + かくれる": "When key pressed + Hide",
"あるく": "Walk", "ジャンプ": "Jump", "ずっと くりかえす": "Forever",
"いう": "Say", "おおきさ": "Size", "いろを かえる": "Change color",
"おともだち": "A friend", "おとを ならす": "Sound", "タッチしたら": "When tapped"
};
function biEl(jp, en, center) {
var w = document.createElement("span");
w.className = "bi" + (center ? " bi-c" : "");
var j = document.createElement("span");
j.className = "bi-jp"; j.textContent = jp;
var e = document.createElement("span");
e.className = "bi-en"; e.textContent = en;
w.appendChild(j); w.appendChild(e);
return w;
}
function biHTML(jp, en, center) {
return '' +
jp + '' + en + "";
}
var toastTimer = null;
function toast(en, jp) {
if (jp) {
toastEl.innerHTML = "";
var tw = document.createElement("span");
tw.className = "bi bi-c";
var tj = document.createElement("span"); tj.className = "bi-jp"; tj.textContent = jp;
var te = document.createElement("span"); te.className = "bi-en"; te.textContent = en;
tw.appendChild(tj); tw.appendChild(te);
toastEl.appendChild(tw);
} else {
toastEl.textContent = en;
}
toastEl.classList.add("show");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2400);
}
/* ================= Tree helpers ================= */
function findBlock(id, arr) {
arr = arr || program();
for (var i = 0; i < arr.length; i++) {
if (arr[i].id === id) return arr[i];
if (CONTAINERS[arr[i].type]) {
var f = findBlock(id, arr[i].children);
if (f) return f;
}
}
return null;
}
function containerArray(key) {
if (key === "root") return program();
var b = findBlock(key);
return b ? b.children : program();
}
function locate(id, arr, key) {
arr = arr || program(); key = key || "root";
for (var i = 0; i < arr.length; i++) {
if (arr[i].id === id) return { arr: arr, index: i, key: key };
if (CONTAINERS[arr[i].type]) {
var f = locate(id, arr[i].children, arr[i].id);
if (f) return f;
}
}
return null;
}
var selectedContainerId = null;
/* ================= Workspace rendering ================= */
function slotEl(key, index) {
var s = document.createElement("div");
s.className = "slot";
s.dataset.key = key;
s.dataset.index = index;
return s;
}
function renderBlocks(arr, container, key) {
container.appendChild(slotEl(key, 0));
arr.forEach(function (b, i) {
container.appendChild(renderBlock(b));
container.appendChild(slotEl(key, i + 1));
});
}
var KEY_OPTIONS = [
{ v: "x", t: "X key" }, { v: "space", t: "Space" },
{ v: "left", t: "← key" }, { v: "right", t: "→ key" }
];
var KEY_JP = { x: "X キー", space: "スペース", left: "← キー", right: "→ キー" };
function keyLabel(v) {
for (var i = 0; i < KEY_OPTIONS.length; i++) if (KEY_OPTIONS[i].v === v) return KEY_OPTIONS[i].t;
return v;
}
function renderBlock(b) {
var def = BLOCK_DEFS[b.type];
var el = document.createElement("div");
el.className = "block b-" + b.type + (def.hat ? " hat" : "") + (def.container ? " container" : "");
el.dataset.id = b.id;
if (b.id === selectedContainerId) el.classList.add("selected");
var head = document.createElement("div");
head.className = "block-head c-" + b.type;
function lbl(jp, en) {
var s = biEl(jp, en);
s.classList.add("lbl");
return s;
}
if (b.type === "repeat") {
head.appendChild(lbl("くりかえす", "Repeat"));
var num = document.createElement("input");
num.type = "number"; num.min = "1"; num.max = "99"; num.value = b.times;
num.setAttribute("aria-label", "How many repeats");
num.addEventListener("input", function () {
var v = parseInt(num.value, 10);
b.times = isNaN(v) ? 1 : Math.max(1, Math.min(99, v));
});
num.addEventListener("change", function () { num.value = b.times; });
head.appendChild(num);
head.appendChild(lbl("かい", "times"));
} else if (b.type === "forever") {
head.appendChild(lbl("ずっと くりかえす", "Forever"));
} else if (b.type === "whenkey") {
var sel = document.createElement("select");
sel.setAttribute("aria-label", "Which key");
KEY_OPTIONS.forEach(function (o) {
var op = document.createElement("option");
op.value = o.v; op.textContent = o.t;
if (o.v === b.key) op.selected = true;
sel.appendChild(op);
});
sel.addEventListener("change", function () { b.key = sel.value; });
head.appendChild(sel);
head.appendChild(lbl("を おしたら", "is pressed"));
} else if (b.type === "whenclick") {
head.appendChild(lbl("キャラを タッチしたら", "When tapped"));
} else if (b.type === "move") {
head.appendChild(lbl("あるく", "Walk"));
var steps = document.createElement("input");
steps.type = "number"; steps.min = "1"; steps.max = "10"; steps.value = b.steps;
steps.setAttribute("aria-label", "How many steps");
steps.addEventListener("input", function () {
var v = parseInt(steps.value, 10);
b.steps = isNaN(v) ? 1 : Math.max(1, Math.min(10, v));
});
steps.addEventListener("change", function () { steps.value = b.steps; });
head.appendChild(steps);
var dir = document.createElement("button");
dir.type = "button"; dir.className = "dirbtn";
dir.textContent = b.dir === 1 ? "→" : "←";
dir.setAttribute("aria-label", "Change direction");
dir.addEventListener("click", function (ev) {
ev.stopPropagation();
b.dir = b.dir === 1 ? -1 : 1;
render();
});
head.appendChild(dir);
} else if (b.type === "say") {
head.appendChild(lbl("いう", "Say"));
var inp = document.createElement("input");
inp.type = "text"; inp.value = b.text; inp.maxLength = 24;
inp.placeholder = charById(state.activeChar === 1 ? state.char1 : state.char2).voice;
inp.setAttribute("aria-label", "Words to say");
inp.addEventListener("input", function () { b.text = inp.value; });
head.appendChild(inp);
} else if (b.type === "size") {
head.appendChild(lbl("おおきさ", "Size"));
var ssel = document.createElement("select");
ssel.setAttribute("aria-label", "How to change size");
[["big", "Bigger"], ["small", "Smaller"]].forEach(function (pair) {
var op = document.createElement("option");
op.value = pair[0]; op.textContent = pair[1];
if (pair[0] === b.mode) op.selected = true;
ssel.appendChild(op);
});
ssel.addEventListener("change", function () { b.mode = ssel.value; });
head.appendChild(ssel);
} else {
head.appendChild(lbl(def.label, def.en));
}
var del = document.createElement("button");
del.type = "button"; del.className = "del"; del.textContent = "✕";
del.setAttribute("aria-label", "Delete this piece");
del.addEventListener("click", function (ev) {
ev.stopPropagation();
var loc = locate(b.id);
if (loc) loc.arr.splice(loc.index, 1);
if (selectedContainerId === b.id) selectedContainerId = null;
render();
});
head.appendChild(del);
el.appendChild(head);
head.addEventListener("pointerdown", function (ev) { startBlockDrag(ev, el, b); });
if (def.container) {
head.addEventListener("click", function () {
selectedContainerId = (selectedContainerId === b.id) ? null : b.id;
render();
});
}
if (def.container) {
var body = document.createElement("div");
body.className = "loop-body";
if (b.children.length === 0) {
body.appendChild(slotEl(b.id, 0));
var note = document.createElement("div");
note.className = "loop-empty";
note.appendChild(biEl("ここに ピースを いれよう", "Put pieces here"));
body.appendChild(note);
} else {
renderBlocks(b.children, body, b.id);
}
el.appendChild(body);
var cap = document.createElement("div");
cap.className = "loop-cap";
cap.appendChild(biEl(def.cap, def.capEn || "end"));
el.appendChild(cap);
}
return el;
}
function render() {
listEl.innerHTML = "";
renderBlocks(program(), listEl, "root");
emptyNote.hidden = program().length !== 0;
if (selectedContainerId && !findBlock(selectedContainerId)) selectedContainerId = null;
updateTargetNote();
}
function updateTargetNote() {
if (selectedContainerId) {
var b = findBlock(selectedContainerId);
targetNote.innerHTML = expHTML("あたらしい ピースは、えらんだ ピースの なかに はいるよ。", "New pieces will go inside the “" + BLOCK_DEFS[b.type].en + "” you picked.");
var btn = document.createElement("button");
btn.type = "button"; btn.innerHTML = biHTML("いちばん したに いれる", "Put at the bottom", true);
btn.addEventListener("click", function () { selectedContainerId = null; render(); });
targetNote.appendChild(document.createElement("br"));
targetNote.appendChild(btn);
} else {
targetNote.innerHTML = expHTML("あたらしい ピースは プログラムの いちばん したに はいるよ。「くりかえす」などを タップすると、なかに いれられるよ。", "New pieces go at the bottom of your program. Tap a piece like “Repeat” to put pieces inside it.");
}
}
/* ================= Palette ================= */
var paletteExpanded = false;
function makePaletteItem(type) {
var def = BLOCK_DEFS[type];
var item = document.createElement("div");
item.className = "palette-item c-" + type + (def.hat ? " hat" : "");
item.dataset.add = type;
item.setAttribute("role", "button");
item.setAttribute("tabindex", "0");
item.setAttribute("aria-label", "Add " + def.en + "(" + def.label + ")");
var txt = document.createElement("span");
txt.className = "pi-txt";
txt.appendChild(biEl(def.label, def.en));
var small = document.createElement("small");
small.className = "sub-bi";
if (def.subJp) {
var subBi = biEl(def.subJp, def.sub);
small.appendChild(subBi);
} else {
small.textContent = def.sub;
}
txt.appendChild(small);
var grip = document.createElement("span");
grip.className = "grip"; grip.setAttribute("aria-hidden", "true"); grip.textContent = "⠿";
item.appendChild(txt);
item.appendChild(grip);
item.addEventListener("click", function () { addBlock(type); });
item.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); addBlock(type); }
});
item.addEventListener("pointerdown", function (ev) { startPaletteDrag(ev, item); });
return item;
}
function renderPalette() {
paletteBody.querySelectorAll(".palette-item, .show-more-btn, .palette-section-label").forEach(function (el) { el.remove(); });
var lesson = currentLesson();
lesson.blocks.forEach(function (type) {
paletteBody.insertBefore(makePaletteItem(type), targetNote);
});
/* Pieces this lesson doesn't need yet stay hidden behind もっと みる — still fully usable */
var extras = Object.keys(BLOCK_DEFS).filter(function (type) { return lesson.blocks.indexOf(type) === -1; });
if (extras.length) {
var toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "show-more-btn";
toggle.setAttribute("aria-expanded", paletteExpanded ? "true" : "false");
toggle.innerHTML = paletteExpanded ? biHTML("かくす", "▲ Hide", true) : biHTML("もっと みる(ほかの ピース)", "▼ Show more", true);
toggle.addEventListener("click", function () {
paletteExpanded = !paletteExpanded;
renderPalette();
});
paletteBody.insertBefore(toggle, targetNote);
if (paletteExpanded) {
var label = document.createElement("div");
label.className = "palette-section-label";
label.appendChild(biEl("そのほかの ピース — つかっても いいよ", "Other pieces — you can use these too"));
paletteBody.insertBefore(label, targetNote);
extras.forEach(function (type) {
paletteBody.insertBefore(makePaletteItem(type), targetNote);
});
}
}
}
function addBlock(type) {
var b = makeBlock(type);
if (type === "say") b.text = "";
var target = selectedContainerId ? containerArray(selectedContainerId) : program();
target.push(b);
if (CONTAINERS[type]) selectedContainerId = b.id;
render();
}
document.getElementById("clearProg").addEventListener("click", function () {
state.programs[progKey(state.lesson, state.activeChar)] = [];
selectedContainerId = null;
render();
});
/* ================= Drag & drop (pointer based, mouse + touch) ================= */
var drag = null;
var suppressClickUntil = 0;
function makeGhost(sourceEl, x, y) {
var g = sourceEl.cloneNode(true);
g.classList.add("drag-ghost");
var r = sourceEl.getBoundingClientRect();
g.style.width = Math.min(r.width, window.innerWidth - 24) + "px";
g.style.left = (x - 24) + "px";
g.style.top = (y - 18) + "px";
document.body.appendChild(g);
return g;
}
function moveGhost(x, y) {
if (!drag || !drag.ghost) return;
drag.ghost.style.left = (x - 24) + "px";
drag.ghost.style.top = (y - 18) + "px";
}
function nearestSlot(x, y) {
var best = null, bestD = Infinity;
document.querySelectorAll(".slot").forEach(function (s) {
if (drag && drag.blockId && s.closest && s.closest('.block[data-id="' + drag.blockId + '"]')) return;
var r = s.getBoundingClientRect();
if (x r.right + 30) return;
var d = Math.abs(y - (r.top + r.height / 2));
if (d < bestD) { bestD = d; best = s; }
});
return bestD < 90 ? best : null;
}
function highlightSlot(slot) {
document.querySelectorAll(".slot.active").forEach(function (s) { s.classList.remove("active"); });
if (slot) slot.classList.add("active");
}
function endDrag(commit) {
if (!drag) return;
var d = drag; drag = null;
if (d.ghost) d.ghost.remove();
if (d.srcEl) d.srcEl.classList.remove("drag-src");
highlightSlot(null);
if (commit && d.activeSlot) {
var key = d.activeSlot.dataset.key;
var index = parseInt(d.activeSlot.dataset.index, 10);
var target = containerArray(key);
if (d.isNew) {
target.splice(index, 0, d.newBlock);
if (CONTAINERS[d.newBlock.type]) selectedContainerId = d.newBlock.id;
} else {
var loc = locate(d.blockId);
if (loc) {
var blk = loc.arr.splice(loc.index, 1)[0];
if (loc.key === key && loc.index 7) {
drag.started = true;
drag.ghost = makeGhost(drag.srcEl, ev.clientX, ev.clientY);
if (!drag.isNew) drag.srcEl.classList.add("drag-src");
}
if (drag.started) {
ev.preventDefault();
moveGhost(ev.clientX, ev.clientY);
drag.activeSlot = nearestSlot(ev.clientX, ev.clientY);
highlightSlot(drag.activeSlot);
}
}
function trackUp(ev) {
if (!drag) return;
var wasDrag = drag.started;
if (wasDrag) suppressClickUntil = Date.now() + 300;
endDrag(true);
if (wasDrag) { ev.preventDefault(); ev.stopPropagation(); }
}
document.addEventListener("pointermove", trackMove, { passive: false });
document.addEventListener("pointerup", trackUp, true);
document.addEventListener("pointercancel", function () { endDrag(false); }, true);
document.addEventListener("click", function (ev) {
if (Date.now() 0) return;
drag = { isNew: false, blockId: b.id, srcEl: el, startX: ev.clientX, startY: ev.clientY, started: false, ghost: null, activeSlot: null };
}
function startPaletteDrag(ev, item) {
if (ev.button !== undefined && ev.button > 0) return;
drag = { isNew: true, newBlock: makeBlock(item.dataset.add), blockId: null, srcEl: item, startX: ev.clientX, startY: ev.clientY, started: false, ghost: null, activeSlot: null };
}
/* ================= Stage ================= */
function freshActor(slot) {
return { slot: slot, count: 0, gone: false, x: 0, y: 0, rot: 0, scale: 1, hue: 0, bubble: null, el: null, layer: null, spinning: false };
}
var actors = { 1: freshActor(1), 2: freshActor(2) };
actors[1].layer = document.getElementById("layer1");
actors[2].layer = document.getElementById("layer2");
function actorCharId(slot) { return slot === 1 ? state.char1 : state.char2; }
function renderActor(a) {
var layer = a.layer;
var ch = charById(actorCharId(a.slot));
var n = a.count;
/* Remove only the speech bubble — the sprite elements themselves must
be REUSED, not rebuilt, and NOT re-appended when they are already in
this layer: detaching / re-inserting a node interrupts its running
CSS transition, which is one reason a spin could look like nothing
happened. A full 360deg turn also ends exactly where it started, so
if the transition is ever skipped (e.g. reduced-motion turns it off)
the turn is invisible. Spin therefore turns 45deg per piece and is
animated by stepping the angle in JS (see execBlock), so the turn
is visible while it happens AND in where the character ends up. */
var oldBubbles = layer.querySelectorAll(".bubble");
for (var bi = 0; bi < oldBubbles.length; bi++) oldBubbles[bi].remove();
var existing = layer.querySelectorAll(".sprite");
if (n <= 0) {
for (var ri = 0; ri < existing.length; ri++) existing[ri].remove();
return;
}
var rowCount = Math.ceil(n / 5);
var dirSign = a.slot === 1 ? 1 : -1;
for (var i = 0; i < n; i++) {
var row = Math.floor(i / 5);
var col = i % 5;
var sp = existing[i] || null;
var isNew = !sp;
if (isNew) {
sp = document.createElement("div");
sp.setAttribute("role", "button");
(function (slot) {
sp.addEventListener("click", function () { triggerClick(actors[slot]); });
})(a.slot);
}
if (isNew || sp.getAttribute("data-char") !== ch.id) {
sp.innerHTML = ch.svg;
sp.setAttribute("data-char", ch.id);
sp.setAttribute("aria-label", ch.nameEn);
}
sp.className = "sprite" + (a.gone ? " gone" : "");
var leftPct = a.slot === 1
? 8 + col * 15
: 96 - 27 - col * 15;
sp.style.left = leftPct + "%";
sp.style.bottom = (15 + (rowCount - 1 - row) * 24) + "%";
sp.style.zIndex = String(2 + row);
if (isNew || a.spinning) {
/* Fresh sprites are placed instantly (no slide/spin in from a
state they never had). While a spin is being stepped in JS the
CSS transition must stay OFF, or it would lag and smooth away
every step; the stepping itself is the animation. */
sp.style.transition = "none";
} else if (sp.style.transition) {
sp.style.transition = "";
}
sp.style.transform =
"translateX(" + (a.x * dirSign * 0 + a.x) + "px) translateY(" + a.y + "px) rotate(" + a.rot + "deg) scale(" + a.scale + ")";
sp.style.filter = a.hue ? "hue-rotate(" + a.hue + "deg)" : "";
/* Only append when the sprite is not already in this layer —
re-appending an existing child detaches and re-inserts it,
which can cancel the very transition we want to see. */
if (sp.parentNode !== layer) layer.appendChild(sp);
if (isNew) {
void sp.offsetWidth; /* flush the instant placement */
if (!a.spinning) sp.style.transition = "";
}
}
for (var xi = n; xi next hat index */
function wait(ms, token) {
return new Promise(function (resolve) {
var started = Date.now();
(function check() {
if (token !== runToken) return resolve(false);
if (Date.now() - started >= ms) return resolve(true);
setTimeout(check, 30);
})();
});
}
function markRunning(id, on) {
var el = listEl.querySelector('.block[data-id="' + id + '"]');
if (el) el.classList.toggle("running", on);
}
function clampActor(a) {
var w = screenEl.clientWidth || 360;
var maxX = w * 0.34;
if (a.x > maxX) a.x = maxX;
if (a.x 2.6) a.scale = 2.6;
if (a.scale < 0.35) a.scale = 0.35;
}
async function execBlocks(arr, a, token) {
for (var i = 0; i < arr.length; i++) {
if (token !== runToken) return;
await execBlock(arr[i], a, token);
}
}
async function execBlock(b, a, token) {
if (token !== runToken) return;
var type = b.type;
if (type === "whenkey" || type === "whenclick") return; /* hats run on events */
markRunning(b.id, true);
try {
if (type === "show") {
if (a.gone) { a.gone = false; if (a.count === 0) a.count = 1; }
else if (a.count 45deg in ~14 small increments
always shows the character turning, and it STAYS tilted by
45deg afterwards, so every spin is countable: 8 spins = one
full turn. */
beep(300, 700, 0.18);
var spinFrom = a.rot;
var spinTo = spinFrom + 45;
var spinStart = Date.now();
var spinDur = 420;
a.spinning = true;
var spinDone = await new Promise(function (resolve) {
(function spinFrame() {
if (token !== runToken) return resolve(false);
var p = Math.min(1, (Date.now() - spinStart) / spinDur);
var e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; /* easeInOut */
a.rot = spinFrom + (spinTo - spinFrom) * e;
renderActor(a);
if (p < 1) {
if (window.requestAnimationFrame) requestAnimationFrame(spinFrame);
else setTimeout(spinFrame, 16);
} else resolve(true);
})();
});
a.rot = spinTo;
a.spinning = false;
renderActor(a);
if (spinDone) await wait(80, token);
} else if (type === "move") {
a.x += b.dir * b.steps * 16;
clampActor(a); renderActor(a);
await wait(420, token);
} else if (type === "jump") {
a.y = -52; renderActor(a);
beep(400, 900, 0.16);
await wait(270, token);
a.y = 0; renderActor(a);
await wait(270, token);
} else if (type === "say") {
var ch = charById(actorCharId(a.slot));
a.bubble = b.text && b.text.trim() ? b.text : ch.voice;
renderActor(a);
await wait(1150, token);
a.bubble = null; renderActor(a);
} else if (type === "size") {
a.scale *= (b.mode === "big" ? 1.28 : 0.78);
clampActor(a); renderActor(a);
beep(b.mode === "big" ? 300 : 700, b.mode === "big" ? 520 : 380, 0.16);
await wait(340, token);
} else if (type === "colour") {
a.hue = (a.hue + 45) % 360; renderActor(a);
beep(760, 1180, 0.14);
await wait(300, token);
} else if (type === "sound") {
charSound(a.slot);
await wait(430, token);
} else if (type === "repeat") {
var n = Math.max(1, Math.min(99, b.times || 1));
for (var k = 0; k < n; k++) {
if (token !== runToken) break;
await execBlocks(b.children, a, token);
}
} else if (type === "forever") {
while (token === runToken) {
await execBlocks(b.children, a, token);
var ok = await wait(110, token);
if (!ok) break;
}
}
} finally {
markRunning(b.id, false);
}
}
function stopRun() {
runToken++;
hatsArmed = false;
hatCycle = {};
screenEl.classList.remove("live");
document.querySelectorAll(".block.running").forEach(function (el) { el.classList.remove("running"); });
}
runBtn.addEventListener("click", function () {
stopRun();
var token = runToken;
hatsArmed = true;
hatCycle = {};
screenEl.classList.add("live");
var jobs = [];
var p1 = getProgram(state.lesson, 1);
if (p1.length) jobs.push(execBlocks(p1, actors[1], token));
if (friendActive()) {
var p2 = getProgram(state.lesson, 2);
if (p2.length) jobs.push(execBlocks(p2, actors[2], token));
}
Promise.all(jobs).then(function () {
if (token === runToken) {
/* hats stay armed so keys / taps keep working until Stop */
}
});
if (!p1.length && !(friendActive() && getProgram(state.lesson, 2).length)) {
toast("Add some pieces first, then press Start", "ピースを おいてから スタートしよう");
}
});
document.getElementById("stopBtn").addEventListener("click", function () {
stopRun();
toast("Stopped!", "とまったよ");
});
document.getElementById("resetBtn").addEventListener("click", function () {
stopRun();
resetStage();
});
/* ---- hat triggers ---- */
function hatsFor(arr, type, key) {
return arr.filter(function (b) { return b.type === type && (type !== "whenkey" || b.key === key); });
}
function triggerKey(key) {
if (!hatsArmed) return;
[1, 2].forEach(function (slot) {
if (slot === 2 && !friendActive()) return;
var hats = hatsFor(getProgram(state.lesson, slot), "whenkey", key);
if (!hats.length) return;
var ck = slot + ":" + key;
var idx = hatCycle[ck] || 0;
var hat = hats[idx % hats.length];
hatCycle[ck] = idx + 1;
execBlocks(hat.children, actors[slot], runToken);
});
}
function triggerClick(a) {
if (!hatsArmed) return;
if (a.slot === 2 && !friendActive()) return;
var hats = hatsFor(getProgram(state.lesson, a.slot), "whenclick");
hats.forEach(function (hat) { execBlocks(hat.children, a, runToken); });
}
document.addEventListener("keydown", function (ev) {
var tag = (ev.target && ev.target.tagName) || "";
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
if (document.querySelector(".overlay.open")) return;
var key = null;
if (ev.key === "x" || ev.key === "X") key = "x";
else if (ev.key === " " || ev.key === "Spacebar") key = "space";
else if (ev.key === "ArrowLeft") key = "left";
else if (ev.key === "ArrowRight") key = "right";
if (key) { ev.preventDefault(); triggerKey(key); }
});
/* ================= Lesson tabs & switching ================= */
function renderLessonTabs() {
lessonTabsEl.innerHTML = "";
LESSONS.forEach(function (L) {
var tab = document.createElement("button");
tab.type = "button";
tab.className = "lesson-tab";
tab.setAttribute("role", "tab");
tab.setAttribute("aria-selected", L.n === state.lesson ? "true" : "false");
var num = document.createElement("span");
num.className = "num"; num.textContent = L.n;
tab.appendChild(num);
tab.appendChild(biEl(L.title, TITLE_EN[L.n - 1]));
tab.addEventListener("click", function () { switchLesson(L.n); });
lessonTabsEl.appendChild(tab);
});
}
function switchLesson(n) {
state.lesson = n;
state.activeChar = 1;
selectedContainerId = null;
paletteExpanded = false;
stopRun();
resetStage();
renderLessonTabs();
renderLessonHead();
renderPalette();
renderWorkspaceHead();
render();
}
function renderLessonHead() {
var L = currentLesson();
lessonTitleEl.innerHTML = "";
lessonTitleEl.appendChild(biEl("レッスン" + L.n + "「" + L.title + "」", "Lesson " + L.n + " — " + TITLE_EN[L.n - 1]));
lessonGoalEl.innerHTML = "";
var goalBi = biEl(L.goal, GOAL_EN[L.n - 1]);
goalBi.classList.add("bi-wrap");
lessonGoalEl.appendChild(goalBi);
if (L.newPiece) {
newPieceEl.hidden = false;
newPieceEl.innerHTML = biHTML("あたらしい ピース:" + L.newPiece, "✦ New piece: " + (NEWPIECE_EN[L.newPiece] || L.newPiece));
} else {
newPieceEl.hidden = true;
}
rockEl.hidden = L.prop !== "rock";
}
/* ================= Character tabs & pickers ================= */
function renderWorkspaceHead() {
var friend = friendActive();
charTabsEl.classList.toggle("on", friend);
wsTitle.style.display = friend ? "none" : "";
charTab1.innerHTML = "";
charTab1.appendChild(biEl(charById(state.char1).name + "の プログラム", charById(state.char1).nameEn + "'s program", true));
charTab2.innerHTML = "";
charTab2.appendChild(biEl(charById(state.char2).name + "の プログラム", charById(state.char2).nameEn + "'s program", true));
charTab1.setAttribute("aria-selected", state.activeChar === 1 ? "true" : "false");
charTab2.setAttribute("aria-selected", state.activeChar === 2 ? "true" : "false");
document.getElementById("friendPicker").hidden = !friend;
renderPicker(document.getElementById("charGrid1"), 1);
renderPicker(document.getElementById("charGrid2"), 2);
}
function renderPicker(grid, slot) {
grid.innerHTML = "";
var current = slot === 1 ? state.char1 : state.char2;
CHARACTERS.forEach(function (ch) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "char-btn";
btn.setAttribute("aria-pressed", ch.id === current ? "true" : "false");
btn.setAttribute("aria-label", "Choose " + ch.nameEn + " (" + ch.name + ")");
btn.innerHTML = ch.svg;
var name = document.createElement("span");
name.appendChild(biEl(ch.name, ch.nameEn, true));
btn.appendChild(name);
btn.addEventListener("click", function () {
if (slot === 1) state.char1 = ch.id; else state.char2 = ch.id;
renderWorkspaceHead();
renderActor(actors[slot]);
render();
});
grid.appendChild(btn);
});
}
charTab1.addEventListener("click", function () {
state.activeChar = 1; selectedContainerId = null; renderWorkspaceHead(); render();
});
charTab2.addEventListener("click", function () {
state.activeChar = 2; selectedContainerId = null; renderWorkspaceHead(); render();
});
/* ================= Keyboard (decorative) ================= */
(function buildKeyboard() {
var rows = [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
["Z", "X", "C", "V", "B", "N", "M"]
];
var wrap = document.getElementById("kbRows");
rows.forEach(function (row) {
var r = document.createElement("div");
r.className = "kb-row";
row.forEach(function (k) {
var key = document.createElement("span");
key.className = "key";
key.textContent = k;
r.appendChild(key);
});
wrap.appendChild(r);
});
var last = document.createElement("div");
last.className = "kb-row";
[["←", "xl"], ["", "space"], ["→", "xl"]].forEach(function (pair) {
var key = document.createElement("span");
key.className = "key " + pair[1];
key.textContent = pair[0] || " ";
last.appendChild(key);
});
wrap.appendChild(last);
})();
/* ================= Password & answer ================= */
var pwOverlay = document.getElementById("pwOverlay");
var pwInput = document.getElementById("pwInput");
var pwMsg = document.getElementById("pwMsg");
var ansOverlay = document.getElementById("ansOverlay");
document.getElementById("keyboard").addEventListener("click", function () {
pwInput.value = "";
pwMsg.textContent = "";
pwMsg.className = "pw-msg";
pwOverlay.classList.add("open");
setTimeout(function () { pwInput.focus(); }, 60);
});
function closePw() { pwOverlay.classList.remove("open"); }
document.getElementById("pwClose").addEventListener("click", closePw);
pwOverlay.addEventListener("click", function (ev) { if (ev.target === pwOverlay) closePw(); });
function tryPassword() {
var guess = pwInput.value.trim().toLowerCase();
if (guess === pwFor(state.lesson)) {
closePw();
showAnswer();
} else {
pwMsg.innerHTML = ""; var _pm = document.createElement("span"); _pm.className = "exp"; _pm.innerHTML = 'ちがう みたい… もういちど かんがえてみようNot quite… think again and try once more'; pwMsg.appendChild(_pm);
pwMsg.className = "pw-msg err";
var modal = pwOverlay.querySelector(".modal");
modal.classList.remove("shake");
void modal.offsetWidth;
modal.classList.add("shake");
pwInput.select();
}
}
document.getElementById("pwGo").addEventListener("click", tryPassword);
pwInput.addEventListener("keydown", function (ev) { if (ev.key === "Enter") tryPassword(); });
document.getElementById("ansClose").addEventListener("click", function () { ansOverlay.classList.remove("open"); });
ansOverlay.addEventListener("click", function (ev) { if (ev.target === ansOverlay) ansOverlay.classList.remove("open"); });
function sceneHTML(scene, charId, friendId) {
var ch = charById(charId);
var html = '' +
'' +
'' +
'' +
'' +
'';
function sprite(opts) {
var style = "position:absolute;bottom:14%;width:24%;left:" + opts.left + "%;z-index:3;";
var tf = [];
if (opts.x) tf.push("translateX(" + opts.x + "px)");
if (opts.jump) tf.push("translateY(-30px)");
if (opts.spin) tf.push("rotate(45deg)");
if (tf.length) style += "transform:" + tf.join(" ") + ";transform-origin:50% 85%;";
if (opts.hue) style += "filter:hue-rotate(" + opts.hue + "deg);";
if (opts.big) style += "transform:" + tf.join(" ") + " scale(1.45);transform-origin:50% 100%;";
if (opts.peek) style += "opacity:.35;";
var s = '
' + charById(opts.char).svg + "
";
if (opts.bubble) {
s += '
' + opts.bubble + "
";
}
if (opts.key) {
s += '
' + opts.key + "
";
}
if (opts.tap) {
s += '';
}
if (opts.note) {
s += '
♪
';
}
if (opts.arrows) {
s += '
← →
';
}
return s;
}
var left = scene.friend ? 10 : (scene.count > 1 ? 6 : 30);
if (scene.count > 1) {
for (var i = 0; i < scene.count; i++) {
html += sprite({ char: charId, left: 4 + i * 17, peek: scene.peek, key: i === 0 ? scene.key : null });
}
} else {
html += sprite({ char: charId, left: left, x: scene.x, jump: scene.jump, spin: scene.spin, hue: scene.hue, big: scene.big, peek: scene.peek, bubble: scene.bubble, key: scene.key, tap: scene.tap, note: scene.note, arrows: scene.arrows });
}
if (scene.friend) {
html += sprite({ char: friendId, left: 62, bubble: scene.friendBubble });
}
if (scene.rock) {
html += '';
}
return html;
}
function blockLabelPair(b) {
var ch = charById(state.char1);
switch (b.type) {
case "show": return { jp: "でる", en: "Show" };
case "hide": return { jp: "かくれる", en: "Hide" };
case "spin": return { jp: "まわる", en: "Spin" };
case "jump": return { jp: "ジャンプ", en: "Jump" };
case "colour": return { jp: "いろを かえる", en: "Change color" };
case "sound": return { jp: "おとを ならす", en: "Sound" };
case "repeat": return { jp: "くりかえす " + b.times + " かい", en: "Repeat " + b.times + " times" };
case "forever": return { jp: "ずっと くりかえす", en: "Forever" };
case "move": return { jp: "あるく " + b.steps + " " + (b.dir === 1 ? "→" : "←"), en: "Walk " + b.steps + " " + (b.dir === 1 ? "→" : "←") };
case "say": return { jp: "いう「" + (b.text === "VOICE" ? ch.voice : b.text) + "」", en: "Say \"" + (b.text === "VOICE" ? ch.voice : b.text) + "\"" };
case "size": return { jp: "おおきさ " + (b.mode === "big" ? "おおきく" : "ちいさく"), en: "Size: " + (b.mode === "big" ? "bigger" : "smaller") };
case "whenkey": return { jp: KEY_JP[b.key] + " を おしたら", en: keyLabel(b.key) + " pressed" };
case "whenclick": return { jp: "キャラを タッチしたら", en: "When tapped" };
default: return { jp: b.type, en: b.type };
}
}
function miniStack(blocks) {
var wrap = document.createElement("div");
wrap.className = "mini-stack";
blocks.forEach(function (b) {
var m = document.createElement("div");
m.className = "mini-block c-" + b.type + (BLOCK_DEFS[b.type].hat ? " hat" : "");
var pair = blockLabelPair(b);
m.appendChild(biEl(pair.jp, pair.en));
wrap.appendChild(m);
if (b.children && b.children.length) {
var kids = document.createElement("div");
kids.className = "mini-kids";
kids.appendChild(miniStack(b.children));
wrap.appendChild(kids);
var cap = document.createElement("div");
cap.className = "mini-block c-" + b.type;
cap.appendChild(biEl(BLOCK_DEFS[b.type].cap, BLOCK_DEFS[b.type].capEn || "end"));
wrap.appendChild(cap);
}
});
return wrap;
}
function showAnswer() {
var L = currentLesson();
document.getElementById("ansTitle").innerHTML = ""; document.getElementById("ansTitle").appendChild(biEl("せいかい! レッスン" + L.n + "の こたえ", "🎉 Correct! The answer for Lesson " + L.n));
document.getElementById("ansGoal").innerHTML = ""; var _ag = document.createElement("span"); _ag.className = "exp"; var _agj = document.createElement("span"); _agj.className = "exp-jp"; _agj.textContent = "もくひょう:" + L.goal; var _age = document.createElement("span"); _age.className = "exp-en"; _age.textContent = "Goal: " + GOAL_EN[L.n - 1]; _ag.appendChild(_agj); _ag.appendChild(_age); document.getElementById("ansGoal").appendChild(_ag);
var scene = {};
Object.keys(L.answer.scene).forEach(function (k) { scene[k] = L.answer.scene[k]; });
var ch = charById(state.char1);
if (scene.bubble === "VOICE") scene.bubble = ch.voice;
document.getElementById("ansStage").innerHTML = sceneHTML(scene, state.char1, state.char2);
var blocksWrap = document.getElementById("ansBlocks");
blocksWrap.innerHTML = "";
blocksWrap.appendChild(miniStack(L.answer.blocks.map(blockFromSpec)));
if (L.answer.friendBlocks) {
var fl = document.createElement("div");
fl.className = "mini-friend-label";
fl.innerHTML = ""; fl.appendChild(biEl(charById(state.char2).name + "の ピース:", charById(state.char2).nameEn + "'s pieces:"));
blocksWrap.appendChild(fl);
blocksWrap.appendChild(miniStack(L.answer.friendBlocks.map(blockFromSpec)));
}
ansOverlay.classList.add("open");
}
/* ================= Save / Load ================= */
document.getElementById("saveBtn").addEventListener("click", function () {
var data = {
app: "mini-scratch",
version: 2,
lesson: state.lesson,
char1: state.char1,
char2: state.char2,
programs: state.programs
};
var blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "mini-scratch-save.json";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
toast("Saved your file!", "ファイルを ほぞんしたよ");
});
var loadFile = document.getElementById("loadFile");
document.getElementById("loadBtn").addEventListener("click", function () { loadFile.click(); });
loadFile.addEventListener("change", function () {
var file = loadFile.files && loadFile.files[0];
loadFile.value = "";
if (!file) return;
var reader = new FileReader();
reader.onload = function () {
try {
var data = JSON.parse(String(reader.result));
if (!data || data.app !== "mini-scratch" || typeof data.programs !== "object" || !data.programs) throw new Error("bad file");
stopRun();
state.programs = data.programs;
if (charById(data.char1).id === data.char1) state.char1 = data.char1;
if (charById(data.char2).id === data.char2) state.char2 = data.char2;
var n = parseInt(data.lesson, 10);
state.lesson = (n >= 1 && n <= 24) ? n : 1;
state.activeChar = 1;
selectedContainerId = null;
paletteExpanded = false;
resetStage();
renderLessonTabs();
renderLessonHead();
renderPalette();
renderWorkspaceHead();
render();
/* keep ids unique for blocks added after loading */
Object.keys(state.programs).forEach(function (k) {
(function bump(arr) {
arr.forEach(function (b) {
var m = /^b(\d+)$/.exec(b.id || "");
if (m) uid = Math.max(uid, parseInt(m[1], 10));
if (b.children) bump(b.children);
});
})(state.programs[k]);
});
toast("Loaded!", "よみこんだよ!");
} catch (e) {
toast("That file could not be loaded", "その ファイルは よみこめなかったよ");
}
};
reader.readAsText(file);
});
/* ================= Init ================= */
renderLessonTabs();
renderLessonHead();
renderPalette();
renderWorkspaceHead();
render();
renderStage();