🛠️ 最佳使用建议
- 静音键提醒:如果玩的时候没声音,请检查手机侧面的物理静音开关(不要露出橙色)。
- 互动提问:你可以试着问小朋友:“你能用一个 2 和一个 3 变出一个 5 吗?”或者“这个 9 里面藏着哪两个好朋友?”
💡 游戏操作手册(家长版)
| 功能 | 操作方式 | 教学意义 |
| 合并加法 | 拖动一个泡泡盖在另一个上面 | 理解“合起来”就是加法,观察圆点数量变多 |
| 拆分减法 | 快速连续双击较大的数字泡泡 | 理解大数字是由小数字组成的,是减法的逆向思维 |
| 重整布局 | 点击底部的“洗牌重新排列” | 保持画面整洁,重新开始数数练习 |
| 防止丢失 | 泡泡会自动弹回屏幕内 | 确保小朋友随时能看到所有数字,不产生挫败感 |
试玩网址
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>泡泡加法乐园</title>
<style>
* { -webkit-tap-highlight-color: transparent; }
body, html {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; background: #f8fafc;
touch-action: none; position: fixed;
}
canvas { display: block; width: 100%; height: 100%; }
#start-overlay {
position: fixed; top:0; left:0; width:100%; height:100%;
background: rgba(255,255,255,0.95);
display: flex; justify-content: center; align-items: center;
z-index: 9999;
}
.start-btn {
padding: 16px 36px; font-size: 22px; background: #007AFF;
color: white; border-radius: 50px; font-weight: bold;
}
#reset-btn {
position: absolute; bottom: 30px; left: 50%;
transform: translateX(-50%);
padding: 12px 30px; font-size: 16px; font-weight: bold;
background: #f43f5e; color: white; border: none; border-radius: 50px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 100;
}
</style>
</head>
<body>
<div id="start-overlay" onclick="initGame()">
<div class="start-btn">点这里开始 🎈</div>
</div>
<button id="reset-btn" onclick="resetGame()">洗牌重新排列 🔄</button>
<canvas id="gameCanvas"></canvas>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
let audioCtx = null;
let bubbles = [];
let dragging = null;
let lastTap = 0;
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
function initGame() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
document.getElementById('start-overlay').style.display = 'none';
resetGame();
requestAnimationFrame(update);
}
function playPop() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(500, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(100, audioCtx.currentTime + 0.1);
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + 0.1);
}
const COLORS = ['#FF5E5B', '#11999E', '#FFCC29', '#845EC2', '#D65DB1', '#4FFBDF', '#FF9671', '#93DEFF', '#00C9A7'];
class Bubble {
constructor(x, y, value, dotColors = null, parentStates = null) {
this.x = x; this.y = y; this.value = value;
// 核心优化:减小基础尺寸,留出更多空白
this.radius = 25 + (Math.sqrt(value) * 10);
this.parentStates = parentStates;
this.dotColors = dotColors || Array(value).fill(COLORS[(value - 1) % 9]);
}
draw() {
ctx.save();
// 边界保护逻辑:确保不出界
const margin = this.radius + 25;
if (this.x < this.radius) this.x = this.radius;
if (this.x > canvas.width - this.radius) this.x = canvas.width - this.radius;
if (this.y < this.radius) this.y = this.radius;
if (this.y > canvas.height - margin) this.y = canvas.height - margin;
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(this.x-8, this.y-8, 0, this.x, this.y, this.radius);
grad.addColorStop(0, '#fff');
grad.addColorStop(1, 'rgba(186, 230, 253, 0.45)');
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)';
ctx.lineWidth = 2;
ctx.stroke();
const count = this.dotColors.length;
const cols = Math.ceil(Math.sqrt(count));
const spacing = 14; // 缩小圆点间距
const offset = ((cols - 1) * spacing) / 2;
this.dotColors.forEach((color, i) => {
const r = Math.floor(i / cols), c = i % cols;
ctx.beginPath();
ctx.arc(this.x + (c * spacing) - offset, this.y + (r * spacing) - offset, 5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
});
ctx.fillStyle = '#0369a1';
ctx.font = 'bold 18px Arial'; // 字体稍微缩小一点更精致
ctx.textAlign = 'center';
ctx.fillText(this.value, this.x, this.y + this.radius + 22);
ctx.restore();
}
}
function resetGame() {
bubbles = [];
const padding = 60;
for (let i = 1; i <= 9; i++) {
let attempts = 0;
let placed = false;
while (!placed && attempts < 100) {
let tx = padding + Math.random() * (canvas.width - padding * 2);
let ty = padding + Math.random() * (canvas.height - padding * 3);
let tr = 25 + (Math.sqrt(i) * 10);
let overlap = bubbles.some(b => Math.sqrt((tx-b.x)**2 + (ty-b.y)**2) < (tr + b.radius + 10));
if (!overlap) {
bubbles.push(new Bubble(tx, ty, i));
placed = true;
}
attempts++;
}
if(!placed) bubbles.push(new Bubble(canvas.width/2, canvas.height/2, i));
}
if(audioCtx) playPop();
}
// 物理辅助:松手后如果太挤,自动推开一点点
function softPhysics() {
for (let i = 0; i < bubbles.length; i++) {
for (let j = i + 1; j < bubbles.length; j++) {
const b1 = bubbles[i];
const b2 = bubbles[j];
if (b1 === dragging || b2 === dragging) continue;
const dx = b2.x - b1.x;
const dy = b2.y - b1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = b1.radius + b2.radius + 5;
if (dist < minDist) {
const angle = Math.atan2(dy, dx);
const force = 0.5; // 很柔和的力
b1.x -= Math.cos(angle) * force;
b1.y -= Math.sin(angle) * force;
b2.x += Math.cos(angle) * force;
b2.y += Math.sin(angle) * force;
}
}
}
}
function handleInput(e) {
const pos = e.touches ? e.touches[0] : e;
const px = pos.clientX;
const py = pos.clientY;
const now = Date.now();
if (e.type === 'touchstart' || e.type === 'mousedown') {
for (let i = bubbles.length - 1; i >= 0; i--) {
const b = bubbles[i];
if (Math.sqrt((px - b.x)**2 + (py - b.y)**2) < b.radius + 20) {
if (now - lastTap < 300 && b.parentStates) {
const p = b.parentStates;
bubbles.splice(i, 1);
bubbles.push(new Bubble(b.x-40, b.y, p[0].val, p[0].colors, p[0].parents));
bubbles.push(new Bubble(b.x+40, b.y, p[1].val, p[1].colors, p[1].parents));
playPop();
dragging = null;
return;
}
dragging = b;
bubbles.push(bubbles.splice(i, 1)[0]);
break;
}
}
lastTap = now;
} else if (dragging) {
dragging.x = px;
dragging.y = py;
}
}
function handleEnd() {
if (!dragging) return;
for (let i = bubbles.length - 2; i >= 0; i--) {
const other = bubbles[i];
if (Math.sqrt((dragging.x - other.x)**2 + (dragging.y - other.y)**2) < dragging.radius + other.radius) {
playPop();
const p = [{val:dragging.value, colors:dragging.dotColors, parents:dragging.parentStates},
{val:other.value, colors:other.dotColors, parents:other.parentStates}];
bubbles = bubbles.filter(b => b !== dragging && b !== other);
bubbles.push(new Bubble(other.x, other.y, p[0].val + p[1].val, [...p[0].colors, ...p[1].colors], p));
break;
}
}
dragging = null;
}
canvas.addEventListener('touchstart', handleInput, {passive: false});
canvas.addEventListener('touchmove', handleInput, {passive: false});
canvas.addEventListener('touchend', handleEnd);
canvas.addEventListener('mousedown', handleInput);
window.addEventListener('mousemove', handleInput);
window.addEventListener('mouseup', handleEnd);
function update() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
softPhysics();
bubbles.forEach(b => b.draw());
requestAnimationFrame(update);
}
</script>
</body>
</html>
