Petit Body Simulator

ぷちの体(M5Stack CoreS3)の画面と、タップ・なぞる・長押しの動きを、ブラウザで試すもの。ファーム m5_petit_cloud cloud-0.2.1 / 設定ページ Ver1.1 の数値どおりに描いています。実機の撮影ではありません。

いま: 起動中 長押し
さわり方(マウスでも指でも)
  • 起動中に画面を長押し → 数字が 0 になったら離す → 設定モード(QR)。右に「スマホ」が出ます
  • いつもの顔: タップ=その方を見る/横になぞる=なでる(ハート)/1 秒に 3 回以上タップ=こちょこちょ(まばたき)
  • 下端から上へなぞる → おと・ひかり・ながもち → さらに上へ → いま だれ → さらに上へ → ぷちに おねがい → さらに上へ → 顔
  • 上端から下へなぞる → ぷちに おねがい(直行)。各ページで下へなぞると前へ、おと画面で下へ → 顔
  • 何もないところを 3 回タップ → 顔に戻る。20 秒さわらないと閉じる。おやすみ中は 3 回タップで起きる
  • 実機にはこのほかに、右上を 3 秒おさえて あいことば を入れる「おやすみモード」(cloud-0.3.0)がある。このシミュレータにはまだ無い

スマホ QR を読んで http://192.168.4.1/ を開いたところ

クラウドから(cmd)ぷちの頭脳が体に送るもの

(きせかえアプリの s=。コア設定で貼ったものが入ります)
cmd people。保存して再起動したときも設定ページの「だれ」から入ります

体のセンサーの出来事 IMU・近接

ぷちが送る event 体 → クラウド

右上の文字: NO WIFI/NO CLOUD/CAM(写真を撮っている)/MIC(聞いている)/SEN(センサーを読んで送っている)/ASK CAM・ASK SEN(おねがいを押した直後、クラウドが動くまで 8 秒)。いまの機体は MIC を出す場面がまだありません。

