Código del paquete · 迪士尼魔法市集:我的小小錢幣王國
CC BY 4.0 · acredita a @SpringFairy · v1
Tómalo, remezcla y enseña con él
El código de abajo es solo el HTML de entrada jugable del paquete. Descarga el ZIP completo para editar o remezclar el juego con todos sus recursos relativos.
Inicio de indicación de edición con IA
Edit this classroom game for interactive whiteboards. Keep large touch targets and whole-class play. Tracking, logins, personal-data collection, and remote API calls are prohibited. Use the downloaded complete ZIP—not only the entry HTML shown below—and preserve its browser-ready folder structure. Preserve the educational goal. Original creator: @SpringFairy on ClassBoard Games (CC BY 4.0).
Solo HTML de entrada · index.html · 416.9 KB
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>皇家歐洲小市集 - 迪士尼魔法錢幣探索</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;800;950&family=Noto+Serif+TC:wght@600;900&display=swap');
* {
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
}
body {
margin: 0;
padding: 0;
background: radial-gradient(circle at center, #d8f3dc 0%, #b7e4c7 70%, #95d5b2 100%);
font-family: 'Noto Serif TC', 'Cinzel', serif;
touch-action: none;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100vw;
}
#canvas-wrapper {
position: relative;
width: 98vw;
height: 96vh;
max-width: 1024px;
max-height: 700px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.25), 0 0 25px rgba(255, 215, 0, 0.5), 0 0 0 4px #ffd700, 0 0 0 8px #3b82f6;
border-radius: 18px;
overflow: hidden;
background: #e0f2fe;
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
</style>
</head>
<body>
<div id="canvas-wrapper">
<canvas id="gameCanvas"></canvas>
</div>
<script>
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function playTone(freq, type, duration, gainVal = 0.15) {
try {
initAudio();
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(gainVal, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
} catch (err) {
console.warn('Audio play restricted:', err);
}
}
function soundCoin() {
playTone(1320, 'sine', 0.12, 0.2);
setTimeout(() => playTone(1760, 'sine', 0.18, 0.15), 60);
}
// 歡樂華麗的迪士尼慶典進行曲 (雙層鐘琴主奏 + 管弦和弦 + 奇妙仙子星芒仙塵)
function playDisneyCelebrationTheme() {
initAudio();
if (!audioCtx) return;
const now = audioCtx.currentTime;
// 輔助:鐘琴與木琴清脆雙振盪器 (基音 + 泛音,自帶歡慶明亮質感)
function playBell(freq, timeOffset, duration, vol = 0.22) {
try {
const osc1 = audioCtx.createOscillator();
const osc2 = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(freq, now + timeOffset);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(freq * 2, now + timeOffset);
gain.gain.setValueAtTime(vol, now + timeOffset);
gain.gain.exponentialRampToValueAtTime(0.0001, now + timeOffset + duration);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(audioCtx.destination);
osc1.start(now + timeOffset);
osc2.start(now + timeOffset);
osc1.stop(now + timeOffset + duration);
osc2.stop(now + timeOffset + duration);
} catch (e) {}
}
// 輔助:伴奏慶典和弦 (溫暖管弦烘托歡樂氣氛)
function playHarmony(freqs, timeOffset, duration, vol = 0.08) {
freqs.forEach(f => {
try {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(f, now + timeOffset);
gain.gain.setValueAtTime(vol, now + timeOffset);
gain.gain.exponentialRampToValueAtTime(0.0001, now + timeOffset + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now + timeOffset);
osc.stop(now + timeOffset + duration);
} catch (e) {}
});
}
// 1. 歡樂主題主旋律 (高亢輕快的進行曲跳躍音型)
const melody = [
{ f: 523.25, t: 0.00, d: 0.16 }, // C5
{ f: 659.25, t: 0.14, d: 0.16 }, // E5
{ f: 783.99, t: 0.28, d: 0.22 }, // G5
{ f: 1046.5, t: 0.44, d: 0.35 }, // C6 (歡樂跳躍高音!)
{ f: 783.99, t: 0.72, d: 0.15 }, // G5
{ f: 880.00, t: 0.85, d: 0.15 }, // A5
{ f: 987.77, t: 0.98, d: 0.16 }, // B5
{ f: 1046.5, t: 1.12, d: 0.16 }, // C6
{ f: 1174.6, t: 1.26, d: 0.18 }, // D6
{ f: 1318.5, t: 1.42, d: 0.38 }, // E6 (慶祝頂峰!)
{ f: 1174.6, t: 1.72, d: 0.16 }, // D6
{ f: 1046.5, t: 1.86, d: 0.22 }, // C6
{ f: 1567.9, t: 2.02, d: 0.26 }, // G6 (驚喜歡呼音!)
// 終曲大齊鳴
{ f: 1046.5, t: 2.22, d: 0.75 } // C6 (大勝利長音)
];
melody.forEach(m => playBell(m.f, m.t, m.d, 0.25));
// 2. 豐富伴奏和弦 (C -> F -> G7 -> 大C歡樂大和弦)
playHarmony([261.63, 329.63, 392.00], 0.00, 0.68, 0.10); // C Major
playHarmony([349.23, 440.00, 523.25], 0.72, 0.68, 0.10); // F Major
playHarmony([392.00, 493.88, 587.33], 1.26, 0.88, 0.11); // G Major
playHarmony([261.63, 392.00, 523.25, 659.25, 1046.5], 2.22, 1.05, 0.14); // Grand Finale C Major
// 3. 迪士尼奇妙仙子星芒仙塵音 (Pixie Dust Glissando 亮晶晶滑音)
const pixieNotes = [1567.98, 1760.00, 1975.53, 2093.00, 2349.32, 2637.02, 3135.96, 3520.00];
pixieNotes.forEach((pf, idx) => {
playBell(pf, 2.30 + idx * 0.07, 0.38, 0.09);
});
}
function soundSuccess() {
playDisneyCelebrationTheme();
}
function soundFailure() {
playTone(280, 'sawtooth', 0.18, 0.18);
setTimeout(() => playTone(210, 'sawtooth', 0.26, 0.2), 140);
}
let currentUtterance = null;
function speak(text, onComplete = null, customRate = null) {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
currentUtterance = utterance; // 鎖定引用,防止 Chromium 垃圾回收提前截斷語音
utterance.lang = 'zh-TW';
utterance.rate = customRate || 0.85; // 預設適中語速,亦可自訂更慢節奏
utterance.pitch = 1.18; // 親切明朗微揚童音
let done = false;
function finish() {
if (done) return;
done = true;
if (currentUtterance === utterance) {
currentUtterance = null;
}
if (typeof onComplete === 'function') {
onComplete();
}
}
utterance.onend = finish;
utterance.onerror = finish;
window.speechSynthesis.speak(utterance);
} else {
if (typeof onComplete === 'function') {
onComplete();
}
}
}
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const V_WIDTH = 960;
const V_HEIGHT = 640;
let scale = 1;
let offsetX = 0;
let offsetY = 0;
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const scaleX = rect.width / V_WIDTH;
const scaleY = rect.height / V_HEIGHT;
scale = Math.min(scaleX, scaleY);
offsetX = (rect.width - V_WIDTH * scale) / 2;
offsetY = (rect.height - V_HEIGHT * scale) / 2;
ctx.setTransform(dpr * scale, 0, 0, dpr * scale, dpr * offsetX, dpr * offsetY);
}
window.addEventListener('resize', resizeCanvas);
let currentStage = 3; // 預設進入第 III 關:市集買賣
let feedback = { text: '', color: 'good', timer: 0 };
let particles = [];
let fireworks = [];
let nextFireworkTime = 0;
let coinDisplaySide = 'reverse'; // 'reverse': 面額面 (1, 5, 50圓), 'obverse': 正面 (肖像面)
// 星芒輔助函式 (修復先前遺漏)
function drawStar(ctx, cx, cy, spikes, outerRadius, innerRadius) {
let rot = (Math.PI / 2) * 3;
let x = cx;
let y = cy;
const step = Math.PI / spikes;
ctx.beginPath();
ctx.moveTo(cx, cy - outerRadius);
for (let i = 0; i < spikes; i++) {
x = cx + Math.cos(rot) * outerRadius;
y = cy + Math.sin(rot) * outerRadius;
ctx.lineTo(x, y);
rot += step;
x = cx + Math.cos(rot) * innerRadius;
y = cy + Math.sin(rot) * innerRadius;
ctx.lineTo(x, y);
rot += step;
}
ctx.lineTo(cx, cy - outerRadius);
ctx.closePath();
ctx.fill();
}
// 粒子系統
function addSparkles(x, y, count = 35) {
const colors = ['#ffd700', '#ff80bf', '#80d8ff', '#b388ff', '#ffffff', '#fff59d'];
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 1.6 + Math.random() * 4.2;
particles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 1.2,
color: colors[Math.floor(Math.random() * colors.length)],
size: 3 + Math.random() * 3.5,
life: 1.0,
decay: 0.015 + Math.random() * 0.02,
isStar: Math.random() > 0.35,
rotation: Math.random() * Math.PI,
rotSpeed: (Math.random() - 0.5) * 0.2
});
}
}
function updateAndDrawParticles(ctx) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.12;
p.life -= p.decay;
if (p.life <= 0) {
particles.splice(i, 1);
} else {
ctx.save();
ctx.fillStyle = p.color;
ctx.globalAlpha = Math.max(0, p.life);
if (p.isStar) {
p.rotation += p.rotSpeed;
ctx.translate(p.x, p.y);
ctx.rotate(p.rotation);
drawStar(ctx, 0, 0, 5, p.size * 1.4, p.size * 0.6);
} else {
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
}
function createFirework() {
const fx = 120 + Math.random() * (V_WIDTH - 240);
const fy = 80 + Math.random() * 160;
const palette = [
['#ff477e', '#ff85a1', '#ffffff'],
['#ffd700', '#fff3b0', '#ffffff'],
['#00d2d3', '#54a0ff', '#ffffff'],
['#a55eea', '#d1d8e0', '#ffffff'],
['#ff9f43', '#feca57', '#ffffff']
];
const colors = palette[Math.floor(Math.random() * palette.length)];
const sparkCount = 36 + Math.floor(Math.random() * 20);
const isMickey = Math.random() < 0.3; // 30% 機率爆出米奇形狀煙火
for (let i = 0; i < sparkCount; i++) {
const angle = (Math.PI * 2 * i) / sparkCount + (Math.random() - 0.5) * 0.2;
const speed = 1.8 + Math.random() * 3.4;
fireworks.push({
x: fx,
y: fy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
color: colors[Math.floor(Math.random() * colors.length)],
size: 2.6 + Math.random() * 2.6,
life: 1.0,
decay: 0.016 + Math.random() * 0.018,
isStar: Math.random() > 0.45
});
}
// 米奇煙火的雙耳
if (isMickey) {
[-24, 24].forEach(earX => {
for (let k = 0; k < 12; k++) {
const ea = (Math.PI * 2 * k) / 12;
const es = 1.2 + Math.random() * 1.5;
fireworks.push({
x: fx + earX,
y: fy - 26,
vx: Math.cos(ea) * es,
vy: Math.sin(ea) * es,
color: '#ffd700',
size: 2.4,
life: 0.9,
decay: 0.02,
isStar: true
});
}
});
}
}
function updateAndDrawFireworks(ctx) {
if (Date.now() > nextFireworkTime) {
createFirework();
nextFireworkTime = Date.now() + 1100 + Math.random() * 1100;
}
for (let i = fireworks.length - 1; i >= 0; i--) {
const f = fireworks[i];
f.x += f.vx;
f.y += f.vy;
f.vy += 0.045;
f.life -= f.decay;
if (f.life <= 0) {
fireworks.splice(i, 1);
} else {
ctx.save();
ctx.globalAlpha = Math.max(0, f.life);
ctx.fillStyle = f.color;
ctx.shadowColor = f.color;
ctx.shadowBlur = 10;
if (f.isStar) {
drawStar(ctx, f.x, f.y, 4, f.size * 1.5, f.size * 0.5);
} else {
ctx.beginPath();
ctx.arc(f.x, f.y, f.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
}
// 繪製明亮陽光版迪士尼樂園經典奇幻城堡與歡樂園區背景
function drawDisneylandBackground(ctx) {
ctx.save();
// 1. 晴朗明亮的迪士尼蔚藍天空漸層
const skyGrad = ctx.createLinearGradient(0, 0, 0, V_HEIGHT);
skyGrad.addColorStop(0, '#5390eb'); // 晴朗天藍
skyGrad.addColorStop(0.35, '#7ebbff'); // 陽光淺藍
skyGrad.addColorStop(0.65, '#bfe3ff'); // 明亮天空
skyGrad.addColorStop(0.85, '#e0f2fe'); // 清新晨光白藍
skyGrad.addColorStop(1, '#fef08a'); // 暖暖金色日光
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, V_WIDTH, V_HEIGHT);
// 2. 陽光光芒射線 (Sunburst Rays)
ctx.save();
const sunX = V_WIDTH / 2;
const sunY = 70;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
ctx.lineWidth = 14;
for (let a = 0; a < Math.PI * 2; a += Math.PI / 8) {
ctx.beginPath();
ctx.moveTo(sunX, sunY);
ctx.lineTo(sunX + Math.cos(a) * 550, sunY + Math.sin(a) * 550);
ctx.stroke();
}
ctx.restore();
// 3. 蓬鬆可愛的迪士尼白雲 (Fluffy Fairytale Clouds)
function drawPuffyCloud(cx, cy, scale) {
ctx.save();
ctx.fillStyle = 'rgba(255, 255, 255, 0.88)';
ctx.beginPath();
ctx.arc(cx, cy, 26 * scale, 0, Math.PI * 2);
ctx.arc(cx + 25 * scale, cy - 10 * scale, 32 * scale, 0, Math.PI * 2);
ctx.arc(cx + 55 * scale, cy, 24 * scale, 0, Math.PI * 2);
ctx.arc(cx + 26 * scale, cy + 12 * scale, 22 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
drawPuffyCloud(90, 80, 0.9);
drawPuffyCloud(230, 110, 0.75);
drawPuffyCloud(720, 75, 0.85);
drawPuffyCloud(860, 105, 0.95);
// 4. 金色奇妙仙子星芒魔法弧線 (Pixie Dust Arc)
ctx.save();
ctx.strokeStyle = 'rgba(255, 193, 7, 0.75)';
ctx.lineWidth = 3.5;
ctx.setLineDash([5, 6]);
ctx.beginPath();
ctx.moveTo(120, 270);
ctx.quadraticCurveTo(V_WIDTH / 2 - 20, 45, V_WIDTH - 140, 250);
ctx.stroke();
ctx.setLineDash([]);
const arcPoints = [0.15, 0.32, 0.48, 0.65, 0.82];
arcPoints.forEach(t => {
const ax = (1 - t) * (1 - t) * 120 + 2 * (1 - t) * t * (V_WIDTH / 2 - 20) + t * t * (V_WIDTH - 140);
const ay = (1 - t) * (1 - t) * 270 + 2 * (1 - t) * t * 45 + t * t * 250;
ctx.fillStyle = '#ffffff';
drawStar(ctx, ax, ay, 4, 7, 2.5);
ctx.fillStyle = '#ffb300';
drawStar(ctx, ax, ay, 4, 4.5, 1.5);
});
drawStar(ctx, V_WIDTH - 140, 250, 5, 11, 4.5);
ctx.restore();
// 5. 陽光下明亮奪目的迪士尼奇幻城堡 (Bright Cinderella Castle)
const cx = V_WIDTH / 2;
const baseY = 360;
// 城堡周圍金色晨曦光環
const castleAura = ctx.createRadialGradient(cx, baseY - 120, 30, cx, baseY - 120, 280);
castleAura.addColorStop(0, 'rgba(255, 255, 255, 0.75)');
castleAura.addColorStop(0.5, 'rgba(255, 235, 160, 0.35)');
castleAura.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = castleAura;
ctx.beginPath();
ctx.arc(cx, baseY - 120, 280, 0, Math.PI * 2);
ctx.fill();
// 遠方淡粉紫尖塔
[-170, -115, 115, 170].forEach((ox, i) => {
const towerW = 28;
const towerH = 120 + (i % 2) * 25;
ctx.fillStyle = '#fce7f3'; // 柔和淺粉白牆
ctx.fillRect(cx + ox - towerW / 2, baseY - towerH, towerW, towerH);
// 亮藍屋頂
ctx.fillStyle = '#2563eb';
ctx.beginPath();
ctx.moveTo(cx + ox, baseY - towerH - 55);
ctx.lineTo(cx + ox - towerW / 2 - 3, baseY - towerH);
ctx.lineTo(cx + ox + towerW / 2 + 3, baseY - towerH);
ctx.closePath();
ctx.fill();
// 金色尖塔小旗
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx + ox, baseY - towerH - 55);
ctx.lineTo(cx + ox, baseY - towerH - 70);
ctx.stroke();
});
// 城堡主體底座與淺象牙白磚牆
ctx.fillStyle = '#ffffff';
ctx.fillRect(cx - 160, baseY - 80, 320, 80);
ctx.fillStyle = '#f1f5f9';
for (let bx = cx - 155; bx < cx + 155; bx += 22) {
ctx.fillRect(bx, baseY - 94, 12, 14);
}
ctx.fillStyle = '#e2e8f0';
ctx.fillRect(cx - 160, baseY - 25, 320, 6);
// 左主副塔 (Left Royal Tower)
ctx.fillStyle = '#ffffff';
ctx.fillRect(cx - 96, baseY - 160, 48, 160);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(cx - 96, baseY - 160, 10, 160);
ctx.fillStyle = '#1d4ed8'; // 蔚藍屋頂
ctx.beginPath();
ctx.moveTo(cx - 72, baseY - 245);
ctx.lineTo(cx - 100, baseY - 160);
ctx.lineTo(cx - 44, baseY - 160);
ctx.closePath();
ctx.fill();
// 金旗
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(cx - 72, baseY - 245);
ctx.lineTo(cx - 72, baseY - 265);
ctx.stroke();
ctx.fillStyle = '#fbbf24';
ctx.beginPath();
ctx.moveTo(cx - 72, baseY - 265);
ctx.lineTo(cx - 86, baseY - 257);
ctx.lineTo(cx - 72, baseY - 250);
ctx.fill();
// 右主副塔 (Right Royal Tower)
ctx.fillStyle = '#ffffff';
ctx.fillRect(cx + 48, baseY - 160, 48, 160);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(cx + 86, baseY - 160, 10, 160);
ctx.fillStyle = '#1d4ed8';
ctx.beginPath();
ctx.moveTo(cx + 72, baseY - 245);
ctx.lineTo(cx + 44, baseY - 160);
ctx.lineTo(cx + 100, baseY - 160);
ctx.closePath();
ctx.fill();
// 金旗
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(cx + 72, baseY - 245);
ctx.lineTo(cx + 72, baseY - 265);
ctx.stroke();
ctx.fillStyle = '#fbbf24';
ctx.beginPath();
ctx.moveTo(cx + 72, baseY - 265);
ctx.lineTo(cx + 86, baseY - 257);
ctx.lineTo(cx + 72, baseY - 250);
ctx.fill();
// 中央高聳主鐘樓塔 (Grand Central Spire & Clock Tower)
ctx.fillStyle = '#ffffff';
ctx.fillRect(cx - 42, baseY - 200, 84, 200);
ctx.fillStyle = '#fbbf24';
ctx.fillRect(cx - 42, baseY - 170, 84, 6);
ctx.fillRect(cx - 42, baseY - 120, 84, 6);
// 中央主蔚藍尖塔
ctx.fillStyle = '#1e40af';
ctx.beginPath();
ctx.moveTo(cx, baseY - 295);
ctx.lineTo(cx - 46, baseY - 200);
ctx.lineTo(cx + 46, baseY - 200);
ctx.closePath();
ctx.fill();
// 金色高聳旗桿與大燕尾旗
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(cx, baseY - 295);
ctx.lineTo(cx, baseY - 324);
ctx.stroke();
ctx.fillStyle = '#f59e0b';
ctx.beginPath();
ctx.moveTo(cx, baseY - 324);
ctx.lineTo(cx + 22, baseY - 314);
ctx.lineTo(cx, baseY - 304);
ctx.fill();
// 迪士尼城堡陽光金光大拱門
ctx.fillStyle = '#fef08a';
ctx.beginPath();
ctx.arc(cx, baseY - 30, 24, Math.PI, 0);
ctx.lineTo(cx + 24, baseY);
ctx.lineTo(cx - 24, baseY);
ctx.closePath();
ctx.fill();
// 鐘樓圓形金光花窗
ctx.beginPath();
ctx.arc(cx, baseY - 150, 16, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#d97706';
ctx.lineWidth = 2.5;
ctx.stroke();
// 6. 明亮繽紛的米奇造型氣球
function drawMickeyBalloon(bx, by, size, color) {
ctx.save();
ctx.fillStyle = color;
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(bx, by, size, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(bx - size * 0.72, by - size * 0.72, size * 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(bx + size * 0.72, by - size * 0.72, size * 0.5, 0, Math.PI * 2);
ctx.fill();
// 氣球亮光
ctx.fillStyle = 'rgba(255, 255, 255, 0.65)';
ctx.beginPath();
ctx.arc(bx - size * 0.35, by - size * 0.35, size * 0.28, 0, Math.PI * 2);
ctx.fill();
// 氣球牽引白線
ctx.strokeStyle = 'rgba(100, 116, 139, 0.65)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(bx, by + size);
ctx.quadraticCurveTo(bx + 4, by + size + 16, bx - 2, by + size + 35);
ctx.stroke();
ctx.restore();
}
drawMickeyBalloon(105, 190, 15, '#ef4444'); // 明亮紅米奇氣球
drawMickeyBalloon(140, 170, 12, '#22c55e'); // 鮮綠米奇氣球
drawMickeyBalloon(825, 175, 15, '#3b82f6'); // 亮藍米奇氣球
drawMickeyBalloon(865, 195, 12, '#f59e0b'); // 鮮黃米奇氣球
// 7. 城堡基座青翠童話小草坡
ctx.fillStyle = '#4ade80';
ctx.beginPath();
ctx.ellipse(cx - 210, baseY + 10, 140, 42, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx + 210, baseY + 10, 140, 42, 0, 0, Math.PI * 2);
ctx.fill();
// 小米奇造型綠雕
function drawMickeyTopiary(tx, ty, r) {
ctx.fillStyle = '#16a34a';
ctx.beginPath();
ctx.arc(tx, ty, r, 0, Math.PI * 2);
ctx.arc(tx - r * 0.7, ty - r * 0.7, r * 0.48, 0, Math.PI * 2);
ctx.arc(tx + r * 0.7, ty - r * 0.7, r * 0.48, 0, Math.PI * 2);
ctx.fill();
}
drawMickeyTopiary(cx - 240, baseY - 12, 16);
drawMickeyTopiary(cx + 240, baseY - 12, 16);
// 8. 迪士尼樂園明亮彩色三角旗與彩燈串 (Bunting Flags)
ctx.strokeStyle = 'rgba(71, 85, 105, 0.4)';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(30, 108);
ctx.quadraticCurveTo(V_WIDTH / 4, 138, V_WIDTH / 2, 108);
ctx.quadraticCurveTo((V_WIDTH * 3) / 4, 138, V_WIDTH - 30, 108);
ctx.stroke();
const flagCount = 26;
for (let i = 0; i <= flagCount; i++) {
const t = i / flagCount;
let lx, ly;
if (t <= 0.5) {
const lt = t * 2;
lx = (1 - lt) * (1 - lt) * 30 + 2 * (1 - lt) * lt * (V_WIDTH / 4) + lt * lt * (V_WIDTH / 2);
ly = (1 - lt) * (1 - lt) * 108 + 2 * (1 - lt) * lt * 138 + lt * lt * 108;
} else {
const lt = (t - 0.5) * 2;
lx = (1 - lt) * (1 - lt) * (V_WIDTH / 2) + 2 * (1 - lt) * lt * ((V_WIDTH * 3) / 4) + lt * lt * (V_WIDTH - 30);
ly = (1 - lt) * (1 - lt) * 108 + 2 * (1 - lt) * lt * 138 + lt * lt * 108;
}
const colors = ['#ef4444', '#f59e0b', '#22c55e', '#3b82f6', '#ec4899', '#a855f7'];
ctx.fillStyle = colors[i % colors.length];
ctx.beginPath();
// 懸掛歡樂三角彩旗
ctx.moveTo(lx - 5, ly);
ctx.lineTo(lx + 5, ly);
ctx.lineTo(lx, ly + 10);
ctx.closePath();
ctx.fill();
}
// 9. 陽光慶典彩帶與星芒
updateAndDrawFireworks(ctx);
ctx.restore();
}
function drawEuropeanAwning(ctx, width) {
const stripWidth = 40;
const strips = Math.ceil(width / stripWidth);
ctx.save();
for (let i = 0; i < strips; i++) {
ctx.fillStyle = i % 2 === 0 ? '#7a1c29' : '#faf5eb';
ctx.beginPath();
ctx.rect(i * stripWidth, 0, stripWidth, 42);
ctx.fill();
ctx.beginPath();
ctx.arc(i * stripWidth + stripWidth / 2, 42, stripWidth / 2, 0, Math.PI);
ctx.fill();
ctx.strokeStyle = '#4a0e16';
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.fillStyle = '#cfa144';
ctx.fillRect(0, 40, width, 4);
ctx.restore();
}
// 羊皮紙魔法橫幅繪製 (修復先前遺漏)
function drawScrollBanner(ctx, cx, cy, w, h, title, subtitle) {
ctx.save();
ctx.translate(cx, cy);
ctx.fillStyle = 'rgba(255, 252, 240, 0.94)';
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 3;
ctx.shadowColor = 'rgba(255, 215, 0, 0.45)';
ctx.shadowBlur = 14;
ctx.beginPath();
ctx.roundRect(-w / 2, -h / 2, w, h, 16);
ctx.fill();
ctx.stroke();
ctx.shadowColor = 'transparent';
ctx.strokeStyle = 'rgba(212, 175, 55, 0.45)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.roundRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 12);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (subtitle) {
ctx.font = '900 20px "Noto Serif TC", serif';
ctx.fillStyle = '#220a40';
ctx.fillText(title, 0, -h * 0.2);
ctx.font = '700 15px "Noto Serif TC", serif';
ctx.fillStyle = '#6a4a10';
ctx.fillText(subtitle, 0, h * 0.25);
} else {
ctx.font = '900 22px "Noto Serif TC", serif';
ctx.fillStyle = '#220a40';
ctx.fillText(title, 0, 1);
}
ctx.restore();
}
// 繪製米奇圖案徽章
function drawMickeyIcon(ctx, x, y, size, color = '#1e293b') {
ctx.save();
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(x - size * 0.78, y - size * 0.78, size * 0.52, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(x + size * 0.78, y - size * 0.78, size * 0.52, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// 繪製米妮經典粉紅波點蝴蝶結
function drawMinnieBow(ctx, x, y, scale = 1) {
ctx.save();
ctx.translate(x, y);
ctx.scale(scale, scale);
// 左側緞帶結
ctx.fillStyle = '#f43f5e';
ctx.strokeStyle = '#be123c';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.bezierCurveTo(-8, -12, -18, -10, -16, 0);
ctx.bezierCurveTo(-18, 10, -8, 12, 0, 0);
ctx.fill();
ctx.stroke();
// 右側緞帶結
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.bezierCurveTo(8, -12, 18, -10, 16, 0);
ctx.bezierCurveTo(18, 10, 8, 12, 0, 0);
ctx.fill();
ctx.stroke();
// 白色波點
ctx.fillStyle = '#ffffff';
[-11, 11].forEach(px => {
ctx.beginPath();
ctx.arc(px, -2, 1.8, 0, Math.PI * 2);
ctx.fill();
});
// 中央結心
ctx.fillStyle = '#e11d48';
ctx.beginPath();
ctx.ellipse(0, 0, 4.5, 5.5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
}
// 繪製米妮圖案徽章 (米奇耳 + 經典粉紅蝴蝶結)
function drawMinnieIcon(ctx, x, y, size) {
ctx.save();
drawMickeyIcon(ctx, x, y, size, '#db2777');
drawMinnieBow(ctx, x, y - size * 0.85, size * 0.08);
ctx.restore();
}
const navButtons = [
{ stage: 1, text: "🏰 第 I 關:迪士尼魔法錢幣", x: 245, y: 76, w: 230, h: 38 },
{ stage: 2, text: "🎀 第 II 關:十位與個位數", x: 490, y: 76, w: 220, h: 38 },
{ stage: 3, text: "🎪 第 III 關:市集買賣", x: 725, y: 76, w: 210, h: 38 }
];
function drawNavHeader(ctx) {
drawEuropeanAwning(ctx, V_WIDTH);
navButtons.forEach(btn => {
const isActive = currentStage === btn.stage;
ctx.save();
ctx.beginPath();
ctx.roundRect(btn.x - btn.w / 2, btn.y - btn.h / 2, btn.w, btn.h, 19);
if (isActive) {
ctx.fillStyle = '#2a3d66';
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 8;
ctx.shadowOffsetY = 3;
ctx.fill();
ctx.strokeStyle = '#cfa144';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.fillStyle = '#fdf4dc';
ctx.font = '900 15px "Noto Serif TC", serif';
} else {
ctx.fillStyle = '#f8f2e4';
ctx.fill();
ctx.strokeStyle = '#b8a68d';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#4a3d36';
ctx.font = '700 14px "Noto Serif TC", serif';
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(btn.text, btn.x, btn.y + 1);
ctx.restore();
});
}
function drawRealTaiwanPortrait(ctx, type, radius, metalColor) {
ctx.save();
ctx.fillStyle = metalColor.reliefDark;
if (type === 'chiang') {
ctx.beginPath();
ctx.arc(-radius * 0.05, -radius * 0.22, radius * 0.32, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-radius * 0.08, -radius * 0.4);
ctx.quadraticCurveTo(-radius * 0.45, -radius * 0.22, -radius * 0.44, -radius * 0.12);
ctx.lineTo(-radius * 0.35, -radius * 0.08);
ctx.lineTo(-radius * 0.38, -radius * 0.02);
ctx.quadraticCurveTo(-radius * 0.36, radius * 0.12, -radius * 0.18, radius * 0.18);
ctx.lineTo(radius * 0.12, radius * 0.24);
ctx.lineTo(radius * 0.24, -radius * 0.15);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(-radius * 0.32, radius * 0.22);
ctx.lineTo(radius * 0.35, radius * 0.28);
ctx.lineTo(radius * 0.28, radius * 0.48);
ctx.lineTo(-radius * 0.25, radius * 0.48);
ctx.closePath();
ctx.fill();
ctx.fillStyle = metalColor.reliefLight;
ctx.beginPath();
ctx.arc(-radius * 0.07, -radius * 0.24, radius * 0.26, 0, Math.PI * 2);
ctx.fill();
} else if (type === 'sun') {
ctx.beginPath();
ctx.arc(0, -radius * 0.18, radius * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-radius * 0.5, radius * 0.46);
ctx.lineTo(-radius * 0.22, radius * 0.16);
ctx.lineTo(0, radius * 0.32);
ctx.lineTo(radius * 0.22, radius * 0.16);
ctx.lineTo(radius * 0.5, radius * 0.46);
ctx.closePath();
ctx.fill();
ctx.fillRect(-radius * 0.18, -radius * 0.05, radius * 0.36, radius * 0.08);
ctx.fillStyle = metalColor.reliefLight;
ctx.beginPath();
ctx.arc(0, -radius * 0.2, radius * 0.28, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawDetailedCoin(ctx, x, y, val, radius, isPressed = false) {
ctx.save();
ctx.translate(x, isPressed ? y + 3 : y);
ctx.shadowColor = 'rgba(0, 0, 0, 0.45)';
ctx.shadowBlur = isPressed ? 4 : 9;
ctx.shadowOffsetY = isPressed ? 2 : 5;
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
const isReverse = (coinDisplaySide === 'reverse');
if (val === 1) {
// 1 元:真實新臺幣紅銅幣 (直徑 20mm)
const grad = ctx.createRadialGradient(-radius * 0.35, -radius * 0.35, radius * 0.08, 0, 0, radius);
grad.addColorStop(0, '#ffa889');
grad.addColorStop(0.28, '#e06f47');
grad.addColorStop(0.72, '#b24320');
grad.addColorStop(0.96, '#7d260e');
grad.addColorStop(1, '#501708');
ctx.fillStyle = grad;
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.strokeStyle = '#611b08';
ctx.lineWidth = Math.max(1.8, radius * 0.065);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, radius * 0.91, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 206, 186, 0.5)';
ctx.lineWidth = Math.max(1, radius * 0.035);
ctx.stroke();
if (isReverse) {
if (radius >= 22) {
const teeth = 36;
ctx.fillStyle = 'rgba(255, 215, 195, 0.45)';
for (let i = 0; i < teeth; i++) {
const a = (i * Math.PI * 2) / teeth;
const ox = Math.cos(a) * (radius * 0.86);
const oy = Math.sin(a) * (radius * 0.86);
ctx.beginPath();
ctx.arc(ox, oy, Math.max(1, radius * 0.035), 0, Math.PI * 2);
ctx.fill();
}
}
const textSize = Math.round(radius * 0.32);
ctx.font = `900 ${textSize}px "Noto Serif TC", serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#421104';
ctx.fillText('壹圓', 0, -radius * 0.38 + 1.2);
ctx.fillStyle = '#ffded0';
ctx.fillText('壹圓', 0, -radius * 0.38);
const numSize = Math.round(radius * 0.72);
ctx.font = `900 ${numSize}px "Cinzel", "Times New Roman", serif`;
ctx.fillStyle = '#471305';
ctx.fillText('1', 0, radius * 0.18 + 1.5);
ctx.fillStyle = '#ffeedd';
ctx.fillText('1', 0, radius * 0.18);
} else {
drawRealTaiwanPortrait(ctx, 'chiang', radius, { reliefDark: '#4a1406', reliefLight: '#ffbeaa' });
const textSize = Math.round(radius * 0.22);
ctx.font = `900 ${textSize}px "Noto Serif TC", serif`;
ctx.textAlign = 'center';
ctx.fillStyle = '#421104';
ctx.fillText('中華民國', 0, -radius * 0.65 + 1);
ctx.fillStyle = '#ffeedd';
ctx.fillText('中華民國', 0, -radius * 0.65);
}
} else if (val === 5) {
// 5 元:真實新臺幣鎳白幣 (直徑 22mm)
const grad = ctx.createRadialGradient(-radius * 0.35, -radius * 0.35, radius * 0.08, 0, 0, radius);
grad.addColorStop(0, '#ffffff');
grad.addColorStop(0.25, '#f0f3f6');
grad.addColorStop(0.65, '#c5ccd3');
grad.addColorStop(0.9, '#8f99a3');
grad.addColorStop(1, '#5f6770');
ctx.fillStyle = grad;
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.strokeStyle = '#4e555d';
ctx.lineWidth = Math.max(2, radius * 0.07);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, radius * 0.9, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
ctx.lineWidth = Math.max(1, radius * 0.04);
ctx.stroke();
if (isReverse) {
if (radius >= 22) {
ctx.save();
ctx.strokeStyle = 'rgba(92, 100, 109, 0.55)';
ctx.lineWidth = Math.max(1, radius * 0.035);
for (let i = 0; i < 8; i++) {
const a = (i * Math.PI * 2) / 8;
const kx = Math.cos(a) * (radius * 0.77);
const ky = Math.sin(a) * (radius * 0.77);
ctx.beginPath();
ctx.arc(kx, ky, radius * 0.08, 0, Math.PI * 1.5);
ctx.stroke();
}
ctx.restore();
}
const textSize = Math.round(radius * 0.32);
ctx.font = `900 ${textSize}px "Noto Serif TC", serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#424850';
ctx.fillText('伍圓', 0, -radius * 0.38 + 1.2);
ctx.fillStyle = '#ffffff';
ctx.fillText('伍圓', 0, -radius * 0.38);
const numSize = Math.round(radius * 0.72);
ctx.font = `900 ${numSize}px "Cinzel", "Times New Roman", serif`;
ctx.fillStyle = '#424850';
ctx.fillText('5', 0, radius * 0.18 + 1.5);
ctx.fillStyle = '#ffffff';
ctx.fillText('5', 0, radius * 0.18);
} else {
drawRealTaiwanPortrait(ctx, 'chiang', radius, { reliefDark: '#3e444c', reliefLight: '#ffffff' });
const textSize = Math.round(radius * 0.22);
ctx.font = `900 ${textSize}px "Noto Serif TC", serif`;
ctx.textAlign = 'center';
ctx.fillStyle = '#424850';
ctx.fillText('中華民國', 0, -radius * 0.65 + 1);
ctx.fillStyle = '#ffffff';
ctx.fillText('中華民國', 0, -radius * 0.65);
}
} else if (val === 10) {
// 10 元:真實新臺幣鎳銅合金幣 (直徑 26mm,介於 5 元與 50 元之間)
const grad = ctx.createRadialGradient(-radius * 0.35, -radius * 0.35, radius * 0.08, 0, 0, radius);
grad.addColorStop(0, '#ffffff');
grad.addColorStop(0.24, '#edf2f7');
grad.addColorStop(0.66, '#b8c3cd');
grad.addColorStop(0.92, '#818d99');
grad.addColorStop(1, '#56606a');
ctx.fillStyle = grad;
ctx.fill();
ctx.shadowColor = 'transparent';
// 外緣滾齒與邊框
ctx.strokeStyle = '#48525b';
ctx.lineWidth = Math.max(2, radius * 0.068);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, radius * 0.91, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.88)';
ctx.lineWidth = Math.max(1, radius * 0.038);
ctx.stroke();
if (isReverse) {
// 防偽同心微紋內圈
if (radius >= 24) {
ctx.save();
ctx.strokeStyle = 'rgba(100, 110, 122, 0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, radius * 0.08, radius * 0.42, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// 上方「10圓」字樣
const topSize = Math.round(radius * 0.28);
ctx.font = `900 ${topSize}px "Noto Serif TC", serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#3c434a';
ctx.fillText('10圓', 0, -radius * 0.45 + 1.2);
ctx.fillStyle = '#ffffff';
ctx.fillText('10圓', 0, -radius * 0.45);
// 中央大「10」
const numSize = Math.round(radius * 0.68);
ctx.font = `900 ${numSize}px "Cinzel", "Times New Roman", serif`;
ctx.fillStyle = '#3c434a';
ctx.fillText('10', 0, radius * 0.16 + 1.5);
ctx.fillStyle = '#ffffff';
ctx.fillText('10', 0, radius * 0.16);
} else {
// 正面國父孫中山先生半身肖像
drawRealTaiwanPortrait(ctx, 'sun', radius, { reliefDark: '#3a4149', reliefLight: '#ffffff' });
const textSize = Math.round(radius * 0.22);
ctx.font = `900 ${textSize}px "Noto Serif TC", serif`;
ctx.textAlign = 'center';
ctx.fillStyle = '#3c434a';
ctx.fillText('中華民國', 0, -radius * 0.65 + 1);
ctx.fillStyle = '#ffffff';
ctx.fillText('中華民國', 0, -radius * 0.65);
}
} else if (val === 50) {
// 50 元:真實新臺幣金黃大幣 (直徑 28mm)
const grad = ctx.createRadialGradient(-radius * 0.32, -radius * 0.32, radius * 0.06, 0, 0, radius);
grad.addColorStop(0, '#fffbe0');
grad.addColorStop(0.22, '#fedc65');
grad.addColorStop(0.68, '#d49b14');
grad.addColorStop(0.92, '#966a05');
grad.addColorStop(1, '#664500');
ctx.fillStyle = grad;
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.save();
ctx.strokeStyle = '#734e00';
ctx.lineWidth = Math.max(2, radius * 0.06);
ctx.setLineDash([Math.max(2.5, radius * 0.07), Math.max(2, radius * 0.055)]);
ctx.beginPath();
ctx.arc(0, 0, radius * 0.94, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.arc(0, 0, radius * 0.84, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 245, 170, 0.75)';
ctx.lineWidth = Math.max(1.2, radius * 0.035);
ctx.stroke();
if (isReverse) {
const topTextSize = Math.round(radius * 0.24);
ctx.font = `900 ${topTextSize}px "Noto Serif TC", serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#523400';
ctx.fillText('50圓', 0, -radius * 0.58 + 1.2);
ctx.fillStyle = '#fffce0';
ctx.fillText('50圓', 0, -radius * 0.58);
// 稻穗浮雕
if (radius >= 26) {
ctx.save();
function drawRiceEarStalk(side) {
const grainCount = 5;
for (let i = 0; i < grainCount; i++) {
const t = i / (grainCount - 1);
const angle = side * (0.35 + t * 0.7) * Math.PI;
const gr = radius * 0.65;
const gx = Math.cos(angle) * gr;
const gy = Math.sin(angle) * gr * 0.82;
ctx.save();
ctx.translate(gx, gy);
ctx.rotate(angle + (side > 0 ? 0.95 : -0.95));
ctx.fillStyle = '#6e4800';
ctx.beginPath();
ctx.ellipse(0, 1, radius * 0.095, radius * 0.048, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fffca8';
ctx.beginPath();
ctx.ellipse(0, 0, radius * 0.09, radius * 0.042, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
drawRiceEarStalk(1);
drawRiceEarStalk(-1);
ctx.restore();
}
// 防偽光學橢圓窗
if (radius >= 26) {
ctx.save();
ctx.fillStyle = 'rgba(107, 72, 0, 0.22)';
ctx.strokeStyle = 'rgba(255, 230, 130, 0.7)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.ellipse(0, -radius * 0.05, radius * 0.44, radius * 0.32, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = 'rgba(255, 240, 150, 0.32)';
ctx.lineWidth = 0.8;
for (let ly = -radius * 0.28; ly <= radius * 0.18; ly += radius * 0.055) {
ctx.beginPath();
ctx.moveTo(-radius * 0.32, ly);
ctx.lineTo(radius * 0.32, ly);
ctx.stroke();
}
ctx.font = `900 ${Math.round(radius * 0.38)}px "Cinzel", serif`;
ctx.fillStyle = '#fffdf0';
ctx.fillText('50', 0, -radius * 0.05);
ctx.restore();
}
// 盲人觸摸點
if (radius >= 26) {
ctx.fillStyle = '#523400';
const dotOffsets = [-radius * 0.22, 0, radius * 0.22];
dotOffsets.forEach(dx => {
ctx.beginPath();
ctx.arc(dx, radius * 0.58 + 1, radius * 0.042, 0, Math.PI * 2);
ctx.fill();
});
ctx.fillStyle = '#fffce0';
dotOffsets.forEach(dx => {
ctx.beginPath();
ctx.arc(dx, radius * 0.58, radius * 0.04, 0, Math.PI * 2);
ctx.fill();
});
}
} else {
drawRealTaiwanPortrait(ctx, 'sun', radius, { reliefDark: '#5e3c00', reliefLight: '#fff7ba' });
const textSize = Math.round(radius * 0.22);
ctx.font = `900 ${textSize}px "Noto Serif TC", serif`;
ctx.textAlign = 'center';
ctx.fillStyle = '#523400';
ctx.fillText('中華民國', 0, -radius * 0.65 + 1);
ctx.fillStyle = '#fffce0';
ctx.fillText('中華民國', 0, -radius * 0.65);
}
}
ctx.restore();
}
// 第一關資料
let s1Coins = [];
let s1TargetCoin = { val: 50, name: "50 元", spoken: "五十元", hint: "金色大硬幣,背面有金黃稻穗" };
let s1Locked = false;
function initStage1() {
s1Locked = false;
// 依幣面真實直徑等比排列:1元(小), 5元(中), 10元(大銀幣), 50元(特大金幣)
s1Coins = [
{ val: 1, name: "1 元", spoken: "一元", x: 190, y: 395, r: 42 },
{ val: 5, name: "5 元", spoken: "五元", x: 370, y: 392, r: 48 },
{ val: 10, name: "10 元", spoken: "十元", x: 560, y: 388, r: 56 },
{ val: 50, name: "50 元", spoken: "五十元", x: 765, y: 380, r: 66 }
];
const targets = [
{ val: 1, name: "1 元", spoken: "一元", hint: "找找看最小顆、紅銅色、上面寫著 1 的硬幣喔!" },
{ val: 5, name: "5 元", spoken: "五元", hint: "找找看銀白色、比 1 元大一點、上面寫著 5 的硬幣!" },
{ val: 10, name: "10 元", spoken: "十元", hint: "找找看銀白色、比 5 元更大、上面寫著 10 的硬幣喔!" },
{ val: 50, name: "50 元", spoken: "五十元", hint: "找找看最大顆、金黃色、兩旁有金黃稻穗的 50 元金幣!" }
];
s1TargetCoin = targets[Math.floor(Math.random() * targets.length)];
speak(`請幫忙找出${s1TargetCoin.spoken}硬幣!`);
}
// 第二關資料:1-100 金額產生庫 (大、中、小三層洗牌牌組,不連續重複)
const s2SmallPool = [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20];
const s2MidPool = [21, 23, 25, 26, 28, 30, 32, 35, 36, 38, 40, 42, 45, 47, 50, 52, 55, 56, 58, 60];
const s2LargePool = [62, 65, 67, 70, 72, 75, 76, 78, 80, 82, 85, 86, 88, 90, 92, 95, 96, 98, 99, 100];
let s2Decks = {
small: [],
mid: [],
large: []
};
let s2TierCycle = ['small', 'large', 'mid', 'small', 'mid', 'large'];
let s2TierIndex = 0;
let s2LastGoal = null;
function s2Shuffle(arr) {
const copy = [...arr];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function getNextS2Goal() {
// 輪替大、中、小金額階層,確保題目有大有小、跳躍豐富且不一直重複
const tier = s2TierCycle[s2TierIndex % s2TierCycle.length];
s2TierIndex++;
if (!s2Decks[tier] || s2Decks[tier].length === 0) {
let pool = (tier === 'small') ? s2SmallPool : (tier === 'mid' ? s2MidPool : s2LargePool);
let shuffled = s2Shuffle(pool);
// 避免重新洗牌後抽出的第一題與上一題相同
if (shuffled[0] === s2LastGoal && shuffled.length > 1) {
[shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]];
}
s2Decks[tier] = shuffled;
}
let goal = s2Decks[tier].pop();
if (goal === s2LastGoal && s2Decks[tier].length > 0) {
const alt = s2Decks[tier].pop();
s2Decks[tier].push(goal);
goal = alt;
}
s2LastGoal = goal;
return goal;
}
let s2Goal = 56;
let s2TensCoins = []; // 可放 50元 與 10元
let s2OnesCoins = []; // 可放 5元 與 1元
let s2Locked = false;
function initStage2() {
s2Locked = false;
s2Goal = getNextS2Goal();
s2TensCoins = [];
s2OnesCoins = [];
feedback.text = '';
const tensGoal = Math.floor(s2Goal / 10) * 10;
const onesGoal = s2Goal % 10;
let speechPrompt = `金額 ${s2Goal} 元,請在十位放入十位硬幣,個位放入個位硬幣`;
if (tensGoal === 0) {
speechPrompt = `金額 ${s2Goal} 元,十位不用放,請在個位放入個位硬幣`;
} else if (onesGoal === 0) {
speechPrompt = `金額 ${s2Goal} 元,請在十位放入十位硬幣,個位不用放`;
}
speak(speechPrompt, null, 0.72);
}
// 第三關資料:迪士尼卡通人物走進店裡購買食材 (櫃台、食材標價1-100元、答對放煙火說謝謝光臨)
const ingredientCategories = [
{
categoryName: "🥛 每日新鮮乳品與農場食材",
items: [
{ id: 101, name: "優質農場鮮雞蛋", price: 5, icon: "🥚" },
{ id: 102, name: "特級純純鮮奶", price: 18, icon: "🥛" },
{ id: 103, name: "發酵草飼奶油", price: 28, icon: "🧈" },
{ id: 104, name: "切達濃郁起司", price: 45, icon: "🧀" },
{ id: 105, name: "皇家鮮奶油大桶", price: 68, icon: "🍦" }
]
},
{
categoryName: "🍎 迪士尼果園鮮採蔬果",
items: [
{ id: 201, name: "陽光脆胡蘿蔔", price: 4, icon: "🥕" },
{ id: 202, name: "香甜蜜富士蘋果", price: 9, icon: "🍎" },
{ id: 203, name: "白蘑菇鮮採包", price: 15, icon: "🍄" },
{ id: 204, name: "鮮摘特級紅草莓", price: 36, icon: "🍓" },
{ id: 205, name: "小熊百花純蜂蜜", price: 75, icon: "🍯" }
]
},
{
categoryName: "🌾 奇幻烘焙穀物與頂級調料",
items: [
{ id: 301, name: "黃金多汁香檸檬", price: 3, icon: "🍋" },
{ id: 302, name: "特級白白有機麵粉", price: 12, icon: "🌾" },
{ id: 303, name: "綜合堅果烘焙包", price: 32, icon: "🌰" },
{ id: 304, name: "比利時黑巧克力磚", price: 56, icon: "🍫" },
{ id: 305, name: "頂級加拿大楓糖漿", price: 88, icon: "🍁" }
]
},
{
categoryName: "🎉 皇家盛宴特選豪華食材",
items: [
{ id: 401, name: "鮮甜紫皮洋蔥", price: 6, icon: "🧅" },
{ id: 402, name: "高山野生藍莓盒", price: 22, icon: "🫐" },
{ id: 403, name: "金色萬聖大南瓜", price: 48, icon: "🎃" },
{ id: 404, name: "進口紅寶石櫻桃", price: 65, icon: "🍒" },
{ id: 405, name: "慶典海陸食材大禮盒", price: 100, icon: "🦞" }
]
}
];
// 迪士尼經典卡通人物小顧客陣容
const disneyCustomers = [
{ id: 'minnie', name: '米妮', type: 'minnie', tag: '🎀 迪士尼米妮', quote: '老闆,我想買草莓做蛋糕!', targetIdx: 3 },
{ id: 'donald', name: '唐老鴨', type: 'donald', tag: '⚓ 迪士尼唐老鴨', quote: '呱呱!我要買麵粉做煎餅!', targetIdx: 1 },
{ id: 'pooh', name: '小熊維尼', type: 'pooh', tag: '🍯 迪士尼維尼', quote: '我想買甜甜的百花蜂蜜!', targetIdx: 4 },
{ id: 'goofy', name: '高飛狗', type: 'goofy', tag: '🎩 迪士尼高飛', quote: '啊哈!我要買大南瓜煮濃湯!', targetIdx: 2 },
{ id: 'tigger', name: '跳跳虎', type: 'tigger', tag: '🐯 迪士尼跳跳虎', quote: '跳跳~我想買濃郁起司!', targetIdx: 3 },
{ id: 'daisy', name: '黛西', type: 'daisy', tag: '💜 迪士尼黛西', quote: '老闆,我想買進口紅櫻桃!', targetIdx: 3 },
{ id: 'chip', name: '奇奇蒂蒂', type: 'chip', tag: '🌰 迪士尼松鼠', quote: '吱吱!我們要買綜合堅果!', targetIdx: 2 }
];
let currentCategoryIndex = 0;
let currentCustomerIndex = 0;
let currentMarketItems = [];
let currentCustomer = disneyCustomers[0];
let selectedShopItem = ingredientCategories[0].items[0];
let insertedRegisterCoins = [];
let s3Locked = false;
let s3Celebrating = false;
// 卡通人物走進店裡動畫變數
let s3CustomerWalkX = 125;
let s3CustomerTargetX = 125;
let s3CustomerIsWalking = true;
let s3WalkStep = 0;
function setupStage3Scene(catIdx, custIdx) {
currentCategoryIndex = catIdx % ingredientCategories.length;
currentCustomerIndex = custIdx % disneyCustomers.length;
currentCustomer = disneyCustomers[currentCustomerIndex];
const category = ingredientCategories[currentCategoryIndex];
const startX = 136;
const gap = 172;
currentMarketItems = category.items.map((item, idx) => ({
...item,
x: Math.round(startX + idx * gap),
y: 228,
w: 156,
h: 98
}));
const targetIdx = currentCustomer.targetIdx % currentMarketItems.length;
selectedShopItem = currentMarketItems[targetIdx];
insertedRegisterCoins = [];
s3Locked = false;
s3Celebrating = false;
// 觸發走進店裡面動畫
s3CustomerWalkX = 125;
s3CustomerIsWalking = false;
s3CustomerTargetX = 125;
s3CustomerIsWalking = true;
s3WalkStep = 0;
}
function initStage3() {
setupStage3Scene(currentCategoryIndex, currentCustomerIndex);
feedback.text = '';
speak(`歡迎光臨迪士尼食材店!${currentCustomer.name} 走進店裡想買【${selectedShopItem.name}】,標價是 ${selectedShopItem.price} 元,請在櫃台托盤放入剛剛好的錢幣!`);
}
function advanceStage3Scene() {
s3CustomerWalkX = -40; s3CustomerIsWalking = true;
// 輪替到下一位迪士尼卡通人物走進店裡,並換下一批食材(全部換圖片!)
currentCustomerIndex = (currentCustomerIndex + 1) % disneyCustomers.length;
currentCategoryIndex = (currentCategoryIndex + 1) % ingredientCategories.length;
setupStage3Scene(currentCategoryIndex, currentCustomerIndex);
feedback.text = '';
speak(`下一位客人來囉!${currentCustomer.name} 走進店裡想買【${selectedShopItem.name}】,標價是 ${selectedShopItem.price} 元!`);
}
function renderStage1Canvas(ctx) {
drawScrollBanner(
ctx,
V_WIDTH / 2,
150,
680,
74,
`請幫忙找出【${s1TargetCoin.name}】硬幣`,
`米奇提示:${s1TargetCoin.hint}`
);
// 明亮版迪士尼魔法托盤基座 (暖白奶黃雲石 + 金雕邊框)
ctx.save();
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 3.5;
ctx.shadowColor = 'rgba(245, 158, 11, 0.35)';
ctx.shadowBlur = 18;
ctx.beginPath();
ctx.roundRect(130, 275, 700, 230, 28);
ctx.fill();
ctx.stroke();
ctx.shadowColor = 'transparent';
// 經典米奇標誌金雕徽章
function drawMickeyIcon(mx, my, size) {
ctx.fillStyle = '#f59e0b';
ctx.beginPath();
ctx.arc(mx, my, size, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(mx - size * 0.9, my - size * 0.9, size * 0.6, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(mx + size * 0.9, my - size * 0.9, size * 0.6, 0, Math.PI * 2);
ctx.fill();
}
drawMickeyIcon(175, 310, 12);
drawMickeyIcon(785, 310, 12);
ctx.fillStyle = '#b45309';
ctx.font = '900 16px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.fillText("✨ DISNEYLAND MAGICAL COIN PEDESTAL ✨", V_WIDTH / 2, 310);
// 各硬幣下方的柔和陽光光環與本體
s1Coins.forEach(c => {
ctx.save();
const aura = ctx.createRadialGradient(c.x, c.y, 10, c.x, c.y, c.r + 25);
aura.addColorStop(0, 'rgba(253, 224, 71, 0.65)');
aura.addColorStop(0.65, 'rgba(254, 240, 138, 0.3)');
aura.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = aura;
ctx.beginPath();
ctx.arc(c.x, c.y, c.r + 25, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
drawDetailedCoin(ctx, c.x, c.y, c.val, c.r, false);
});
ctx.restore();
}
function renderStage2Canvas(ctx) {
const tensGoal = Math.floor(s2Goal / 10) * 10;
const onesGoal = s2Goal % 10;
let s2SubTitle = `請在十位放入十位硬幣 (50/10元) | 個位放入個位硬幣 (5/1元)`;
if (tensGoal === 0) {
s2SubTitle = `十位為 0 不用放 | 請在個位放入 ${onesGoal} 元 (5/1元)`;
} else if (onesGoal === 0) {
s2SubTitle = `請在十位放入 ${tensGoal} 元 (50/10元) | 個位為 0 不用放`;
}
// 橫幅標題改為 「🎯 金額:XX 元」(移除「目標」兩字)
drawScrollBanner(
ctx,
V_WIDTH / 2,
145,
710,
74,
`🎯 金額:${s2Goal} 元`,
s2SubTitle
);
ctx.save();
// 左側:米奇十位盤 (標題純粹為 「十位」)
ctx.fillStyle = 'rgba(240, 246, 255, 0.94)';
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.roundRect(110, 205, 350, 235, 18);
ctx.fill();
ctx.stroke();
// 盤頂裝飾條與米奇圖案
ctx.fillStyle = '#dbeafe';
ctx.beginPath();
ctx.roundRect(113, 208, 344, 40, [15, 15, 0, 0]);
ctx.fill();
drawMickeyIcon(ctx, 230, 228, 12, '#1e40af');
const tensSum = s2TensCoins.reduce((a, b) => a + b, 0);
ctx.fillStyle = '#1e3a8a';
ctx.font = '900 20px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText("十位", 285, 228);
ctx.font = '700 14px "Noto Serif TC", serif';
ctx.fillStyle = '#2563eb';
ctx.fillText(`(${tensSum} 元)`, 340, 228);
if (s2TensCoins.length === 0) {
ctx.fillStyle = tensGoal === 0 ? '#16a34a' : '#94a3b8';
ctx.font = 'italic 15px "Noto Serif TC", serif';
ctx.fillText(tensGoal === 0 ? "✨ 十位為 0,不用放硬幣喔!" : "請點擊下方 50 或 10 元放入十位...", 285, 325);
} else {
// 十位盤硬幣排列展示 (5欄網格排列,整齊不超出,支援 50 與 10)
s2TensCoins.forEach((val, idx) => {
const col = idx % 5;
const row = Math.floor(idx / 5);
const px = 150 + col * 66;
const py = 285 + row * 54;
drawDetailedCoin(ctx, px, py, val, val === 50 ? 28 : 25);
});
}
// 右側:米妮個位盤 (標題純粹為 「個位」)
ctx.fillStyle = 'rgba(255, 245, 247, 0.94)';
ctx.strokeStyle = '#db2777';
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.roundRect(495, 205, 355, 235, 18);
ctx.fill();
ctx.stroke();
// 盤頂裝飾條與米妮圖案
ctx.fillStyle = '#fce7f3';
ctx.beginPath();
ctx.roundRect(498, 208, 349, 40, [15, 15, 0, 0]);
ctx.fill();
drawMinnieIcon(ctx, 615, 228, 12);
const onesSum = s2OnesCoins.reduce((a, b) => a + b, 0);
ctx.fillStyle = '#9d174d';
ctx.font = '900 20px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText("個位", 672, 228);
ctx.font = '700 14px "Noto Serif TC", serif';
ctx.fillStyle = '#db2777';
ctx.fillText(`(${onesSum} 元)`, 725, 228);
if (s2OnesCoins.length === 0) {
ctx.fillStyle = onesGoal === 0 ? '#16a34a' : '#f472b6';
ctx.font = 'italic 15px "Noto Serif TC", serif';
ctx.fillText(onesGoal === 0 ? "✨ 個位為 0,不用放硬幣喔!" : "請點擊下方 5 或 1 元放入個位...", 672, 325);
} else {
// 個位盤硬幣排列展示 (5欄網格排列,整齊不超出,支援 5 與 1)
s2OnesCoins.forEach((val, idx) => {
const col = idx % 5;
const row = Math.floor(idx / 5);
const px = 536 + col * 66;
const py = 285 + row * 54;
drawDetailedCoin(ctx, px, py, val, val === 5 ? 24 : 21);
});
}
ctx.restore();
// 下方硬幣分配列:提供 50、10、5、1 元完整四種硬幣!
ctx.save();
ctx.fillStyle = 'rgba(255, 252, 245, 0.95)';
ctx.beginPath();
ctx.roundRect(110, 455, 740, 95, 16);
ctx.fill();
ctx.strokeStyle = '#cfa144';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.fillStyle = '#5c4b3f';
ctx.font = '900 15px "Noto Serif TC", serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText("點擊硬幣放入:", 125, 502);
// 50 元 (進十位盤)
drawDetailedCoin(ctx, 268, 502, 50, 32);
// 10 元 (進十位盤)
drawDetailedCoin(ctx, 350, 502, 10, 28);
// 5 元 (進個位盤)
drawDetailedCoin(ctx, 428, 502, 5, 25);
// 1 元 (進個位盤)
drawDetailedCoin(ctx, 498, 502, 1, 21);
drawCanvasButton(ctx, 642, 502, 110, 44, "確認檢查", '#1e3a8a', '#ffd700');
drawCanvasButton(ctx, 768, 502, 92, 44, "重置盤面", '#be123c', '#ffe4e6');
ctx.restore();
}
// 繪製小對話氣泡輔助函式
function drawSpeechBubble(ctx, x, y, w, h, text, isCelebrating, arrowSide = 'bottom') {
ctx.save();
ctx.fillStyle = isCelebrating ? '#fef3c7' : 'rgba(255, 255, 255, 0.95)';
ctx.strokeStyle = isCelebrating ? '#f59e0b' : '#94a3b8';
ctx.lineWidth = 2;
ctx.shadowColor = isCelebrating ? 'rgba(245, 158, 11, 0.4)' : 'rgba(0,0,0,0.12)';
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.roundRect(x - w / 2, y - h / 2, w, h, 8);
ctx.fill();
ctx.stroke();
ctx.shadowColor = 'transparent';
// 氣泡尖角
ctx.beginPath();
if (arrowSide === 'bottom') {
ctx.moveTo(x - 6, y + h / 2);
ctx.lineTo(x, y + h / 2 + 7);
ctx.lineTo(x + 6, y + h / 2);
}
ctx.fillStyle = isCelebrating ? '#fef3c7' : 'rgba(255, 255, 255, 0.95)';
ctx.fill();
ctx.stroke();
ctx.fillStyle = isCelebrating ? '#b45309' : '#334155';
ctx.font = '900 12px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y + 1);
ctx.restore();
}
// 繪製小顧客的點餐/購物對話框 (食材改為圖片,與貨架標價圖片完全一模一樣!)
function drawCustomerSpeechBubble(ctx, x, y, selectedItem, isCelebrating, isWalking) {
if (isCelebrating) {
drawSpeechBubble(ctx, x, y, 140, 32, "買到了!謝謝!", true, 'bottom');
return;
}
if (isWalking) {
drawSpeechBubble(ctx, x, y, 140, 30, "走進店裡囉~", false, 'bottom');
return;
}
// 正在櫃台選購/點餐:包含【我想買】 + 【食材圖示圖片】 + 【標價元】
const w = 160;
const h = 42;
ctx.save();
// 對話氣泡背景 (白玉微暖 + 迪士尼金框)
ctx.fillStyle = 'rgba(255, 255, 255, 0.98)';
ctx.strokeStyle = '#cda250';
ctx.lineWidth = 2.2;
ctx.shadowColor = 'rgba(0, 0, 0, 0.15)';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.roundRect(x - w / 2, y - h / 2, w, h, 12);
ctx.fill();
ctx.stroke();
ctx.shadowColor = 'transparent';
// 氣泡底部小三角指示箭頭 (指向人物頭部)
ctx.beginPath();
ctx.moveTo(x - 6, y + h / 2);
ctx.lineTo(x, y + h / 2 + 8);
ctx.lineTo(x + 6, y + h / 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.98)';
ctx.fill();
ctx.stroke();
// 文字:「我想買」
ctx.fillStyle = '#1e293b';
ctx.font = '900 12px "Noto Serif TC", serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText("我想買", x - w / 2 + 10, y + 1);
// 食材圖示微光襯底圓盤
ctx.fillStyle = 'rgba(254, 243, 199, 0.65)';
ctx.beginPath();
ctx.arc(x - 2, y, 16, 0, Math.PI * 2);
ctx.fill();
// 食材圖片 (與標價貨架完全一模一樣的 28px Emoji 圖示!)
ctx.font = '28px "Apple Color Emoji", "Segoe UI Emoji", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(selectedItem.icon, x - 2, y);
// 價格標籤
ctx.fillStyle = '#b91c1c';
ctx.font = '900 13px "Cinzel", "Noto Serif TC", serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(selectedItem.price + "元", x + w / 2 - 10, y + 1);
ctx.restore();
}
// 繪製小顧客的點餐/購物對話框 (食材改為圖片,與貨架標價圖片完全一模一樣!)
function drawCustomerSpeechBubble(ctx, x, y, selectedItem, isCelebrating) {
if (isCelebrating) {
drawSpeechBubble(ctx, x, y, 140, 32, "買到了!謝謝!", true, 'bottom');
return;
}
// 包含【我想買】 + 【食材圖示圖片】 + 【標價元】
const w = 160;
const h = 42;
ctx.save();
// 對話氣泡背景 (白玉微暖 + 迪士尼金框)
ctx.fillStyle = 'rgba(255, 255, 255, 0.98)';
ctx.strokeStyle = '#cda250';
ctx.lineWidth = 2.2;
ctx.shadowColor = 'rgba(0, 0, 0, 0.15)';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.roundRect(x - w / 2, y - h / 2, w, h, 12);
ctx.fill();
ctx.stroke();
ctx.shadowColor = 'transparent';
// 氣泡底部小三角指示箭頭 (指向人物頭部)
ctx.beginPath();
ctx.moveTo(x - 6, y + h / 2);
ctx.lineTo(x, y + h / 2 + 8);
ctx.lineTo(x + 6, y + h / 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.98)';
ctx.fill();
ctx.stroke();
// 文字:「我想買」
ctx.fillStyle = '#1e293b';
ctx.font = '900 12px "Noto Serif TC", serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText("我想買", x - w / 2 + 10, y + 1);
// 食材圖示微光襯底圓盤
ctx.fillStyle = 'rgba(254, 243, 199, 0.65)';
ctx.beginPath();
ctx.arc(x - 2, y, 16, 0, Math.PI * 2);
ctx.fill();
// 食材圖片 (與標價貨架完全一模一樣的 28px Emoji 圖示!)
ctx.font = '28px "Apple Color Emoji", "Segoe UI Emoji", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(selectedItem.icon, x - 2, y);
// 價格標籤
ctx.fillStyle = '#b91c1c';
ctx.font = '900 13px "Cinzel", "Noto Serif TC", serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(selectedItem.price + "元", x + w / 2 - 10, y + 1);
ctx.restore();
}
// 繪製迪士尼卡通人物小顧客頭部 (Disney Customer Head - 只要頭部,不需要身體)
function drawMarketCustomer(ctx, cx, cy, customer, isCelebrating, isWalking) {
ctx.save();
const bobY = isWalking ? Math.abs(Math.sin(s3WalkStep * 3)) * 6 : Math.sin(Date.now() / 350) * 2;
const hx = cx;
const hy = cy - bobY;
// 1. 迪士尼角色專屬頭部特徵繪製 (只要頭部,不需要身體)
if (customer.type === 'minnie') {
// 米妮:兩隻大黑耳、桃色臉龐、長睫毛大眼、黑鼻、經典紅底白點大蝴蝶結
ctx.fillStyle = '#1e293b';
ctx.beginPath();
ctx.arc(hx - 18, hy - 18, 13, 0, Math.PI * 2);
ctx.arc(hx + 18, hy - 18, 13, 0, Math.PI * 2);
ctx.arc(hx, hy - 2, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fed7aa';
ctx.beginPath();
ctx.ellipse(hx, hy + 2, 13, 12, 0, 0, Math.PI * 2);
ctx.fill();
// 睫毛與雙眼
ctx.fillStyle = '#0f172a';
ctx.beginPath();
ctx.arc(hx - 4, hy - 1, 2.4, 0, Math.PI * 2);
ctx.arc(hx + 4, hy - 1, 2.4, 0, Math.PI * 2);
ctx.arc(hx, hy + 4, 2.4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(hx - 6, hy - 4); ctx.lineTo(hx - 9, hy - 7);
ctx.moveTo(hx + 6, hy - 4); ctx.lineTo(hx + 9, hy - 7);
ctx.stroke();
drawMinnieBow(ctx, hx, hy - 20, 0.95);
} else if (customer.type === 'donald') {
// 唐老鴨:白色羽毛頭、水手帽與飄帶、藍色靈動大眼、招牌黃色扁鴨嘴
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#cbd5e1';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(hx, hy - 2, 18, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 水手帽
ctx.fillStyle = '#1d4ed8';
ctx.beginPath();
ctx.ellipse(hx, hy - 18, 14, 6, -0.1, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.beginPath();
ctx.moveTo(hx + 10, hy - 18); ctx.lineTo(hx + 24, hy - 22); ctx.lineTo(hx + 20, hy - 14);
ctx.closePath();
ctx.fill();
// 藍眼與鴨嘴
ctx.fillStyle = '#2563eb';
ctx.beginPath();
ctx.ellipse(hx - 4, hy - 4, 2.6, 4.2, 0, 0, Math.PI * 2);
ctx.ellipse(hx + 4, hy - 4, 2.6, 4.2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f59e0b';
ctx.beginPath();
ctx.ellipse(hx, hy + 6, 15, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#d97706';
ctx.lineWidth = 1.2;
ctx.stroke();
} else if (customer.type === 'pooh') {
// 小熊維尼:圓滾滾金黃小熊頭、可愛圓耳朵、憨厚黑圓眼與小鼻
ctx.fillStyle = '#f59e0b';
ctx.beginPath();
ctx.arc(hx - 15, hy - 15, 9, 0, Math.PI * 2);
ctx.arc(hx + 15, hy - 15, 9, 0, Math.PI * 2);
ctx.arc(hx, hy - 2, 19, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fbbf24';
ctx.beginPath();
ctx.ellipse(hx, hy + 3, 11, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#451a03';
ctx.beginPath();
ctx.arc(hx - 5, hy - 2, 2.2, 0, Math.PI * 2);
ctx.arc(hx + 5, hy - 2, 2.2, 0, Math.PI * 2);
ctx.arc(hx, hy + 3, 2.8, 0, Math.PI * 2);
ctx.fill();
} else if (customer.type === 'goofy') {
// 高飛狗:長條下垂大耳、經典高頂綠帽、凸凸長鼻子與兩顆可愛大門牙
ctx.fillStyle = '#1e293b';
ctx.beginPath();
ctx.ellipse(hx - 22, hy + 2, 6, 20, 0.2, 0, Math.PI * 2);
ctx.ellipse(hx + 22, hy + 2, 6, 20, -0.2, 0, Math.PI * 2);
ctx.arc(hx, hy - 2, 17, 0, Math.PI * 2);
ctx.fill();
// 綠帽子
ctx.fillStyle = '#16a34a';
ctx.beginPath();
ctx.roundRect(hx - 9, hy - 36, 18, 20, [6, 6, 2, 2]);
ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.fillRect(hx - 11, hy - 20, 22, 3);
// 長吻與大門牙
ctx.fillStyle = '#fed7aa';
ctx.beginPath();
ctx.ellipse(hx, hy + 6, 12, 9, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.beginPath();
ctx.arc(hx, hy + 3, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.fillRect(hx - 3.5, hy + 9, 3, 4.5);
ctx.fillRect(hx + 0.5, hy + 9, 3, 4.5);
} else if (customer.type === 'tigger') {
// 跳跳虎:亮橘色虎紋小圓耳、黑色虎斑條紋、粉嫩俏皮鼻子
ctx.fillStyle = '#ea580c';
ctx.beginPath();
ctx.arc(hx - 15, hy - 15, 8, 0, Math.PI * 2);
ctx.arc(hx + 15, hy - 15, 8, 0, Math.PI * 2);
ctx.arc(hx, hy - 2, 19, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.beginPath();
ctx.moveTo(hx, hy - 16); ctx.lineTo(hx, hy - 11);
ctx.moveTo(hx - 16, hy - 2); ctx.lineTo(hx - 9, hy);
ctx.moveTo(hx + 16, hy - 2); ctx.lineTo(hx + 9, hy);
ctx.stroke();
ctx.fillStyle = '#ffedd5';
ctx.beginPath();
ctx.ellipse(hx, hy + 4, 12, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f43f5e';
ctx.beginPath();
ctx.arc(hx, hy + 2, 2.8, 0, Math.PI * 2);
ctx.fill();
} else if (customer.type === 'daisy') {
// 黛西:優雅白色羽毛頭、薰衣草紫大蝴蝶結、深紫長睫毛、溫柔鵝黃鴨嘴
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#cbd5e1';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(hx, hy - 2, 18, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 紫蝴蝶結
ctx.fillStyle = '#c084fc';
ctx.beginPath();
ctx.ellipse(hx - 11, hy - 19, 9, 6, -0.3, 0, Math.PI * 2);
ctx.ellipse(hx + 11, hy - 19, 9, 6, 0.3, 0, Math.PI * 2);
ctx.arc(hx, hy - 19, 4, 0, Math.PI * 2);
ctx.fill();
// 睫毛與鵝黃鴨嘴
ctx.fillStyle = '#9333ea';
ctx.beginPath();
ctx.ellipse(hx - 4, hy - 4, 2.6, 4.2, 0, 0, Math.PI * 2);
ctx.ellipse(hx + 4, hy - 4, 2.6, 4.2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#581c87';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(hx - 7, hy - 6); ctx.lineTo(hx - 10, hy - 9);
ctx.moveTo(hx + 7, hy - 6); ctx.lineTo(hx + 10, hy - 9);
ctx.stroke();
ctx.fillStyle = '#f59e0b';
ctx.beginPath();
ctx.ellipse(hx, hy + 6, 14, 7, 0, 0, Math.PI * 2);
ctx.fill();
} else {
// 奇奇蒂蒂:栗子深棕小毛頭、米白雙頰與紅色小鼻頭
ctx.fillStyle = '#78350f';
ctx.beginPath();
ctx.arc(hx - 15, hy - 15, 7, 0, Math.PI * 2);
ctx.arc(hx + 15, hy - 15, 7, 0, Math.PI * 2);
ctx.arc(hx, hy - 2, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fef3c7';
ctx.beginPath();
ctx.arc(hx - 7, hy + 2, 7, 0, Math.PI * 2);
ctx.arc(hx + 7, hy + 2, 7, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#dc2626';
ctx.beginPath();
ctx.arc(hx, hy + 1, 3.5, 0, Math.PI * 2);
ctx.fill();
}
// 2. 對話框 (物品改為圖片,與貨架標價圖片完全一模一樣!)
drawCustomerSpeechBubble(ctx, cx, hy - 46, selectedShopItem, isCelebrating);
// 3. 底部角色名牌標籤 (Pill Tag)
ctx.fillStyle = '#ffe4e6';
ctx.strokeStyle = '#e11d48';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(cx - 50, hy + 24, 100, 20, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#9f1239';
ctx.font = '900 11px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(customer.name, cx, hy + 34);
ctx.restore();
}
// 繪製市集店長老闆頭部 (Shopkeeper Boss Head - 米奇店長,只要頭部,不需要身體,完全不被櫃台擋住!)
function drawShopkeeperBoss(ctx, bx, by, isCelebrating) {
ctx.save();
const bobY = Math.sin(Date.now() / 380) * 2;
const hx = bx;
const hy = by - bobY;
// 1. 米奇經典黑色大圓耳與頭部
ctx.fillStyle = '#1e293b';
ctx.beginPath();
ctx.arc(hx - 20, hy - 18, 14, 0, Math.PI * 2);
ctx.arc(hx + 20, hy - 18, 14, 0, Math.PI * 2);
ctx.arc(hx, hy - 2, 19, 0, Math.PI * 2);
ctx.fill();
// 桃色臉龐
ctx.fillStyle = '#fed7aa';
ctx.beginPath();
ctx.ellipse(hx, hy + 2, 14, 12, 0, 0, Math.PI * 2);
ctx.fill();
// 雙眼與黑色鼻頭
ctx.fillStyle = '#0f172a';
ctx.beginPath();
ctx.arc(hx - 4.5, hy - 1, 2.7, 0, Math.PI * 2);
ctx.arc(hx + 4.5, hy - 1, 2.7, 0, Math.PI * 2);
ctx.arc(hx, hy + 3.5, 2.8, 0, Math.PI * 2);
ctx.fill();
// 親切笑容
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(hx, hy + 4, 5.5, 0.1 * Math.PI, 0.9 * Math.PI);
ctx.stroke();
// 高頂主廚帽 (Toque Blanche)
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#cbd5e1';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(hx - 16, hy - 40, 32, 22, [10, 10, 2, 2]);
ctx.fill();
ctx.stroke();
// 主廚帽金邊緞帶
ctx.fillStyle = '#f59e0b';
ctx.fillRect(hx - 16, hy - 20, 32, 4);
if (isCelebrating) {
// 慶祝金星塵
ctx.fillStyle = '#f59e0b';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText("*", hx - 30, hy - 28);
ctx.fillText("*", hx + 30, hy - 28);
}
// 2. 對話框 (答對只說謝謝光臨!)
const bossSpeechText = isCelebrating
? "謝謝光臨!"
: "歡迎光臨!";
drawSpeechBubble(ctx, bx, hy - 54, 136, 26, bossSpeechText, isCelebrating, 'bottom');
// 3. 店長名牌標籤 (Pill Tag)
ctx.fillStyle = '#dbeafe';
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(bx - 55, hy + 24, 110, 20, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#1e3a8a';
ctx.font = '900 11px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText("店長:米奇", bx, hy + 34);
ctx.restore();
}
// 繪製店內實體櫃台 (Store Counter - 精緻實木檯面與嵌入托盤,不遮擋右側老闆)
function drawStoreCounter(ctx, paidSum) {
ctx.save();
const counterX = 205;
const counterY = 312;
const counterW = 485;
const counterH = 144;
// 1. 櫃台下半部:古典直條木紋壁板
const gradFace = ctx.createLinearGradient(0, counterY + 20, 0, counterY + counterH);
gradFace.addColorStop(0, '#533418');
gradFace.addColorStop(1, '#321c0b');
ctx.fillStyle = gradFace;
ctx.strokeStyle = '#231306';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(counterX, counterY + 20, counterW, counterH - 20, [0, 0, 12, 12]);
ctx.fill();
ctx.stroke();
// 櫃台壁板垂直溝槽裝飾
ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)';
ctx.lineWidth = 2;
for (let lx = counterX + 40; lx < counterX + counterW; lx += 45) {
ctx.beginPath();
ctx.moveTo(lx, counterY + 22);
ctx.lineTo(lx, counterY + counterH - 4);
ctx.stroke();
}
// 2. 櫃面上半部:拋光實木檯面
const gradTop = ctx.createLinearGradient(0, counterY, 0, counterY + 22);
gradTop.addColorStop(0, '#b87c44');
gradTop.addColorStop(0.5, '#8f5627');
gradTop.addColorStop(1, '#653b17');
ctx.fillStyle = gradTop;
ctx.beginPath();
ctx.roundRect(counterX - 6, counterY, counterW + 12, 22, 6);
ctx.fill();
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 2;
ctx.stroke();
// 4. 櫃台收銀機 (Antique Cash Register)
const regX = counterX + counterW - 48; // ~642
const regY = counterY + 50; // ~362
ctx.fillStyle = '#92400e';
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(regX - 28, regY - 32, 56, 68, 8);
ctx.fill();
ctx.stroke();
// 收銀機頂部金額顯示綠色螢幕
ctx.fillStyle = '#064e3b';
ctx.beginPath();
ctx.roundRect(regX - 22, regY - 28, 44, 16, 4);
ctx.fill();
ctx.fillStyle = '#34d399';
ctx.font = '900 11px "Cinzel", monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`$ ${selectedShopItem.price}`, regX, regY - 20);
// 收銀機按鍵
ctx.fillStyle = '#fde68a';
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
ctx.beginPath();
ctx.arc(regX - 14 + c * 14, regY - 4 + r * 9, 2.5, 0, Math.PI * 2);
ctx.fill();
}
}
// 錢抽把手
ctx.fillStyle = '#d97706';
ctx.fillRect(regX - 16, regY + 25, 32, 4);
// 5. 櫃台叮咚服務鈴 (Brass Service Bell)
const bellX = regX - 52; // ~590
const bellY = counterY + 24; // ~336
ctx.fillStyle = '#78350f';
ctx.fillRect(bellX - 12, bellY + 5, 24, 4);
ctx.fillStyle = '#f59e0b';
ctx.strokeStyle = '#d97706';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(bellX, bellY + 5, 11, Math.PI, 0);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#ef4444';
ctx.fillRect(bellX - 2, bellY - 8, 4, 4);
// 6. 嵌入式結帳木托盤 (Embedded Checkout Coin Tray)
const trayX = counterX + 12; // 217
const trayY = counterY + 12; // 324
const trayW = 345;
const trayH = 120;
ctx.fillStyle = 'rgba(250, 245, 235, 0.98)';
ctx.strokeStyle = '#a26a38';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(trayX, trayY, trayW, trayH, 10);
ctx.fill();
ctx.stroke();
// 托盤頂部標題列
ctx.fillStyle = 'rgba(224, 206, 182, 0.92)';
ctx.beginPath();
ctx.roundRect(trayX + 2, trayY + 2, trayW - 4, 28, [8, 8, 0, 0]);
ctx.fill();
ctx.fillStyle = '#4a382c';
ctx.font = '900 12px "Noto Serif TC", serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`🛒 托盤(標價:${selectedShopItem.price}元, 已放:`, trayX + 8, trayY + 16);
ctx.fillStyle = '#b91c1c';
ctx.font = '900 16px "Cinzel", serif';
ctx.fillText(`${paidSum} 元`, trayX + 185, trayY + 16);
ctx.fillStyle = '#4a382c';
ctx.font = '900 12px "Noto Serif TC", serif';
ctx.fillText(`)`, trayX + 242, trayY + 16);
// 托盤即時比對狀態
ctx.textAlign = 'right';
if (paidSum === selectedShopItem.price) {
ctx.fillStyle = '#15803d';
ctx.font = '900 11px "Noto Serif TC", serif';
ctx.fillText("✨剛剛好!請結帳", trayX + trayW - 6, trayY + 16);
} else if (paidSum < selectedShopItem.price) {
ctx.fillStyle = '#b45309';
ctx.font = '700 11px "Noto Serif TC", serif';
ctx.fillText(`(差${selectedShopItem.price - paidSum}元)`, trayX + trayW - 6, trayY + 16);
} else {
ctx.fillStyle = '#dc2626';
ctx.font = '700 11px "Noto Serif TC", serif';
ctx.fillText(`⚠️多${paidSum - selectedShopItem.price}元`, trayX + trayW - 6, trayY + 16);
}
// 托盤內硬幣展示
if (insertedRegisterCoins.length === 0) {
ctx.fillStyle = '#8f7d6a';
ctx.font = 'italic 12px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.fillText("點下方 50、10、5、1 元放入托盤(點幣退回)...", trayX + trayW / 2, trayY + 70);
} else {
insertedRegisterCoins.forEach((val, idx) => {
const px = trayX + 35 + (idx % 5) * 65;
const py = trayY + 52 + Math.floor(idx / 5) * 42;
drawDetailedCoin(ctx, px, py, val, val === 50 ? 25 : val === 10 ? 22 : val === 5 ? 20 : 17);
});
}
ctx.restore();
}
function renderStage3Canvas(ctx) {
// 1. 頂部羊皮紙魔法橫幅
const category = ingredientCategories[currentCategoryIndex];
drawScrollBanner(
ctx,
V_WIDTH / 2,
138,
750,
52,
`🏪 迪士尼食材專賣店:【${currentCustomer.name}】來買食材囉!`,
`【${category.categoryName}】食材標價清楚透明,請在櫃台托盤放入剛剛好的錢幣!`
);
// 2. 食材展示陳列架 (5 件食材,每件均有清晰大標價 1-100 元)
ctx.save();
ctx.fillStyle = 'rgba(248, 241, 227, 0.94)';
ctx.strokeStyle = '#cda250';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.roundRect(50, 172, 860, 115, 12);
ctx.fill();
ctx.stroke();
currentMarketItems.forEach(item => {
const isSel = item.id === selectedShopItem.id;
ctx.save();
ctx.beginPath();
ctx.roundRect(item.x - item.w / 2, item.y - item.h / 2, item.w, item.h, 12);
if (isSel) {
ctx.fillStyle = 'rgba(255, 252, 235, 0.98)';
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 3.5;
ctx.shadowColor = 'rgba(245, 158, 11, 0.45)';
ctx.shadowBlur = 12;
} else {
ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
ctx.strokeStyle = '#cbd5e1';
ctx.lineWidth = 1.8;
}
ctx.fill();
ctx.stroke();
ctx.shadowColor = 'transparent';
// 選購中標籤
if (isSel) {
ctx.fillStyle = '#dc2626';
ctx.beginPath();
ctx.roundRect(item.x - 30, item.y - item.h / 2 + 4, 60, 16, 8);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '900 10px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText("🎯 購買中", item.x, item.y - item.h / 2 + 12);
}
// 食材 Emoji 圖示
ctx.font = '32px "Apple Color Emoji", "Segoe UI Emoji", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(item.icon, item.x, item.y - 14);
// 食材名稱
ctx.font = '700 13px "Noto Serif TC", serif';
ctx.fillStyle = '#1e293b';
ctx.fillText(item.name, item.x, item.y + 14);
// 食材醒目標價 (目標 2: 食材都有標價錢 1-100元)
ctx.fillStyle = isSel ? '#dc2626' : '#b45309';
ctx.beginPath();
ctx.roundRect(item.x - 56, item.y + 26, 112, 19, 9);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '900 12px "Noto Serif TC", serif';
ctx.fillText(`💰 標價:${item.price} 元`, item.x, item.y + 35);
ctx.restore();
});
ctx.restore();
// 3. 結帳實體櫃台 (Store Counter) - 位於中間 x=205~690,不遮擋老闆!
const paidSum = insertedRegisterCoins.reduce((a, b) => a + b, 0);
drawStoreCounter(ctx, paidSum);
// 4. 店長米奇老闆站在右側專屬區 (Shopkeeper Boss) - 有手有腳、完全不被擋住!
drawShopkeeperBoss(ctx, 820, 370, s3Celebrating);
// 5. 走進店裡面的小顧客 (Customer) - 有手有腳、提菜籃、邁步走進店裡
drawMarketCustomer(ctx, s3CustomerWalkX, 375, currentCustomer, s3Celebrating, s3CustomerIsWalking);
// 6. 下方收銀台付款硬幣列:必須完整包含 50、10、5、1 元!
ctx.save();
ctx.fillStyle = 'rgba(255, 252, 245, 0.96)';
ctx.strokeStyle = '#cfa144';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.roundRect(50, 466, 860, 92, 16);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#5c4a3d';
ctx.font = '900 15px "Noto Serif TC", serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText("🪙 點擊付款:", 75, 512);
// 50 元 (大金幣)
drawDetailedCoin(ctx, 205, 512, 50, 29);
// 10 元 (大銀幣)
drawDetailedCoin(ctx, 285, 512, 10, 26);
// 5 元 (中銀幣)
drawDetailedCoin(ctx, 360, 512, 5, 23);
// 1 元 (小銅幣)
drawDetailedCoin(ctx, 430, 512, 1, 19);
drawCanvasButton(ctx, 615, 512, 140, 46, "確認結帳", '#1b5e20', '#c8e6c9');
drawCanvasButton(ctx, 770, 512, 120, 46, "清除托盤", '#be123c', '#ffe4e6');
ctx.restore();
}
function drawCanvasButton(ctx, x, y, w, h, text, bgColor, textColor) {
ctx.save();
ctx.beginPath();
ctx.roundRect(x - w / 2, y - h / 2, w, h, 10);
ctx.fillStyle = bgColor;
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 2;
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.strokeStyle = '#cfa144';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = textColor;
ctx.font = '900 16px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y + 1);
ctx.restore();
}
function drawFeedbackRibbon(ctx) {
if (feedback.timer > 0) {
ctx.save();
ctx.fillStyle = feedback.color === 'good' ? '#2e7d32' : '#b71c1c';
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.roundRect(V_WIDTH / 2 - 320, 570, 640, 50, 25);
ctx.fill();
ctx.strokeStyle = '#ffd700';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '900 18px "Noto Serif TC", serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(feedback.text, V_WIDTH / 2, 595);
ctx.restore();
feedback.timer--;
}
}
function getCanvasCoordinates(e) {
const rect = canvas.getBoundingClientRect();
const clientX = e.clientX || (e.touches && e.touches[0] ? e.touches[0].clientX : 0);
const clientY = e.clientY || (e.touches && e.touches[0] ? e.touches[0].clientY : 0);
const dpr = window.devicePixelRatio || 1;
const x = (clientX - rect.left - offsetX) / scale;
const y = (clientY - rect.top - offsetY) / scale;
return { x, y };
}
function handlePointerDown(e) {
initAudio();
const { x, y } = getCanvasCoordinates(e);
// 檢查頂部導航按鈕
for (let btn of navButtons) {
if (Math.abs(x - btn.x) <= btn.w / 2 && Math.abs(y - btn.y) <= btn.h / 2) {
soundCoin();
currentStage = btn.stage;
if (currentStage === 1) initStage1();
else if (currentStage === 2) initStage2();
else if (currentStage === 3) initStage3();
return;
}
}
// 第一關互動
if (currentStage === 1) {
if (s1Locked) return;
for (let c of s1Coins) {
const dist = Math.hypot(x - c.x, y - c.y);
if (dist <= c.r) {
soundCoin();
if (c.val === s1TargetCoin.val) {
s1Locked = true;
soundSuccess();
addSparkles(c.x, c.y, 55);
createFirework();
setTimeout(createFirework, 400);
setTimeout(createFirework, 850);
const fullSuccessText = `謝謝你幫忙找到 ${c.name} 硬幣!🎉`;
feedback = {
text: fullSuccessText,
color: 'good',
timer: 260
};
// 語音完整念出「謝謝你幫忙找到X元硬幣!」
const spokenSuccess = `謝謝你幫忙找到${c.spoken}硬幣!`;
let transitioned = false;
function nextQuestion() {
if (transitioned) return;
transitioned = true;
clearTimeout(fallbackTimer);
setTimeout(() => {
initStage1();
}, 900); // 語音完整念完後留 0.9 秒聆聽慶祝音樂餘韻,再切換下一題
}
const fallbackTimer = setTimeout(nextQuestion, 4200);
speak(spokenSuccess, nextQuestion);
} else {
soundFailure();
feedback = {
text: `請再試試看!`,
color: 'bad',
timer: 110
};
speak(`請再試試看!`);
}
return;
}
}
}
// 第二關互動
else if (currentStage === 2) {
if (s2Locked) return;
// 1. 點擊下方硬幣
// 點擊 50 元 (進十位盤)
if (Math.hypot(x - 268, y - 502) <= 32) {
soundCoin();
s2TensCoins.push(50);
return;
}
// 點擊 10 元 (進十位盤)
if (Math.hypot(x - 350, y - 502) <= 28) {
soundCoin();
s2TensCoins.push(10);
return;
}
// 點擊 5 元 (進個位盤)
if (Math.hypot(x - 428, y - 502) <= 25) {
soundCoin();
s2OnesCoins.push(5);
return;
}
// 點擊 1 元 (進個位盤)
if (Math.hypot(x - 498, y - 502) <= 21) {
soundCoin();
s2OnesCoins.push(1);
return;
}
// 2. 點擊盤面可取出/撤回硬幣
// 點擊米奇十位盤區 (取出最後一個十位硬幣)
if (x >= 110 && x <= 460 && y >= 250 && y <= 440) {
if (s2TensCoins.length > 0) {
soundCoin();
s2TensCoins.pop();
return;
}
}
// 點擊米妮個位盤區 (取出最後一個個位硬幣)
if (x >= 495 && x <= 850 && y >= 250 && y <= 440) {
if (s2OnesCoins.length > 0) {
soundCoin();
s2OnesCoins.pop();
return;
}
}
// 3. 確認檢查按鈕
if (Math.abs(x - 642) <= 110 / 2 && Math.abs(y - 502) <= 44 / 2) {
const tensSum = s2TensCoins.reduce((a, b) => a + b, 0);
const onesSum = s2OnesCoins.reduce((a, b) => a + b, 0);
const tensGoal = Math.floor(s2Goal / 10) * 10;
const onesGoal = s2Goal % 10;
const total = tensSum + onesSum;
if (tensSum === tensGoal && onesSum === onesGoal) {
s2Locked = true;
soundSuccess(); // 答對只播放歡樂迪士尼慶典主題旋律!
addSparkles(V_WIDTH / 2, 300, 80); // 滿天金色星塵
createFirework();
setTimeout(createFirework, 350);
setTimeout(createFirework, 750);
feedback = {
text: `🎉 太棒了!金額完全正確!`,
color: 'good',
timer: 220
};
// 答對完全刪除說話,只享受歡樂的迪士尼音樂與星塵
setTimeout(() => {
initStage2();
}, 2600);
} else {
soundFailure();
if (tensSum !== tensGoal) {
const msg = tensGoal === 0
? `十位目前是 ${tensSum} 元,不需要放硬幣喔!`
: `十位目前是 ${tensSum} 元,需要 ${tensGoal} 元喔!`;
feedback = {
text: msg,
color: 'bad',
timer: 130
};
speak(tensGoal === 0 ? `十位目前是 ${tensSum} 元,不需要放硬幣喔!` : `十位目前是 ${tensSum} 元,需要放 ${tensGoal} 元喔!`, null, 0.75);
} else {
const msg = onesGoal === 0
? `個位目前是 ${onesSum} 元,不需要放硬幣喔!`
: `個位目前是 ${onesSum} 元,需要 ${onesGoal} 元喔!`;
feedback = {
text: msg,
color: 'bad',
timer: 130
};
speak(onesGoal === 0 ? `個位目前是 ${onesSum} 元,不需要放硬幣喔!` : `個位目前是 ${onesSum} 元,需要放 ${onesGoal} 元喔!`, null, 0.75);
}
}
return;
}
// 4. 重置盤面按鈕
if (Math.abs(x - 768) <= 92 / 2 && Math.abs(y - 502) <= 44 / 2) {
soundCoin();
s2TensCoins = [];
s2OnesCoins = [];
feedback.text = '';
return;
}
}
// 第三關互動 (市集買賣)
else if (currentStage === 3) {
if (s3Locked) return;
// 1. 點選架上商品
for (let item of currentMarketItems) {
if (Math.abs(x - item.x) <= item.w / 2 && Math.abs(y - item.y) <= item.h / 2) {
soundCoin();
selectedShopItem = item;
insertedRegisterCoins = [];
feedback.text = '';
speak(`${currentCustomer.name} 想買 ${item.name},價格是 ${item.price} 元!`);
return;
}
}
// 2. 點選付款硬幣 (完整支援 50, 10, 5, 1 元)
// 50 元 (大金幣)
if (Math.hypot(x - 205, y - 512) <= 32) {
soundCoin();
insertedRegisterCoins.push(50);
return;
}
// 10 元 (大銀幣)
if (Math.hypot(x - 285, y - 512) <= 28) {
soundCoin();
insertedRegisterCoins.push(10);
return;
}
// 5 元 (中銀幣)
if (Math.hypot(x - 360, y - 512) <= 25) {
soundCoin();
insertedRegisterCoins.push(5);
return;
}
// 1 元 (小銅幣)
if (Math.hypot(x - 430, y - 512) <= 21) {
soundCoin();
insertedRegisterCoins.push(1);
return;
}
// 3. 點擊結帳木托盤區 (可撤回/退回最後一個硬幣)
if (x >= 217 && x <= 562 && y >= 324 && y <= 444) {
if (insertedRegisterCoins.length > 0) {
soundCoin();
insertedRegisterCoins.pop();
return;
}
}
// 4. 確認結帳按鈕
if (Math.abs(x - 615) <= 140 / 2 && Math.abs(y - 512) <= 46 / 2) {
const sum = insertedRegisterCoins.reduce((a, b) => a + b, 0);
if (sum === selectedShopItem.price) {
s3Locked = true;
s3Celebrating = true;
soundSuccess(); // 播放歡樂迪士尼慶典旋律!
addSparkles(V_WIDTH / 2, 330, 80); // 滿天金色星塵
// 答對放煙火的圖案 (多重絢麗煙火連環綻放!)
createFirework();
setTimeout(createFirework, 250);
setTimeout(createFirework, 550);
setTimeout(createFirework, 850);
setTimeout(createFirework, 1200);
feedback = {
text: `🎉 謝謝光臨!`,
color: 'good',
timer: 240
};
// 答對只要說「謝謝光臨」就好!
speak("謝謝光臨!", null, 0.9);
// 付完錢全部再換一次圖片(換客人、換整批商品!)
setTimeout(() => {
advanceStage3Scene();
}, 2800);
} else if (sum > selectedShopItem.price) {
soundFailure();
const diff = sum - selectedShopItem.price;
feedback = {
text: `付了 ${sum} 元,多付了 ${diff} 元!要練習付「剛剛好」的金額!`,
color: 'bad',
timer: 130
};
speak(`付太多錢囉,多付了 ${diff} 元,請練習給剛剛好`, null, 0.75);
} else {
soundFailure();
const diff = selectedShopItem.price - sum;
feedback = {
text: `目前才 ${sum} 元,托盤還短少 ${diff} 元喔!`,
color: 'bad',
timer: 130
};
speak(`金額還不夠,還差 ${diff} 元喔`, null, 0.75);
}
return;
}
// 5. 清除托盤按鈕
if (Math.abs(x - 770) <= 120 / 2 && Math.abs(y - 512) <= 46 / 2) {
soundCoin();
insertedRegisterCoins = [];
feedback.text = '';
return;
}
}
}
canvas.addEventListener('pointerdown', handlePointerDown);
function gameLoop() {
// 0. 第三關卡通人物走進店裡動畫更新
if (currentStage === 3 && s3CustomerIsWalking) {
s3CustomerWalkX += 3.8;
s3WalkStep += 0.22;
if (Math.random() < 0.25) {
addSparkles(s3CustomerWalkX - 10, 420, 2);
}
if (s3CustomerWalkX >= s3CustomerTargetX) {
s3CustomerWalkX = s3CustomerTargetX;
s3CustomerIsWalking = false;
}
}
// 1. 迪士尼樂園天際線、奇幻城堡與煙火背景
drawDisneylandBackground(ctx);
// 2. 歐風遮陽棚與頂部導航欄
drawNavHeader(ctx);
// 3. 渲染對應關卡
if (currentStage === 1) {
renderStage1Canvas(ctx);
} else if (currentStage === 2) {
renderStage2Canvas(ctx);
} else if (currentStage === 3) {
renderStage3Canvas(ctx);
}
// 4. 回饋橫幅與魔法粒子
drawFeedbackRibbon(ctx);
updateAndDrawParticles(ctx);
requestAnimationFrame(gameLoop);
}
// 立即啟動與自適應 (預設進入第 III 關:市集買賣)
function startApp() {
resizeCanvas();
initStage3();
requestAnimationFrame(gameLoop);
}
window.addEventListener('load', startApp);
if (document.readyState === 'complete' || document.readyState === 'interactive') {
startApp();
}
</script>
</body>
</html>