Mini Scratch button, [role="button"], select, summary, input:not([type="checkbox"], [type="radio"]) { min-height: 2rem; } :root { --bg: #e9f1f6; --panel: #ffffff; --panel-2: #f4f8fb; --ink: #1d2a36; --ink-soft: #5a7185; --line: #d3dfe9; --run: #1d9d63; --run-dark: #157a4c; --stop: #d64545; --danger: #d64545; --slot: #ffb35c; --bezel: #2c3644; --bezel-2: #1d242f; --kb: #39434f; --kb-key: #525f6e; --kb-ink: #e8eef4; --shadow: 0 1px 2px rgba(20, 40, 60, .08), 0 6px 18px rgba(20, 40, 60, .06); --radius: 14px; /* block colours */ --c-show: #2f9e63; --c-hide: #5b7a99; --c-spin: #f0a832; --c-move: #3a86c8; --c-jump: #00a3b4; --c-say: #ef7aa5; --c-size: #8e6cc8; --c-colour: #c2187a; --c-sound: #b07d3c; --c-repeat: #e8862e; --c-forever: #d95336; --c-whenkey: #dd9d00; --c-whenclick: #7cb518; --goal-bg: #fff6dc; --goal-line: #eec95f; } @media (prefers-color-scheme: dark) { :root { --bg: #0f161d; --panel: #1a232e; --panel-2: #212d3a; --ink: #e8eff5; --ink-soft: #9db2c3; --line: #31424f; --shadow: 0 1px 2px rgba(0,0,0,.4), 0 6px 18px rgba(0,0,0,.3); --goal-bg: #2e2a12; --goal-line: #6b5c1e; } } * { box-sizing: border-box; } html, body { height: 100%; } body { margin: 0; background: var(--bg); color: var(--ink); font-family: "M PLUS Rounded 1c", "Hiragino Maru Gothic ProN", ui-rounded, system-ui, sans-serif; -webkit-font-smoothing: antialiased; } .wrap { max-width: 1560px; margin: 0 auto; padding: 12px clamp(10px, 1.6vw, 22px) 26px; } /* ---------- Bilingual labels: small semi-transparent Japanese on top, English below ---------- */ .bi { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 1px; line-height: 1.12; vertical-align: middle; } .bi-c { align-items: center; text-align: center; } .bi-jp { font-size: .6em; font-weight: 700; opacity: .58; letter-spacing: .03em; white-space: nowrap; } .bi-en { font-size: 1em; font-weight: 800; white-space: nowrap; } .goalbar h1 .bi-jp { font-size: .52em; } .char-btn .bi-en { font-size: .84rem; } .char-btn .bi-jp { font-size: .62rem; } .pi-txt { display: flex; flex-direction: column; gap: 2px; } .loop-cap .bi-jp, .mini-block .bi-jp { font-size: .68em; } /* Long explanations: English wraps, Japanese translation stays tiny above */ .bi-wrap { align-items: flex-start; max-width: 100%; } .bi-wrap .bi-en { white-space: normal; line-height: 1.45; } .bi-wrap .bi-jp { white-space: normal; line-height: 1.35; font-size: .68rem; } .goalbar .goal .bi-jp { font-size: .78rem; } .goalbar .goal .bi-en { font-size: 1.08rem; } .exp { display: flex; flex-direction: column; gap: 1px; line-height: 1.4; } .exp .exp-jp { font-size: .68rem; font-weight: 700; opacity: .58; letter-spacing: .02em; } .exp .exp-en { font-size: .95em; font-weight: 700; } .palette-tip .exp-en { font-weight: 500; color: var(--ink-soft); } .modal p .exp-en { font-weight: 700; color: var(--ink-soft); } .sub-bi .bi-jp { font-size: .62rem; } .sub-bi .bi-en { font-size: .74rem; font-weight: 700; opacity: .92; } /* ---------- Lesson tabs ---------- */ .lesson-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; } .lesson-tab { border: 1.5px solid var(--line); background: var(--panel); color: var(--ink-soft); border-radius: 999px; padding: 5px 12px 6px; cursor: pointer; font: inherit; font-weight: 700; font-size: .86rem; min-height: 38px; display: inline-flex; align-items: center; gap: 6px; } .lesson-tab .num { background: var(--panel-2); border: 1px solid var(--line); color: var(--ink); border-radius: 999px; min-width: 24px; height: 24px; padding: 0 5px; display: inline-flex; align-items: center; justify-content: center; font-size: .8rem; font-weight: 800; } .lesson-tab:hover { border-color: var(--ink-soft); } .lesson-tab[aria-selected="true"] { background: #2f9e63; border-color: #2f9e63; color: #fff; } .lesson-tab[aria-selected="true"] .num { background: rgba(255,255,255,.22); border-color: transparent; color: #fff; } .goalbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; background: var(--goal-bg); border: 2px solid var(--goal-line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 12px 16px; margin-bottom: 14px; } .goalbar h1 { margin: 0; font-size: 1.25rem; font-weight: 800; white-space: nowrap; } .goal-tag { background: #f2a007; color: #fff; border-radius: 999px; padding: 5px 13px; font-size: .88rem; font-weight: 800; white-space: nowrap; box-shadow: inset 0 -3px 0 rgba(0,0,0,.18); } .goalbar .goal { margin: 0; font-size: 1.1rem; font-weight: 800; color: var(--ink); flex: 1 1 320px; line-height: 1.5; } .newpiece { background: #fff3d6; color: #8a5a00; border: 1.5px dashed #e6b93f; border-radius: 999px; padding: 4px 12px; font-size: .85rem; font-weight: 800; white-space: nowrap; } @media (prefers-color-scheme: dark) { .newpiece { background: #3a2f14; color: #ffd97a; } } .file-tools { display: flex; gap: 8px; margin-left: auto; } .file-tools button { border: 1.5px solid var(--line); background: var(--panel-2); color: var(--ink); border-radius: 10px; padding: 6px 14px; font: inherit; font-weight: 800; font-size: .9rem; cursor: pointer; min-height: 40px; } .file-tools button:hover { border-color: var(--ink-soft); } /* ---------- Main layout: wide / landscape first ---------- */ .layout { display: grid; grid-template-columns: 232px minmax(0, 1fr) 424px; gap: 14px; align-items: start; } .panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel-2); } .panel-head h2 { margin: 0; font-size: .98rem; font-weight: 800; } .panel-head .hint { font-size: .8rem; color: var(--ink-soft); font-weight: 700; } /* ---------- Palette ---------- */ .palette-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; } .palette-item { display: flex; align-items: center; gap: 9px; padding: 10px 12px; border-radius: 10px 10px 10px 4px; color: #fff; font-weight: 800; font-size: 1rem; line-height: 1.15; cursor: grab; user-select: none; -webkit-user-select: none; touch-action: none; position: relative; box-shadow: inset 0 -3px 0 rgba(0,0,0,.18); } .palette-item::before { content: ""; position: absolute; top: -5px; left: 16px; width: 26px; height: 10px; border-radius: 5px 5px 0 0; background: inherit; } .palette-item:active { cursor: grabbing; } .palette-item small { display: block; font-weight: 700; font-size: .74rem; opacity: .88; } .palette-item .grip { margin-left: auto; opacity: .7; font-size: .95rem; letter-spacing: 1px; } .palette-item.hat { border-radius: 22px 22px 10px 4px; } .palette-item.hat::before { display: none; } .c-show { background: var(--c-show); } .c-hide { background: var(--c-hide); } .c-spin { background: var(--c-spin); } .c-move { background: var(--c-move); } .c-jump { background: var(--c-jump); } .c-say { background: var(--c-say); } .c-size { background: var(--c-size); } .c-colour { background: var(--c-colour); } .c-sound { background: var(--c-sound); } .c-repeat { background: var(--c-repeat); } .c-forever { background: var(--c-forever); } .c-whenkey { background: var(--c-whenkey); color: #3a2a00; } .c-whenclick { background: var(--c-whenclick); } .target-note { border: 1.5px dashed var(--line); border-radius: 10px; padding: 9px 11px; font-size: .86rem; font-weight: 700; color: var(--ink-soft); line-height: 1.4; } .target-note strong { color: var(--ink); } .target-note button { margin-top: 6px; border: 1px solid var(--line); background: var(--panel-2); color: var(--ink); border-radius: 8px; padding: 4px 10px; font: inherit; font-size: .8rem; font-weight: 700; cursor: pointer; min-height: 36px; } .palette-tip { font-size: .82rem; color: var(--ink-soft); font-weight: 500; line-height: 1.5; margin: 0; } .show-more-btn { border: 2px dashed var(--line); background: var(--panel-2); color: var(--ink); border-radius: 10px; padding: 9px 10px; font: inherit; font-weight: 800; font-size: .92rem; cursor: pointer; min-height: 46px; width: 100%; } .show-more-btn:hover { border-color: var(--ink-soft); } .palette-section-label { font-size: .8rem; font-weight: 800; color: var(--ink-soft); } /* ---------- Workspace ---------- */ .char-tabs { display: none; gap: 6px; } .char-tabs.on { display: flex; } .char-tabs button { border: 1.5px solid var(--line); background: var(--panel); color: var(--ink-soft); border-radius: 999px; padding: 4px 12px; font: inherit; font-size: .82rem; font-weight: 800; cursor: pointer; min-height: 36px; } .char-tabs button[aria-selected="true"] { background: var(--ink); color: var(--panel); border-color: var(--ink); } .ws-tools { display: flex; flex-wrap: wrap; gap: 6px; } .ws-tools button { border: 1px solid var(--line); background: var(--panel); color: var(--ink); border-radius: 999px; padding: 4px 12px; font: inherit; font-size: .82rem; font-weight: 700; cursor: pointer; min-height: 36px; } .ws-tools button.danger { color: var(--danger); } .workspace { padding: 14px 14px 20px; min-height: 430px; background: radial-gradient(circle, var(--line) 1.1px, transparent 1.2px) 0 0 / 22px 22px; } .empty-note { border: 1.5px dashed var(--line); border-radius: 10px; padding: 22px 16px; text-align: center; color: var(--ink-soft); font-weight: 700; font-size: .95rem; background: var(--panel); line-height: 1.6; } .slot { height: 8px; border-radius: 99px; margin: 1px 2px; transition: background .08s, height .08s; } .slot.active { background: var(--slot); height: 12px; box-shadow: 0 0 0 2px rgba(255,179,92,.35); } .block { position: relative; user-select: none; -webkit-user-select: none; } .block-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; color: #fff; font-weight: 800; font-size: 1rem; padding: 9px 10px 11px 14px; border-radius: 10px 10px 10px 4px; box-shadow: inset 0 -3px 0 rgba(0,0,0,.18); cursor: grab; touch-action: none; position: relative; } .block-head::before { content: ""; position: absolute; top: -5px; left: 16px; width: 26px; height: 10px; border-radius: 5px 5px 0 0; background: inherit; } .block-head:active { cursor: grabbing; } .block.hat > .block-head { border-radius: 24px 24px 10px 4px; padding-top: 12px; } .block.hat > .block-head::before { display: none; } .b-whenkey > .block-head { color: #3a2a00; } .block.container > .block-head { border-radius: 10px 10px 4px 4px; } .block.hat.container > .block-head { border-radius: 24px 24px 4px 4px; } .block.running > .block-head { outline: 3px solid #ffd23e; outline-offset: 1px; } .block.selected > .block-head { outline: 3px solid var(--ink); outline-offset: 1px; } .block-head .lbl { white-space: nowrap; } .block-head input[type="text"] { flex: 1 1 110px; min-width: 80px; border: none; border-radius: 7px; padding: 5px 9px; font: inherit; font-weight: 700; font-size: .95rem; background: rgba(255,255,255,.94); color: #20303f; min-height: 36px; } .block-head input[type="number"] { width: 62px; border: none; border-radius: 7px; text-align: center; padding: 5px 4px; font: inherit; font-weight: 800; font-size: .95rem; background: rgba(255,255,255,.94); color: #20303f; min-height: 36px; } .block-head select { border: none; border-radius: 7px; padding: 5px 8px; font: inherit; font-weight: 800; font-size: .92rem; background: rgba(255,255,255,.94); color: #20303f; min-height: 36px; cursor: pointer; } .block-head input:focus, .block-head select:focus { outline: 3px solid rgba(255,210,63,.9); } .dirbtn { border: none; border-radius: 7px; width: 40px; height: 36px; min-height: 36px; background: rgba(255,255,255,.94); color: #20303f; font-size: 1.05rem; font-weight: 800; cursor: pointer; } .del { margin-left: auto; flex: 0 0 auto; width: 36px; height: 36px; min-height: 36px; border: none; border-radius: 8px; cursor: pointer; background: rgba(0,0,0,.18); color: inherit; font-size: 1rem; font-weight: 800; line-height: 1; } .del:hover { background: rgba(0,0,0,.32); } .loop-body { border-left: 14px solid; border-radius: 0 0 0 6px; padding: 6px 4px 6px 10px; min-height: 44px; } .b-repeat > .loop-body { border-left-color: var(--c-repeat); } .b-forever > .loop-body { border-left-color: var(--c-forever); } .b-whenkey > .loop-body { border-left-color: var(--c-whenkey); } .b-whenclick > .loop-body { border-left-color: var(--c-whenclick); } .loop-empty { color: var(--ink-soft); font-size: .85rem; font-weight: 700; padding: 6px 4px; } .loop-cap { color: #fff; font-weight: 800; font-size: .82rem; padding: 4px 10px 6px 14px; border-radius: 4px 4px 10px 10px; box-shadow: inset 0 -3px 0 rgba(0,0,0,.18); width: fit-content; min-width: 120px; } .b-repeat > .loop-cap { background: var(--c-repeat); } .b-forever > .loop-cap { background: var(--c-forever); } .b-whenkey > .loop-cap { background: var(--c-whenkey); color: #3a2a00; } .b-whenclick > .loop-cap { background: var(--c-whenclick); } .drag-ghost { position: fixed; z-index: 60; pointer-events: none; opacity: .92; filter: drop-shadow(0 10px 18px rgba(0,0,0,.3)); } .drag-src { opacity: .35; } /* ---------- Stage column : monitor + keyboard ---------- */ .stage-col { display: flex; flex-direction: column; gap: 12px; } .monitor { background: var(--bezel); border-radius: 20px; padding: 14px 14px 10px; box-shadow: var(--shadow), inset 0 1px 0 rgba(255,255,255,.14); } .screen { position: relative; border-radius: 10px; overflow: hidden; aspect-ratio: 4 / 3; background: #aee3f9; } .screen svg.backdrop { position: absolute; inset: 0; width: 100%; height: 100%; } .screen .live-dot { position: absolute; top: 8px; right: 10px; z-index: 6; background: rgba(20,40,30,.55); color: #b8ffd2; font-size: .72rem; font-weight: 800; border-radius: 999px; padding: 3px 10px; display: none; align-items: center; gap: 6px; } .screen .live-dot::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #3ddc74; } .screen.live .live-dot { display: inline-flex; } .bezel-bar { display: flex; align-items: center; justify-content: center; gap: 8px; padding-top: 9px; color: #93a3b5; font-size: .72rem; font-weight: 700; letter-spacing: .14em; } .bezel-bar .cam { width: 8px; height: 8px; border-radius: 50%; background: #57687c; box-shadow: inset 0 0 2px #000; } .stand { display: flex; flex-direction: column; align-items: center; } .stand .neck { width: 74px; height: 20px; background: var(--bezel-2); clip-path: polygon(18% 0, 82% 0, 100% 100%, 0 100%); } .stand .base { width: 190px; height: 12px; background: var(--bezel); border-radius: 6px 6px 10px 10px; } .actor-layer { position: absolute; inset: 0; } .sprite { position: absolute; bottom: 15%; width: 27%; transform-origin: 50% 85%; transition: transform .45s ease, opacity .25s ease, filter .3s ease; cursor: pointer; } .sprite svg { display: block; width: 100%; height: auto; filter: drop-shadow(0 3px 3px rgba(30,60,30,.25)); } .sprite.gone { opacity: 0; pointer-events: none; } .bubble { position: absolute; z-index: 5; background: #fff; color: #20303f; border: 2.5px solid #20303f; border-radius: 14px; padding: 5px 12px; font-weight: 800; font-size: clamp(.8rem, 1.4vw, 1rem); max-width: 62%; line-height: 1.3; transform: translate(-50%, -100%); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .bubble::after { content: ""; position: absolute; left: 50%; bottom: -9px; transform: translateX(-50%); border: 5px solid transparent; border-top: 7px solid #20303f; border-bottom: none; } .rock { position: absolute; bottom: 13.5%; right: 16%; width: 17%; z-index: 2; } .stage-controls { display: flex; gap: 10px; align-items: stretch; } .run-btn { flex: 1; border: none; cursor: pointer; border-radius: 12px; background: var(--run); color: #fff; font: inherit; font-weight: 800; font-size: 1.12rem; padding: 10px 18px; min-height: 52px; box-shadow: inset 0 -4px 0 rgba(0,0,0,.18); } .run-btn:hover { background: var(--run-dark); } .stop-btn { border: none; cursor: pointer; border-radius: 12px; background: var(--stop); color: #fff; font: inherit; font-weight: 800; font-size: 1rem; padding: 10px 16px; min-height: 52px; box-shadow: inset 0 -4px 0 rgba(0,0,0,.18); } .reset-btn { border: 1.5px solid var(--line); background: var(--panel); color: var(--ink); border-radius: 12px; font: inherit; font-weight: 800; font-size: .92rem; padding: 10px 14px; cursor: pointer; min-height: 52px; } /* keyboard (decorative — tap it for the password) */ .keyboard { background: var(--kb); border-radius: 14px; padding: 12px 12px 14px; box-shadow: var(--shadow), inset 0 1px 0 rgba(255,255,255,.14); cursor: pointer; border: none; width: 100%; text-align: left; transition: transform .06s ease, box-shadow .15s ease; } .keyboard:hover { box-shadow: var(--shadow), 0 0 0 3px rgba(255,179,92,.55); } .keyboard:active { transform: translateY(1px); } .kb-row { display: flex; gap: 5px; margin-bottom: 5px; } .kb-row:last-child { margin-bottom: 0; } .key { flex: 1 1 0; background: var(--kb-key); color: var(--kb-ink); border-radius: 6px; text-align: center; font-size: .68rem; font-weight: 700; line-height: 1; padding: 7px 0 6px; box-shadow: inset 0 -2px 0 rgba(0,0,0,.35); font-family: "IBM Plex Mono", monospace; } .key.wide { flex: 2.2 1 0; } .key.space { flex: 6 1 0; } .key.xl { flex: 1.6 1 0; } .kb-note { margin: 6px 2px 0; font-size: .82rem; font-weight: 700; color: var(--ink-soft); text-align: center; } /* character picker */ .picker { padding: 12px 14px 14px; } .picker h3 { margin: 0 0 8px; font-size: .92rem; font-weight: 800; } .picker h3 .who { color: var(--ink-soft); font-size: .8rem; font-weight: 700; } .char-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; } .char-btn { border: 2px solid var(--line); background: var(--panel-2); border-radius: 12px; padding: 6px 2px 4px; cursor: pointer; font: inherit; display: flex; flex-direction: column; align-items: center; gap: 2px; } .char-btn svg { width: 82%; height: auto; display: block; } .char-btn span { font-size: .74rem; font-weight: 800; color: var(--ink-soft); } .char-btn[aria-pressed="true"] { border-color: var(--run); background: #e7f6ee; } @media (prefers-color-scheme: dark) { .char-btn[aria-pressed="true"] { background: #173527; } } .char-btn[aria-pressed="true"] span { color: var(--ink); } .picker + .picker { border-top: 1px dashed var(--line); } /* ---------- Modals ---------- */ .overlay { position: fixed; inset: 0; z-index: 80; background: rgba(15, 25, 35, .55); display: none; align-items: center; justify-content: center; padding: 18px; } .overlay.open { display: flex; } .modal { background: var(--panel); color: var(--ink); border-radius: 18px; box-shadow: 0 18px 50px rgba(0,0,0,.35); width: min(520px, 100%); max-height: 88vh; overflow: auto; padding: 22px 22px 20px; } .modal h2 { margin: 0 0 6px; font-size: 1.3rem; font-weight: 800; } .modal p { margin: 0 0 12px; font-weight: 700; color: var(--ink-soft); line-height: 1.55; font-size: .95rem; } .modal .pw-row { display: flex; gap: 8px; } .modal input[type="text"] { flex: 1; border: 2px solid var(--line); border-radius: 10px; padding: 9px 12px; font: inherit; font-weight: 700; font-size: 1.05rem; background: var(--panel); color: var(--ink); min-height: 48px; font-family: "IBM Plex Mono", monospace; letter-spacing: .04em; } .modal input[type="text"]:focus { outline: 3px solid rgba(255,179,92,.7); border-color: var(--slot); } .modal .go { border: none; background: var(--run); color: #fff; border-radius: 10px; font: inherit; font-weight: 800; font-size: 1rem; padding: 9px 20px; cursor: pointer; min-height: 48px; } .modal .close { border: 1.5px solid var(--line); background: var(--panel-2); color: var(--ink); border-radius: 10px; font: inherit; font-weight: 800; font-size: .92rem; padding: 8px 16px; cursor: pointer; min-height: 44px; margin-top: 14px; } .pw-msg { min-height: 1.5em; margin-top: 10px; font-weight: 800; font-size: .95rem; } .pw-msg.err { color: var(--danger); } .shake { animation: shake .3s ease; } @keyframes shake { 0%,100% { transform: none; } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } } .answer-stage { position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 16 / 8; background: #aee3f9; margin-bottom: 14px; border: 3px solid var(--bezel); } .answer-stage svg.backdrop { position: absolute; inset: 0; width: 100%; height: 100%; } .answer-blocks { background: var(--panel-2); border: 1.5px dashed var(--line); border-radius: 12px; padding: 12px; } .answer-blocks h3 { margin: 0 0 10px; font-size: .92rem; font-weight: 800; color: var(--ink-soft); } .mini-block { color: #fff; font-weight: 800; font-size: .92rem; border-radius: 9px 9px 9px 4px; padding: 7px 12px 9px; box-shadow: inset 0 -3px 0 rgba(0,0,0,.18); width: fit-content; max-width: 100%; } .mini-block.hat { border-radius: 20px 20px 9px 4px; } .mini-kids { margin: 4px 0 4px 14px; display: flex; flex-direction: column; gap: 4px; } .mini-stack { display: flex; flex-direction: column; gap: 4px; } .mini-friend-label { font-weight: 800; font-size: .85rem; color: var(--ink-soft); margin: 10px 0 4px; } .toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%) translateY(20px); background: var(--ink); color: var(--panel); font-weight: 800; font-size: .95rem; border-radius: 999px; padding: 10px 22px; z-index: 90; opacity: 0; pointer-events: none; transition: opacity .2s ease, transform .2s ease; box-shadow: 0 10px 30px rgba(0,0,0,.3); } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } @media (prefers-reduced-motion: reduce) { .shake { animation: none; } .sprite { transition: none; } } /* ---------- Responsive: stack on narrow screens ---------- */ @media (max-width: 1180px) { .layout { grid-template-columns: 210px minmax(0, 1fr); } .stage-col { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 460px) minmax(0, 1fr); align-items: start; } .stage-side { display: flex; flex-direction: column; gap: 12px; } } @media (max-width: 700px) { .layout { grid-template-columns: 1fr; } .stage-col { display: flex; } .palette-body { flex-direction: row; flex-wrap: wrap; } .palette-item { flex: 1 1 140px; } .show-more-btn, .palette-section-label { flex: 1 1 100%; } .target-note, .palette-tip { flex: 1 1 100%; } .workspace { min-height: 300px; } .goalbar h1 { font-size: 1.1rem; } }

Lesson 1

もくひょう🎯 Goal

ピースPieces

タップか ドラッグTap or drag

この レッスンの ピースだけ みせているよ。「もっと みる」を おすと、ほかの ピースも でてきて つかえるよ。Only showing the pieces for this lesson. Tap “Show more” to see and use the other pieces too.

プログラムProgram

Running!
MINI SCRATCH

キーボードを タップすると、せんせいの あいことばを きかれるよTap the keyboard and you will be asked for the teacher's password

キャラを えらぶ(1にんめ)Choose a character (1st)

"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();