"; // ---------- 状態 ---------- const S = { mode:"boot", bootAt:0, holdStart:0, holdDone:false, setupAt:0, savedAt:0, name:"ぷちまる", faceHex:"034f45", bgHex:"ffffff", tapColor:"", kisekae:"#c=034f45&e=now&m=heart&s=slant&g=none", volume:70, brightness:120, quiet:false, conn:0, battery:82, charging:false, people:["さとおやさん","さくら","りく"], selected:0, // 顔 eyeT:{x:0,y:0,m:0}, eye:{x:0,y:0,m:0}, returnAt:0, blinkAt:0, blinkStart:0, blinking:false, wink:{l:0,r:0}, icon:"", iconAt:0, override:"", overrideUntil:0, camUntil:0, micUntil:0, senUntil:0, askLabel:"", askUntil:0, sleeping:false, pickupResetAt:0, // パネル panel:false, page:0, lastAction:0, askUntil:0, tapBurst:0, lastTap:0, lastOnVol:2, // 入力 press:null, taps:[], lastTouchEv:0, rapid:false, rapidCount:0, rapidLast:0, setupTaps:[], sleepTaps:[], }; const cv = document.getElementById("lcd"), ctx = cv.getContext("2d"); ctx.scale(2,2); const now = () => performance.now(); const F12 = '12px "Zen Maru Gothic","Noto Sans JP",sans-serif', F24 = '500 24px "Zen Maru Gothic","Noto Sans JP",sans-serif', F40 = '700 40px "Zen Maru Gothic","Noto Sans JP",sans-serif', FMONO = '10px ui-monospace,Menlo,monospace', FMONO2 = '18px ui-monospace,Menlo,monospace'; const hex = h => "#" + h.replace("#",""); const accentOf = h => { const c = parseInt(h,16); return `rgb(${((c>>16&255)+255)>>1},${((c>>8&255)+255)>>1},${((c&255)+255)>>1})`; }; // ---------- ログ ---------- const logEl = document.getElementById("log"); function log(kind, name, value) { const d = new Date(), ts = d.toTimeString().slice(0,8); const line = document.createElement("div"); line.className = kind; line.textContent = `${ts} ${kind === "ev" ? "event" : kind === "cmd" ? "cmd " : " "} ${name}${value !== undefined ? " " + JSON.stringify(value) : ""}`; logEl.appendChild(line); logEl.scrollTop = logEl.scrollHeight; } function withWho(v) { if (S.people.length) v.who = {index:S.selected, label:S.people[S.selected]}; return v; } function sendEvent(name, v) { log("ev", name, v); } // ---------- 顔 ---------- function look(x,y,m) { S.eyeT = {x:Math.max(-100,Math.min(100,x)), y:Math.max(-100,Math.min(100,y)), m:Math.max(0,Math.min(100,m))}; S.returnAt = now() + T.lookReturn; } function blink(l,r) { const t = now(); if (l) S.wink.l = t + T.winkMs; if (r) S.wink.r = t + T.winkMs; } function icon(n) { S.icon = n; S.iconAt = now(); } function flashColor(h, ms) { S.override = h; S.overrideUntil = now() + ms; } function scheduleBlink() { S.blinkAt = now() + T.blinkMin + Math.random() * (T.blinkMax - T.blinkMin); } function ellipse(cx,cy,rx,ry,rot,color) { ctx.save(); ctx.translate(cx,cy); ctx.rotate(rot*Math.PI/180); ctx.scale(1, ry/rx); ctx.beginPath(); ctx.arc(0,0,rx,0,Math.PI*2); ctx.fillStyle = color; ctx.fill(); ctx.restore(); } function drawFace(t) { const smooth = 0.15; S.eye.x += (S.eyeT.x - S.eye.x) * smooth; S.eye.y += (S.eyeT.y - S.eye.y) * smooth; S.eye.m += (S.eyeT.m - S.eye.m) * smooth; if (S.returnAt && t > S.returnAt) { S.eyeT = {x:0,y:0,m:0}; S.returnAt = 0; } if (S.pickupResetAt && t > S.pickupResetAt) { S.pickupResetAt = 0; look(0,0,0); } const tt = t * 0.002, sx = S.quiet ? 0 : T.swayX, sy = S.quiet ? 0 : T.swayY; const cx = 160 + Math.round(Math.sin(tt) * sx), cy = 120 + Math.round(Math.cos(tt*0.7) * sy); const epx = Math.round(S.eye.x * 30 / 100), epy = Math.round(S.eye.y * 20 / 100), ms = 20 + Math.round(S.eye.m * 20 / 100); ctx.fillStyle = hex(S.bgHex); ctx.fillRect(0,0,W,H); const col = (S.override && t < S.overrideUntil) ? hex(S.override) : hex(S.faceHex); // 自動まばたき if (!S.blinking && t >= S.blinkAt) { S.blinking = true; S.blinkStart = t; } let lh = 32, rh = 32; if (S.blinking) { const p = (t - S.blinkStart) / T.blinkClose; if (p >= 1) { S.blinking = false; scheduleBlink(); } else { const tri = p < .5 ? p*2 : (1-p)*2; lh = rh = Math.max(3, Math.round(32 - tri*29)); } } if (t < S.wink.l) lh = 3; if (t < S.wink.r) rh = 3; ellipse(cx + epx*0.8, cy + 1 + epy*0.8, 14, 6, 0, col); ellipse(cx - 85 + epx, cy - 30 + epy, 22, lh, 20, col); ellipse(cx + 85 + epx, cy - 30 + epy, 22, rh, -20, col); const my = cy + 45 + epy; ellipse(cx - 18 + epx, my, ms, ms, 0, col); ellipse(cx + 18 + epx, my, ms, ms, 0, col); ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(cx-30+epx, my+10); ctx.lineTo(cx+30+epx, my+10); ctx.lineTo(cx+epx, my+45); ctx.closePath(); ctx.fill(); // icon if (S.icon && t - S.iconAt > T.iconMs) S.icon = ""; const ti = t * 0.005; if (S.icon === "love") { const hx = 260, hy = 40 + Math.sin(ti)*5; ctx.fillStyle = "#f00"; ctx.beginPath(); ctx.arc(hx-6,hy,8,0,7); ctx.fill(); ctx.beginPath(); ctx.arc(hx+6,hy,8,0,7); ctx.fill(); ctx.beginPath(); ctx.moveTo(hx-14,hy); ctx.lineTo(hx+14,hy); ctx.lineTo(hx,hy+20); ctx.closePath(); ctx.fill(); } if (S.icon === "cry") { const drop = Math.abs(Math.sin(ti))*10; for (const x of [cx-85+epx, cx+85+epx]) ellipse(x, cy-5+epy+drop, 5, 10, 0, "#00f"); } // 右上の文字 const labels = []; if (S.conn === 2) labels.push("NO WIFI"); else if (S.conn === 1) labels.push("NO CLOUD"); if (t < S.camUntil) labels.push("CAM"); if (t < S.micUntil) labels.push("MIC"); if (t < S.senUntil) labels.push("SEN"); if (t < S.askUntil && !(t < S.camUntil) && !(t < S.senUntil)) labels.push(S.askLabel); if (labels.length) { ctx.font = '16px Arial, Helvetica, sans-serif'; ctx.textAlign = "right"; ctx.textBaseline = "top"; ctx.fillStyle = "#808080"; ctx.fillText(labels.join(" "), W-6, 5); } } // ---------- パネル ---------- function hasWho() { return S.people.length > 0; } function nearest(v, st) { let b = 0, d = Math.abs(v-st[0]); for (let i=1;i<3;i++){ const dd=Math.abs(v-st[i]); if (dd { ctx.beginPath(); ctx.roundRect(x,y,w,h,r); if (fill) { ctx.fillStyle = fill; ctx.fill(); } else { ctx.strokeStyle = ac; ctx.lineWidth = 1; ctx.stroke(); } }; if (S.page === 1) { ctx.font = F24; ctx.textAlign = "left"; ctx.fillText("いま だれ", 12, 8); S.people.slice(0,6).forEach((p,i) => { const x = 8 + (i%2)*156, y = 44 + Math.floor(i/2)*56; if (i === S.selected) { rr(x,y,148,48,10,ac); ctx.fillStyle = bg; } else { rr(x,y,148,48,10,null); ctx.fillStyle = tx; } ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(p, x+74, y+24); ctx.textBaseline = "top"; }); return; } if (S.page === 2) { ctx.font = F24; ctx.textAlign = "left"; ctx.fillText("ぷちに おねがい", 12, 8); const labels = ["みてみて","かんじて"], subs = [["しゃしんを","とって みてね"],["センサーで","かんじてね"]]; for (let i=0;i<2;i++) { const x = 8 + i*156; rr(x,56,148,72,12,null); ctx.fillStyle = tx; ctx.font = F24; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(labels[i], x+74, 92); ctx.textBaseline = "top"; ctx.font = F12; ctx.fillText(subs[i][0], x+74, 136); ctx.fillText(subs[i][1], x+74, 152); } ctx.font = F12; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillText("3かい タップで もどる", 160, H-6); ctx.textBaseline = "top"; return; } const d = new Date(); const hh = String(d.getHours()).padStart(2,"0"), mm = String(d.getMinutes()).padStart(2,"0"); ctx.font = F40; ctx.textAlign = "center"; ctx.fillText(`${hh}:${mm}`, 160, 4); ctx.font = F24; ctx.fillText(`${d.getMonth()+1}/${d.getDate()} (${"日月火水木金土"[d.getDay()]})`, 130, 60); // 電池 const bx = 206, by = 64; ctx.strokeStyle = "#555"; ctx.lineWidth = 1; ctx.strokeRect(bx+.5,by+.5,22,12); ctx.fillStyle = "#555"; ctx.fillRect(bx+22,by+3,2,6); const fw = Math.round(S.battery*18/100); if (fw>0) { ctx.fillStyle = S.battery <= 20 ? "#f00" : tx; ctx.fillRect(bx+2,by+2,fw,8); } if (S.charging) { ctx.fillStyle = "#ffa500"; ctx.beginPath(); ctx.moveTo(bx+9,by); ctx.lineTo(bx+14,by+6); ctx.lineTo(bx+10,by+6); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(bx+12,by+6); ctx.lineTo(bx+8,by+12); ctx.lineTo(bx+13,by+6); ctx.closePath(); ctx.fill(); } ctx.fillStyle = tx; ctx.textAlign = "left"; ctx.fillText(`${S.battery}%`, 232, 60); const vi = nearest(S.volume, VOL), bi = nearest(S.brightness, BR); const labels = ["おと","ひかり","ながもち"], states = [VOL_L[vi], BR_L[bi], S.quiet ? "ON" : "OFF"]; ctx.textAlign = "center"; for (let i=0;i<3;i++) { const x = Math.floor(W*i/3), w = Math.floor(W/3); rr(x+4,108,w-8,70,8,null); ctx.fillStyle = tx; ctx.fillText(labels[i], x+w/2, 124); ctx.fillText(states[i], x+w/2, 186); } } function panelTouchEnd(p, lx, ly) { const t = now(), dx = lx - p.x, dy = ly - p.y; const burst = () => { if (t - S.lastTap <= 1000) S.tapBurst++; else S.tapBurst = 1; S.lastTap = t; if (S.tapBurst >= 3) { S.tapBurst = 0; closePanel(); log("sys","パネルを閉じた(3 回タップ)"); } }; if (Math.abs(dy) >= 60 && Math.abs(dy) > Math.abs(dx)) { if (dy < 0 && S.page === 2) { closePanel(); log("sys","おねがい画面で上へ → 顔"); return; } if (dy > 0 && S.page === 0) { closePanel(); log("sys","おと画面で下へ → 顔"); return; } let next = S.page; if (dy < 0) next = S.page === 0 ? (hasWho() ? 1 : 2) : 2; else next = S.page === 2 ? (hasWho() ? 1 : 0) : 0; if (next !== S.page) { S.page = next; S.tapBurst = 0; S.lastAction = t; } return; } if (hasWho() && S.page === 0 && dx <= -60) { S.page = 1; S.lastAction = t; return; } if (S.page === 1 && dx >= 60) { S.page = 0; S.lastAction = t; return; } if (S.page === 2) { for (let i=0;i<2;i++) { const x = 8 + i*156; if (p.x >= x && p.x < x+148 && p.y >= 56 && p.y < 128) { const what = i ? "feel" : "look"; closePanel(); sendEvent("ask", withWho({what})); S.askLabel = what === "feel" ? "ASK SEN" : "ASK CAM"; S.askUntil = t + 8000; // クラウド側が受けて動く様子(見本): look → 写真(CAM)、feel → センサー(SEN) setTimeout(() => { if (what === "look") { S.camUntil = now() + 3000; log("cmd","snapshot"); } else { S.senUntil = now() + 1500; log("cmd","sensors"); } }, 900); return; } } burst(); return; } if (S.page === 1) { for (let i=0;i= x && p.x < x+148 && p.y >= y && p.y < y+48) { S.selected = i; S.page = 0; S.lastAction = t; sendEvent("who", {index:i, label:S.people[i], source:"panel"}); return; } } burst(); return; } if (p.y >= 108 && p.y < 178) { S.tapBurst = 0; const col = Math.max(0, Math.min(2, Math.floor(p.x*3/W))); if (col === 0) { const idx = nearest(S.volume, VOL); if (idx !== 0) { S.lastOnVol = idx; S.volume = 0; } else { const n = S.lastOnVol === 1 ? 2 : 1; S.lastOnVol = n; S.volume = VOL[n]; } } else if (col === 1) { const idx = nearest(S.brightness, BR); S.brightness = BR[(idx+1)%3]; } else { S.quiet = !S.quiet; } S.lastAction = t; sendEvent("status", {volume:S.volume, brightness:S.brightness, power_save:S.quiet}); } else burst(); } // ---------- 起動・設定モード ---------- let qrCanvas = null; function makeQr() { try { const div = document.createElement("div"); new QRCode(div, {text:"WIFI:T:WPA;S:petit-setup;P:petitpetit;;", width:164, height:164, correctLevel:QRCode.CorrectLevel.M}); qrCanvas = div.querySelector("canvas"); } catch (e) { qrCanvas = null; } } function drawBoot(t) { ctx.fillStyle = "#000"; ctx.fillRect(0,0,W,H); ctx.font = FMONO; ctx.fillStyle = "#0f0"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillText("Booting... " + FW, 4, 4); ctx.fillStyle = "#ff0"; if (S.mode === "hold") { const holdMs = +document.getElementById("holdSel").value; const remain = Math.ceil((holdMs - (t - S.holdStart)) / 1000); ctx.font = FMONO2; ctx.textAlign = "center"; ctx.fillText(String(Math.max(0,remain)), 160, 200); if (t - S.holdStart >= holdMs) { S.mode = "release"; } } else if (S.mode === "release") { ctx.font = F24; ctx.fillText("ボタンを はなしてね", 6, 208); } else { ctx.font = F24; ctx.fillText("長押しで設定画面", 6, 208); } } function drawSetup(t) { ctx.fillStyle = "#FBF4E8"; ctx.fillRect(0,0,W,H); ctx.fillStyle = "#fff"; ctx.fillRect(2,6,176,176); if (qrCanvas) ctx.drawImage(qrCanvas, 8, 12, 164, 164); else { ctx.fillStyle = "#000"; for (let y=0;y<10;y++) for (let x=0;x<10;x++) if ((x*7+y*3)%4<2) ctx.fillRect(8+x*16.4, 12+y*16.4, 16.4, 16.4); } ctx.fillStyle = "#3A322D"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.font = F24; let y = 16; for (const s of ["スマホの","カメラで","読みとり"]) { ctx.fillText(s, 186, y); y += 28; } y += 8; for (const s of ["つないだら","開いてね"]) { ctx.fillText(s, 186, y); y += 28; } ctx.font = '13px ui-monospace,Menlo,monospace'; ctx.fillText("192.168.4.1", 186, y + 2); ctx.font = F24; ctx.textAlign = "center"; ctx.fillText("5分で戻る・3回タップで戻る", 160, 212); } function drawSaved() { ctx.fillStyle = "#fff"; ctx.fillRect(0,0,W,H); ctx.fillStyle = "#000"; ctx.font = F24; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillText("保存したよ。再起動します", 160, 100); } function sleepStyle() { const m = /(?:^|[#&])s=([a-z]+)/.exec(S.kisekae || ""); return m && ["slant","line","down","up"].includes(m[1]) ? m[1] : "slant"; } function drawSleep() { // face.cpp drawSleepFace(): 灰色 #bcbcbc、閉じた目 4 種、鼻、ハート口、zz(きせかえアプリと同じ形) const c = "#bcbcbc"; ctx.fillStyle = hex(S.bgHex); ctx.fillRect(0,0,W,H); ellipse(160, 121, 14, 6, 0, c); ctx.strokeStyle = c; ctx.lineWidth = 9; ctx.lineCap = "round"; const st = sleepStyle(); for (const side of [-1, 1]) { const x = 160 + side*85, y = 90, w = 44; ctx.beginPath(); if (st === "line") { ctx.moveTo(x-w/2, y); ctx.lineTo(x+w/2, y); } else if (st === "down") { ctx.moveTo(x-w/2, y-4); ctx.quadraticCurveTo(x, y+w*0.45, x+w/2, y-4); } else if (st === "up") { ctx.moveTo(x-w/2, y+6); ctx.quadraticCurveTo(x, y-w*0.5, x+w/2, y+6); } else { const dy = w*0.32; ctx.moveTo(x-w/2, y + (side<0?dy:-dy)); ctx.lineTo(x+w/2, y + (side<0?-dy:dy)); } ctx.stroke(); } const my = 165; ctx.fillStyle = c; ctx.beginPath(); ctx.arc(142,my,20,0,7); ctx.fill(); ctx.beginPath(); ctx.arc(178,my,20,0,7); ctx.fill(); ctx.beginPath(); ctx.moveTo(130,my+10); ctx.lineTo(190,my+10); ctx.lineTo(160,my+45); ctx.closePath(); ctx.fill(); ctx.lineWidth = 7; ctx.lineJoin = "round"; for (const [x,y,sz] of [[118,46,18],[148,26,22]]) { ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+sz,y); ctx.lineTo(x,y+sz); ctx.lineTo(x+sz,y+sz); ctx.stroke(); } } function reboot(reason) { S.mode = "boot"; S.bootAt = now(); S.holdDone = false; S.panel = false; S.sleeping = false; S.press = null; S.taps = []; S.setupTaps = []; setPhone(false); log("sys", "起動" + (reason ? "(" + reason + ")" : "")); setTimeout(() => { if (S.mode === "boot") { toFace(); sendEvent("boot", {fw:FW, name:S.name, color:S.faceHex, bg_color:S.bgHex}); } }, 2000); } function toFace() { S.mode = "face"; setPhone(false); scheduleBlink(); } function enterSetup() { S.mode = "setup"; S.setupAt = now(); S.setupTaps = []; makeQr(); setPhone(true, "main"); log("sys", "設定モード(SoftAP + http://192.168.4.1/)"); } function leaveSetup(saved, what) { if (saved) { S.mode = "saved"; S.savedAt = now(); setPhone(false); log("sys", "保存 → 再起動(" + what + ")"); setTimeout(() => reboot("保存のあと"), 1200); } else { toFace(); log("sys", "設定モードをやめて、いつもの顔へ(" + what + ")"); } } // ---------- スマホ(設定ページ) ---------- const phone = document.getElementById("phone"), phoneWrap = document.getElementById("phoneWrap"); function setPhone(show, page) { phoneWrap.classList.toggle("show", show); if (show) loadPage(page || "main"); } function loadPage(page) { phone.srcdoc = page === "core" ? CORE_PAGE : MAIN_PAGE; phone.onload = () => { const d = phone.contentDocument; if (!d) return; // いまの値を入れる(機体が g_formCfg から入れるぶん) const q = s => d.querySelector(s); if (page === "main") { S.people.forEach((p,i) => { const el = q(`[name=person${i+1}]`); if (el) el.value = p; }); } else { const n = q("[name=display_name]"); if (n) n.value = S.name; const fc = q("[name=face_color]"); if (fc) fc.value = "#" + S.faceHex; const fp = q("#fcPick"); if (fp) fp.value = "#" + S.faceHex; const bh = q("#bgHex"); if (bh) bh.value = S.bgHex; d.querySelectorAll("[data-hex]").forEach(b => b.classList.toggle("sel", b.dataset.hex === S.bgHex)); const ks = q("[name=kisekae]"); if (ks) ks.value = S.kisekae; } d.querySelectorAll("form").forEach(f => f.addEventListener("submit", ev => { ev.preventDefault(); const fd = new FormData(f); const action = f.getAttribute("action") || ""; if (action.includes("save_core")) { const nm = (fd.get("display_name") || "").trim(); if (nm) S.name = nm.slice(0,12); const fc = (fd.get("face_color") || "").replace("#",""); if (/^[0-9a-fA-F]{6}$/.test(fc)) S.faceHex = fc.toLowerCase(); const bg = (fd.get("bg_color") || "").replace("#",""); if (BGS.some(b => b[0] === bg)) S.bgHex = bg; S.kisekae = fd.get("kisekae") || S.kisekae; document.getElementById("sleepSel").value = sleepStyle(); leaveSetup(true, "ぷちのコア設定を保存"); } else { const ppl = []; for (let i=1;i<=6;i++) { const v = (fd.get(`person${i}`) || "").trim(); if (v) ppl.push(v.slice(0,6)); } S.people = ppl; S.selected = 0; document.getElementById("peopleTa").value = ppl.join("\n"); leaveSetup(true, "Wi-Fi とだれを保存"); } })); d.querySelectorAll("a[href]").forEach(a => a.addEventListener("click", ev => { const href = a.getAttribute("href"); if (!href.startsWith("/")) return; ev.preventDefault(); if (href === "/core") loadPage("core"); else if (href === "/cancel") leaveSetup(false, "保存せずに戻る"); else loadPage("main"); })); d.querySelectorAll("button[form]").forEach(b => b.addEventListener("click", ev => { ev.preventDefault(); const f = d.getElementById(b.getAttribute("form")); if (f) f.requestSubmit ? f.requestSubmit() : f.dispatchEvent(new Event("submit", {cancelable:true})); })); }; } // ---------- 入力(input.cpp の判定) ---------- function pos(ev) { const r = cv.getBoundingClientRect(); return {x:Math.round((ev.clientX - r.left) * W / r.width), y:Math.round((ev.clientY - r.top) * H / r.height)}; } cv.addEventListener("pointerdown", ev => { cv.setPointerCapture(ev.pointerId); const p = pos(ev); S.press = {x:p.x, y:p.y, t:now(), lx:p.x, ly:p.y}; if (S.mode === "boot" && !S.holdDone) { S.mode = "hold"; S.holdStart = now(); } }); cv.addEventListener("pointermove", ev => { if (!S.press) return; const p = pos(ev); S.press.lx = p.x; S.press.ly = p.y; }); cv.addEventListener("pointerup", ev => { if (!S.press) return; const p = S.press; S.press = null; const t = now(), dur = t - p.t, dx = p.lx - p.x, dy = p.ly - p.y, adx = Math.abs(dx), ady = Math.abs(dy); if (S.mode === "hold") { S.mode = "boot"; log("sys", "長押しをやめた → ふつうに起動"); return; } if (S.mode === "release") { enterSetup(); return; } if (S.mode === "boot" || S.mode === "saved") return; if (S.mode === "setup") { S.setupTaps.push(t); S.setupTaps = S.setupTaps.filter(x => t - x <= 1000); if (S.setupTaps.length >= 3) leaveSetup(false, "3 回タップ"); return; } if (S.sleeping) { S.sleepTaps.push(t); S.sleepTaps = S.sleepTaps.filter(x => t - x <= 1000); if (S.sleepTaps.length >= 3) { S.sleeping = false; S.sleepTaps = []; sendEvent("wake", "touch"); log("sys","起きた(3 回タップ)"); } return; } if (S.panel) { panelTouchEnd(p, p.lx, p.ly); return; } // いつもの顔 if (dur <= T.tapMaxMs && adx < T.tapMovePx && ady < T.tapMovePx) { S.taps.push(t); S.taps = S.taps.filter(x => t - x <= T.rapidWindow); if (S.taps.length >= T.rapidN) { S.rapid = true; S.rapidCount = Math.max(S.rapidCount, S.taps.length); S.rapidLast = t; blink(1,1); return; } if (S.rapid) { S.rapidCount++; S.rapidLast = t; blink(1,1); return; } look(Math.round((p.x-160)*100/160), Math.round((p.y-120)*100/120), T.tapMouth); if (S.tapColor) flashColor(S.tapColor, T.tapColorMs); if (t - S.lastTouchEv >= T.touchGap) { S.lastTouchEv = t; sendEvent("touch", withWho({x:p.x, y:p.y})); } } else if (p.y >= T.swipeUpStartY && dy <= -T.swipeDy && ady > adx) { openPanel(0); log("sys","下端から上へ → パネル"); } else if (p.y <= T.swipeDownStartY && dy >= T.swipeDy && ady > adx) { openPanel(2); log("sys","上端から下へ → ぷちに おねがい"); } else if (adx >= T.strokeDx && adx > ady) { icon("love"); sendEvent("stroke", withWho({dir: dx > 0 ? "right" : "left"})); } }); cv.addEventListener("pointercancel", () => { S.press = null; if (S.mode === "hold") S.mode = "boot"; }); // ---------- 画面の更新 ---------- function frame() { const t = now(); if (S.rapid && t - S.rapidLast > 1000) { sendEvent("tickle", withWho({count:S.rapidCount})); S.rapid = false; S.rapidCount = 0; S.taps = []; } if (S.mode === "setup" && t - S.setupAt > T.setupTimeout) leaveSetup(false, "5 分たった"); if (S.panel && t - S.lastAction >= T.panelTimeout) { closePanel(); log("sys","20 秒さわらなかった → 顔"); } if (S.mode === "boot" || S.mode === "hold" || S.mode === "release") drawBoot(t); else if (S.mode === "setup") drawSetup(t); else if (S.mode === "saved") drawSaved(); else if (S.sleeping) drawSleep(); else if (S.panel) drawPanel(t); else drawFace(t); // 輝度(省電力・おやすみ) const br = S.sleeping ? T.sleepBrightness : S.brightness; const dim = 1 - br/255; if (dim > 0.02 && S.mode === "face") { ctx.fillStyle = `rgba(0,0,0,${dim*0.85})`; ctx.fillRect(0,0,W,H); } document.getElementById("modeLabel").textContent = ({boot:"起動中", hold:"長押し中", release:"離してね", setup:"設定モード", saved:"保存 → 再起動"})[S.mode] || (S.sleeping ? "おやすみ" : S.panel ? ["おと・ひかり・ながもち","いま だれ","ぷちに おねがい"][S.page] : "いつもの顔"); requestAnimationFrame(frame); } // ---------- 右側の操作 ---------- const $ = s => document.querySelector(s); document.querySelectorAll("[data-look]").forEach(b => b.onclick = () => { const [x,y] = b.dataset.look.split(",").map(Number); const m = +$("#mouthR").value; log("cmd","look",{x,y,mouth:m}); look(x,y,m); }); document.querySelectorAll("[data-blink]").forEach(b => b.onclick = () => { const [l,r] = b.dataset.blink.split(",").map(Number); log("cmd","blink",{left:!!l,right:!!r}); blink(l,r); }); document.querySelectorAll("[data-icon]").forEach(b => b.onclick = () => { log("cmd","icon",{name:b.dataset.icon}); icon(b.dataset.icon); }); $("#hexBtn").onclick = () => { const h = $("#hexIn").value.replace("#","").toLowerCase(); if (/^[0-9a-f]{6}$/.test(h)) { S.faceHex = h; log("cmd","color",{hex:h}); } }; BGS.forEach(([h,n]) => { const b = document.createElement("button"); b.className = "swatch"; b.title = n; b.style.background = "#"+h; b.onclick = () => { S.bgHex = h; log("cmd","color",{bg:h}); }; $("#bgBtns").appendChild(b); }); $("#sleepSel").onchange = e => { S.kisekae = (S.kisekae || "#").replace(/([#&])s=[a-z]+/, "$1s=" + e.target.value); if (!/s=/.test(S.kisekae)) S.kisekae += "&s=" + e.target.value; }; $("#sleepBtn").onclick = () => { log("cmd","sleep"); S.sleeping = true; S.panel = false; }; $("#wakeBtn").onclick = () => { log("cmd","wake"); S.sleeping = false; }; $("#snapBtn").onclick = () => { log("cmd","snapshot"); S.camUntil = now() + 3000; setTimeout(() => sendEvent("snapshot", withWho({ok:true, bytes:18234})), 1800); }; $("#sensBtn").onclick = () => { log("cmd","sensors"); S.senUntil = now() + 1500; sendEvent("sensors", withWho({accel:{x:0.01,y:-0.02,z:0.99}, prox:12, light:340})); }; $("#micBtn").onclick = () => { log("cmd","(将来) listen"); S.micUntil = now() + 3000; }; VOL.concat([100]).forEach(v => { const b = document.createElement("button"); b.textContent = v; b.onclick = () => { S.volume = v; log("cmd","volume",{percent:v}); }; $("#volBtns").appendChild(b); }); BR.forEach(v => { const b = document.createElement("button"); b.textContent = v; b.onclick = () => { S.brightness = v; log("cmd","brightness",{level:v}); }; $("#brBtns").appendChild(b); }); $("#connSel").onchange = e => { S.conn = +e.target.value; }; $("#battR").oninput = e => { S.battery = +e.target.value; }; $("#chgChk").onchange = e => { S.charging = e.target.checked; }; $("#peopleBtn").onclick = () => { S.people = $("#peopleTa").value.split("\n").map(s => s.trim()).filter(Boolean).slice(0,6).map(s => s.slice(0,6)); S.selected = 0; log("cmd","people",{people:S.people}); }; document.querySelectorAll("[data-sens]").forEach(b => b.onclick = () => { const k = b.dataset.sens; log("sys","センサー: " + k); if (k === "pickup") { look(0,-40,60); S.pickupResetAt = now() + 2000; } else if (k === "shake") blink(1,1); else if (k === "putdown") look(0,0,0); else if (k === "pet") { icon("love"); look(0,-30,30); } else if (k === "near") look(0,0,40); setTimeout(() => sendEvent(k, withWho({raw:"(直近 2 秒の値)"})), 2000); }); $("#rebootBtn").onclick = () => reboot("電源を入れ直した"); $("#clearLog").onclick = () => { logEl.textContent = ""; }; document.getElementById("verLine").textContent = `Petit Body Simulator v${SIM_VERSION} / ファーム ${FW}・設定ページ Ver${VER} のコードから`; reboot(); requestAnimationFrame(frame);