<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<title>Cutie's Hobby Horse</title>
<style>
:root {
--bg1: #fff2fb;
--bg2: #e9f8ff;
--pink: #ff7fc8;
--pink-deep: #ff5bb3;
--lav: #b888ff;
--mint: #91f3d1;
--butter: #ffe58a;
--ink: #6f4774;
--white: #ffffff;
--danger: #ff4757;
--shadow: rgba(164, 90, 150, 0.18);
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
touch-action: none;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, var(--bg1), var(--bg2));
color: var(--ink);
}
#app {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(255,255,255,0.85), rgba(255,255,255,0) 40%),
linear-gradient(180deg, #fff5fd 0%, #eaf9ff 100%);
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.hud {
position: absolute;
inset: 0;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: max(14px, env(safe-area-inset-top)) 14px max(86px, calc(86px + env(safe-area-inset-bottom))) 14px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.card {
pointer-events: auto;
background: rgba(255,255,255,0.62);
border: 1px solid rgba(255,255,255,0.7);
backdrop-filter: blur(10px);
border-radius: 22px;
box-shadow: 0 10px 24px var(--shadow);
}
.title-card {
padding: 12px 14px;
max-width: 65%;
}
.score-card {
padding: 12px 14px;
min-width: 110px;
text-align: right;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.title {
font-size: 22px;
font-weight: 900;
letter-spacing: -0.05em;
line-height: 1;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 12px;
opacity: 0.82;
line-height: 1.3;
margin: 0;
}
.score-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.7;
}
.score-value {
font-size: 34px;
font-weight: 900;
letter-spacing: -0.06em;
line-height: 1;
margin-top: 4px;
}
.bottombar {
display: grid;
grid-template-columns: minmax(260px, 1fr) auto;
gap: 10px;
align-items: end;
}
.mode-group {
display: flex;
gap: 8px;
align-items: center;
pointer-events: auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.mode-pill {
pointer-events: auto;
border: 0;
border-radius: 999px;
background: rgba(255,255,255,0.72);
color: var(--ink);
padding: 12px 14px;
font-size: 13px;
font-weight: 900;
letter-spacing: -0.02em;
box-shadow: 0 10px 20px var(--shadow);
cursor: pointer;
min-width: 82px;
}
.mode-pill.active {
background: linear-gradient(180deg, #b888ff, #8f64e8);
color: var(--white);
box-shadow: 0 14px 28px rgba(143, 100, 232, 0.24);
}
.info-card {
padding: 12px 14px;
min-height: 72px;
display: flex;
flex-direction: column;
justify-content: center;
}
.info-main {
font-size: 16px;
font-weight: 800;
line-height: 1.15;
margin-bottom: 4px;
}
.info-sub {
font-size: 12px;
opacity: 0.75;
line-height: 1.25;
}
.action-btn {
background: linear-gradient(180deg, var(--pink), var(--pink-deep));
padding: 16px 22px;
min-width: 112px;
}
.reset-pill {
min-width: 74px;
}
.mode-pill:active, .top-reset:active { transform: scale(0.98); }
.center-tip {
position: absolute;
left: 50%;
top: 17%;
transform: translateX(-50%);
pointer-events: none;
background: rgba(255,255,255,0.7);
border-radius: 999px;
padding: 10px 14px;
font-size: 14px;
font-weight: 800;
color: var(--ink);
box-shadow: 0 8px 18px var(--shadow);
animation: tipBob 1.8s ease-in-out infinite;
}
@keyframes tipBob {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-6px); }
}
.top-reset {
pointer-events: auto;
border: 0;
background: rgba(255,255,255,0.72);
color: var(--ink);
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
font-weight: 800;
box-shadow: 0 8px 18px var(--shadow);
cursor: pointer;
}
@media (max-width: 760px) {
.bottombar {
grid-template-columns: 1fr;
}
.info-card {
grid-column: 1 / -1;
}
.mode-group {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 100%;
}
.mode-pill, .top-reset {
width: 100%;
}
.title-card { max-width: 58%; }
.score-value { font-size: 30px; }
}
</style>
</head>
<body>
<div id="app">
<canvas id="game" width="720" height="1280"></canvas>
<div class="hud">
<div class="topbar">
<div class="card title-card">
<div class="title">Cutie's Hobby Horse</div>
<p class="subtitle" id="titleSubtitle">A tiny three-mode toy. Arcade chases points, Zen grows a flower bed, and Bubble Pop turns the whole thing into a pastel popping picnic.</p>
</div>
<div class="card score-card" id="scoreCard">
<div class="score-label" id="scoreLabel">Cute Points</div>
<div class="score-value" id="scoreValue">0</div>
<button class="top-reset" id="resetBtn">Reset</button>
</div>
</div>
<div class="bottombar">
<div class="card info-card">
<div class="info-main" id="infoMain">Tap Cutie to make her boing.</div>
<div class="info-sub" id="infoSub">Tap Cutie. Catch hearts. Dropping them costs points!</div>
</div>
<div class="mode-group" id="modeGroup">
<button class="mode-pill active" id="modeArcade" data-mode="arcade">Arcade</button>
<button class="mode-pill" id="modeZen" data-mode="zen">Zen</button>
<button class="mode-pill" id="modeBubble" data-mode="bubble">Bubble</button>
</div>
</div>
</div>
<div class="center-tip" id="centerTip">Tap Cutie</div>
</div>
<script>
const SAVE_KEY = 'cutiesHobbyHorseModes_v1';
const CUTIE_IMAGE_SRC = './cutie-hobby-horse.png';
const MODES = {
ARCADE: 'arcade',
ZEN: 'zen',
BUBBLE: 'bubble'
};
const MODE_ORDER = [MODES.ARCADE, MODES.ZEN, MODES.BUBBLE];
const app = document.getElementById('app');
const canvas = document.getElementById('game');
const ctx = canvas ? canvas.getContext('2d') : null;
const titleSubtitle = document.getElementById('titleSubtitle');
const scoreCard = document.getElementById('scoreCard');
const scoreLabel = document.getElementById('scoreLabel');
const scoreValue = document.getElementById('scoreValue');
const infoMain = document.getElementById('infoMain');
const infoSub = document.getElementById('infoSub');
const centerTip = document.getElementById('centerTip');
const modeGroup = document.getElementById('modeGroup');
const modeArcade = document.getElementById('modeArcade');
const modeZen = document.getElementById('modeZen');
const modeBubble = document.getElementById('modeBubble');
const resetBtn = document.getElementById('resetBtn');
if (!app || !canvas || !ctx) throw new Error('Canvas not available.');
function freshState() {
return {
score: 0,
best: 0,
taps: 0,
caughtHearts: 0,
currentMode: MODES.ARCADE,
lastPlayed: Date.now()
};
}
function load() {
try {
const raw = localStorage.getItem(SAVE_KEY);
const parsed = raw ? Object.assign(freshState(), JSON.parse(raw)) : freshState();
if (!MODE_ORDER.includes(parsed.currentMode)) parsed.currentMode = MODES.ARCADE;
return parsed;
} catch {
return freshState();
}
}
let state = load();
let W = 720;
let H = 1280;
let last = performance.now();
let pulse = 0;
let sparkleTimer = 0;
let cloudShift = 0;
let cutieImageReady = false;
const cutieImage = new Image();
cutieImage.onload = () => { cutieImageReady = true; };
cutieImage.onerror = () => {
cutieImageReady = false;
console.warn('Could not load Cutie image at', CUTIE_IMAGE_SRC);
};
cutieImage.src = CUTIE_IMAGE_SRC;
const game = {
cutie: {
x: W * 0.5,
y: H * 0.64,
bounce: 0,
tilt: 0,
gallop: 0
},
hearts: [],
pops: [],
stars: [],
fallingHearts: [],
flowers: [],
bubbles: [],
nextHeartSpawn: performance.now() + 900,
nextBubbleSpawn: performance.now() + 800
};
function save() {
state.lastPlayed = Date.now();
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
}
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = app.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
W = rect.width;
H = rect.height;
game.cutie.x = W * 0.5;
game.cutie.y = H * 0.64;
layoutByMode();
}
function layoutByMode() {
scoreCard.style.display = state.currentMode === MODES.ZEN ? 'none' : 'block';
scoreLabel.textContent = state.currentMode === MODES.BUBBLE ? 'Bubble Score' : 'Cute Points';
modeArcade.classList.toggle('active', state.currentMode === MODES.ARCADE);
modeZen.classList.toggle('active', state.currentMode === MODES.ZEN);
modeBubble.classList.toggle('active', state.currentMode === MODES.BUBBLE);
if (state.currentMode === MODES.ARCADE) {
titleSubtitle.textContent = 'Arcade mode is the score-chasing one. Tap Cutie, catch falling hearts, and do not let them hit the ground.';
infoMain.textContent = 'Tap Cutie to make her boing.';
infoSub.textContent = 'Tap Cutie. Catch hearts. Dropping them costs points!';
centerTip.textContent = 'Tap Cutie';
} else if (state.currentMode === MODES.ZEN) {
titleSubtitle.textContent = 'Zen Garden is the calm mode. Hearts fall on their own and turn into a layered flower bed as they land.';
infoMain.textContent = 'Zen Garden is running.';
infoSub.textContent = 'No score. Tap anywhere to help the garden bloom.';
centerTip.textContent = 'Just relax';
} else {
titleSubtitle.textContent = 'Bubble Pop Picnic is the playful mode. Pop floating bubbles for points, hearts, hugs, and golden little bonuses.';
infoMain.textContent = 'Bubble Pop Picnic is live.';
infoSub.textContent = 'Tap bubbles for points. Golden ones feel extra nice.';
centerTip.textContent = 'Pop bubbles';
}
}
function setMode(nextMode) {
if (!MODE_ORDER.includes(nextMode) || nextMode === state.currentMode) return;
state.currentMode = nextMode;
game.fallingHearts = [];
game.hearts = [];
game.pops = [];
game.stars = [];
game.bubbles = [];
if (state.currentMode !== MODES.ZEN) game.flowers = [];
game.nextHeartSpawn = performance.now() + 900;
game.nextBubbleSpawn = performance.now() + 800;
centerTip.style.display = 'block';
layoutByMode();
save();
}
function cycleMode() {
const idx = MODE_ORDER.indexOf(state.currentMode);
setMode(MODE_ORDER[(idx + 1) % MODE_ORDER.length]);
}
function spawnHeartBurst(x, y, golden = false) {
game.hearts.push({
x,
y,
vx: (Math.random() - 0.5) * 0.7,
vy: -1.8 - Math.random() * 0.9,
drift: (Math.random() - 0.5) * 0.2,
life: 1,
size: golden ? 20 : 15 + Math.random() * 6,
golden,
wobble: Math.random() * Math.PI * 2
});
}
function spawnPop(text, x, y, color, size = 24, big = false) {
game.pops.push({
x, y, text, color, life: 1, size, big,
driftX: (Math.random() - 0.5) * (big ? 0.6 : 0.3)
});
}
function spawnStars(x, y, count) {
for (let i = 0; i < count; i += 1) {
game.stars.push({
x, y,
vx: (Math.random() - 0.5) * 2.6,
vy: -Math.random() * 2.8,
life: 1,
size: 2.5 + Math.random() * 3.5,
hue: i % 3
});
}
}
function spawnFallingHeart(now) {
const scale = Math.min(W / 720, H / 1280);
const golden = Math.random() < 0.15;
game.fallingHearts.push({
x: Math.random() * (W - 80) + 40,
y: -50,
vy: (Math.random() * 3 + 4) * scale,
size: (golden ? 24 : 18) * scale,
golden,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: (Math.random() - 0.5) * 0.005,
planted: false
});
game.nextHeartSpawn = now + 600 + Math.random() * 1200;
}
function spawnBubble(now) {
const scale = Math.min(W / 720, H / 1280);
const typeRoll = Math.random();
const kind = typeRoll < 0.15 ? 'gold' : typeRoll < 0.55 ? 'heart' : typeRoll < 0.8 ? 'hug' : 'spark';
const radius = (kind === 'gold' ? 26 : 22 + Math.random() * 8) * scale;
game.bubbles.push({
x: Math.random() * (W - 100) + 50,
y: H + 60,
vx: (Math.random() - 0.5) * 0.22 * scale,
vy: -(1.4 + Math.random() * 1.2) * scale,
r: radius,
kind,
life: 1,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: (Math.random() - 0.5) * 0.003,
hue: Math.random()
});
game.nextBubbleSpawn = now + 380 + Math.random() * 700;
}
function drawHeart(x, y, size, fill) {
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = fill;
ctx.beginPath();
ctx.moveTo(0, size * 0.25);
ctx.bezierCurveTo(size * 0.9, -size * 0.45, size * 1.25, size * 0.55, 0, size * 1.45);
ctx.bezierCurveTo(-size * 1.25, size * 0.55, -size * 0.9, -size * 0.45, 0, size * 0.25);
ctx.fill();
ctx.restore();
}
function drawStar(x, y, r, color) {
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = color;
ctx.beginPath();
for (let i = 0; i < 10; i += 1) {
const ang = -Math.PI / 2 + i * Math.PI / 5;
const rad = i % 2 === 0 ? r : r * 0.45;
const px = Math.cos(ang) * rad;
const py = Math.sin(ang) * rad;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawFlower(x, y, size, color) {
ctx.save();
ctx.translate(x, y);
ctx.strokeStyle = '#7cc8a3';
ctx.lineWidth = Math.max(2, size * 0.12);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, size * 1.6);
ctx.stroke();
ctx.fillStyle = color;
for (let i = 0; i < 5; i += 1) {
const a = (Math.PI * 2 * i) / 5;
ctx.beginPath();
ctx.ellipse(Math.cos(a) * size * 0.55, Math.sin(a) * size * 0.55, size * 0.45, size * 0.28, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#ffe58a';
ctx.beginPath();
ctx.arc(0, 0, size * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawCloud(x, y, scale) {
ctx.save();
ctx.translate(x, y);
ctx.scale(scale, scale);
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.beginPath();
ctx.arc(0, 18, 22, 0, Math.PI * 2);
ctx.arc(24, 6, 28, 0, Math.PI * 2);
ctx.arc(54, 18, 20, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function roundRectPath(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawBackground(dt) {
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, '#fff2fb');
sky.addColorStop(1, '#dff7ff');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath();
ctx.arc(W * 0.18, H * 0.1, 48, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(W * 0.82, H * 0.14, 34, 0, Math.PI * 2);
ctx.fill();
cloudShift += dt * 0.006;
drawCloud((W * 0.1 + cloudShift * 8) % (W + 120) - 60, H * 0.14, 1.2);
drawCloud((W * 0.55 + cloudShift * 5) % (W + 120) - 40, H * 0.19, 0.9);
drawCloud((W * 0.8 + cloudShift * 6) % (W + 120) - 50, H * 0.11, 1.05);
ctx.fillStyle = '#c6f7df';
ctx.beginPath();
ctx.ellipse(W * 0.5, H * 0.92, W * 0.62, H * 0.11, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#a7edc8';
ctx.beginPath();
ctx.ellipse(W * 0.5, H * 0.98, W * 0.82, H * 0.14, 0, 0, Math.PI * 2);
ctx.fill();
if (state.currentMode === MODES.ZEN) {
game.flowers.forEach(f => drawFlower(f.x, f.y, f.size, f.color));
}
for (let i = 0; i < 8; i += 1) {
const fx = i * (W / 7) + (i % 2) * 20;
ctx.fillStyle = i % 2 ? '#ff9ed8' : '#ffe48f';
ctx.beginPath();
ctx.arc(fx, H * 0.88 + Math.sin(i + cloudShift * 0.03) * 8, 6 + (i % 3), 0, Math.PI * 2);
ctx.fill();
}
}
function drawCutie(now) {
const u = game.cutie;
const bob = Math.sin(now * 0.0045) * 3 - u.bounce * 12;
const tilt = Math.sin(now * 0.0035) * 0.008 - u.tilt * 0.03;
const trot = Math.sin(now * 0.012 + u.gallop);
const scale = Math.min(W / 720, H / 1280);
const hop = Math.max(0, trot) * 2.5 * scale;
const jointX = u.x;
const jointY = u.y + bob - hop;
ctx.save();
ctx.translate(jointX, jointY);
ctx.rotate(tilt * 0.45);
ctx.scale(1 + pulse * 0.015, 1 - pulse * 0.015);
if (cutieImageReady) {
const drawH = 300 * scale;
const aspect = cutieImage.naturalWidth > 0 && cutieImage.naturalHeight > 0 ? cutieImage.naturalWidth / cutieImage.naturalHeight : 0.72;
const drawW = drawH * aspect;
ctx.drawImage(cutieImage, -drawW * 0.5, -drawH * 0.72, drawW, drawH);
} else {
ctx.fillStyle = '#fffdfd';
ctx.strokeStyle = '#e7c5da';
ctx.lineWidth = 4;
roundRectPath(-48 * scale, -120 * scale, 96 * scale, 140 * scale, 32 * scale);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#8b5d41';
roundRectPath(-8 * scale, 0, 16 * scale, 220 * scale, 5 * scale);
ctx.fill();
}
ctx.restore();
}
function boop(pointerX = game.cutie.x, pointerY = game.cutie.y - 40) {
state.taps += 1;
pulse = 1;
game.cutie.bounce = 1;
game.cutie.tilt = 1;
game.cutie.gallop += 0.8;
centerTip.style.display = 'none';
if (state.currentMode === MODES.ZEN) {
const scale = Math.min(W / 720, H / 1280);
const golden = Math.random() < 0.18;
game.fallingHearts.push({
x: game.cutie.x + (Math.random() - 0.5) * 18,
y: game.cutie.y - 110,
vy: (1.8 + Math.random() * 1.2) * scale,
size: (golden ? 22 : 18) * scale,
golden,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: (Math.random() - 0.5) * 0.005,
planted: false
});
spawnStars(pointerX, pointerY, 8);
infoMain.textContent = 'Cutie helped the garden bloom.';
infoSub.textContent = 'One more heart for the flower bed.';
save();
return;
}
if (state.currentMode === MODES.BUBBLE) {
state.score += 1;
scoreValue.textContent = state.score.toLocaleString();
spawnPop('+1', pointerX, pointerY, '#ff5bb3', 22, false);
spawnStars(pointerX, pointerY, 6);
infoMain.textContent = 'Cutie helped pop the picnic.';
infoSub.textContent = 'Tap bubbles too for bigger bonuses.';
save();
return;
}
let gained = 1;
let label = '+1';
let popColor = '#ff5bb3';
let popSize = 24;
if (Math.random() < 0.16) {
gained = 3;
label = 'Sweet! +3';
popColor = '#b888ff';
popSize = 28;
}
if (Math.random() < 0.06) {
gained = 5;
label = 'Golden boop! +5';
popColor = '#ffb703';
popSize = 30;
}
const hugBurst = Math.random() < 0.12;
state.score += gained;
state.best = Math.max(state.best, state.score);
scoreValue.textContent = state.score.toLocaleString();
spawnHeartBurst(game.cutie.x + (Math.random() - 0.5) * 18, game.cutie.y - 58, gained >= 5);
if (gained >= 3) spawnHeartBurst(game.cutie.x + (Math.random() - 0.5) * 26, game.cutie.y - 48);
if (gained >= 5) spawnHeartBurst(game.cutie.x + (Math.random() - 0.5) * 34, game.cutie.y - 38, true);
spawnPop(label, pointerX, pointerY, popColor, popSize, false);
if (hugBurst) {
spawnPop('CUTIE GETS A HUG!!!', pointerX, pointerY - 34, '#ff5bb3', 42, true);
spawnStars(pointerX, pointerY - 8, 14);
}
spawnStars(pointerX, pointerY, gained >= 5 ? 12 : 7);
if (gained >= 5) {
infoMain.textContent = 'Cutie did a legendary boing.';
infoSub.textContent = 'You found a golden heart. Keep catching them!';
} else if (state.score > 0 && state.score % 25 === 0) {
infoMain.textContent = `Tiny milestone: ${state.score} Cute Points.`;
infoSub.textContent = 'Still pointless. Still slightly fun. Watch the drops!';
} else {
infoMain.textContent = 'Tap Cutie to make her boing.';
infoSub.textContent = 'Tap Cutie. Catch hearts. Dropping them costs points!';
}
save();
}
function pointerPos(ev) {
const rect = canvas.getBoundingClientRect();
return { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
}
function handleArcadeTap(p) {
let heartCaught = false;
for (let i = game.fallingHearts.length - 1; i >= 0; i -= 1) {
const h = game.fallingHearts[i];
const wobbleX = Math.sin(h.wobble) * 20;
const dxHeart = p.x - (h.x + wobbleX);
const dyHeart = p.y - h.y;
const heartRadius = h.size * 1.1;
if ((dxHeart * dxHeart) + (dyHeart * dyHeart) <= heartRadius * heartRadius) {
game.fallingHearts.splice(i, 1);
const bonus = h.golden ? 10 : 4;
state.score += bonus;
state.caughtHearts += 1;
scoreValue.textContent = state.score.toLocaleString();
spawnPop(h.golden ? 'Golden Heart! +10' : 'Caught! +4', p.x, p.y, h.golden ? '#ffb703' : '#ff5bb3', h.golden ? 30 : 24, h.golden);
spawnStars(p.x, p.y, h.golden ? 16 : 9);
infoMain.textContent = h.golden ? 'Golden heart caught!' : 'Heart caught!';
infoSub.textContent = h.golden ? 'That one was worth a lot. Watch the ground!' : 'Nice catch. Don\'t let the rest drop!';
save();
heartCaught = true;
break;
}
}
if (heartCaught) return true;
const scale = Math.min(W / 720, H / 1280);
const headCenterX = game.cutie.x;
const headCenterY = game.cutie.y - 92 * scale;
const dx = p.x - headCenterX;
const dy = p.y - headCenterY;
const distance = Math.sqrt(dx * dx + dy * dy);
const hitRadius = 62 * scale;
if (distance <= hitRadius) {
boop(p.x, p.y);
return true;
}
spawnPop('Miss!', p.x, p.y, 'rgba(111, 71, 116, 0.55)', 18, false);
return false;
}
function handleBubbleTap(p) {
for (let i = game.bubbles.length - 1; i >= 0; i -= 1) {
const b = game.bubbles[i];
const bubbleX = b.x + Math.sin(b.wobble) * 14;
const dx = p.x - bubbleX;
const dy = p.y - b.y;
if ((dx * dx) + (dy * dy) <= b.r * b.r) {
game.bubbles.splice(i, 1);
let value = 2;
let text = '+2';
let color = '#91f3d1';
if (b.kind === 'heart') {
value = 5; text = 'Heart Bubble! +5'; color = '#ff7fc8';
spawnHeartBurst(bubbleX, b.y, false);
} else if (b.kind === 'hug') {
value = 8; text = 'HUG! +8'; color = '#ff5bb3';
} else if (b.kind === 'gold') {
value = 12; text = 'Golden Pop! +12'; color = '#ffb703';
spawnHeartBurst(bubbleX, b.y, true);
} else {
value = 3; text = 'Sparkle! +3'; color = '#b888ff';
}
state.score += value;
scoreValue.textContent = state.score.toLocaleString();
spawnPop(text, bubbleX, b.y, color, b.kind === 'gold' ? 30 : 24, b.kind !== 'spark');
spawnStars(bubbleX, b.y, b.kind === 'gold' ? 16 : 10);
infoMain.textContent = b.kind === 'gold' ? 'Golden bubble popped!' : b.kind === 'hug' ? 'Cutie gets a hug!!!' : 'Bubble popped!';
infoSub.textContent = 'Keep the picnic going.';
save();
return true;
}
}
const scale = Math.min(W / 720, H / 1280);
const headCenterX = game.cutie.x;
const headCenterY = game.cutie.y - 92 * scale;
const dx = p.x - headCenterX;
const dy = p.y - headCenterY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= 62 * scale) {
boop(p.x, p.y);
return true;
}
spawnPop('plip', p.x, p.y, 'rgba(111, 71, 116, 0.38)', 16, false);
return false;
}
function tapped(ev) {
ev.preventDefault();
const p = pointerPos(ev);
if (state.currentMode === MODES.ZEN) {
boop(p.x, p.y);
return;
}
if (state.currentMode === MODES.ARCADE) handleArcadeTap(p);
else handleBubbleTap(p);
}
function drawBubble(b) {
const x = b.x + Math.sin(b.wobble) * 14;
const y = b.y;
ctx.save();
ctx.translate(x, y);
ctx.globalAlpha = 0.88;
const stroke = b.kind === 'gold' ? '#ffb703' : b.kind === 'heart' ? '#ff7fc8' : b.kind === 'hug' ? '#ff5bb3' : '#b888ff';
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(2, b.r * 0.1);
ctx.beginPath();
ctx.arc(0, 0, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.arc(-b.r * 0.25, -b.r * 0.25, b.r * 0.28, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fill();
if (b.kind === 'heart') drawHeart(0, 2, b.r * 0.45, '#ff7fc8');
else if (b.kind === 'gold') drawStar(0, 2, b.r * 0.45, '#ffb703');
else if (b.kind === 'hug') {
ctx.fillStyle = '#ff5bb3';
ctx.font = `900 ${Math.max(12, b.r * 0.55)}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('♡', 0, b.r * 0.2);
} else drawStar(0, 2, b.r * 0.38, '#b888ff');
ctx.restore();
}
function updateArcade(dt, now) {
if (now > game.nextHeartSpawn) spawnFallingHeart(now);
}
function updateZen(dt, now) {
if (now > game.nextHeartSpawn) spawnFallingHeart(now);
}
function updateBubble(dt, now) {
if (now > game.nextBubbleSpawn) spawnBubble(now);
}
function updateAndDrawEffects(dt, now) {
if (state.currentMode === MODES.ARCADE || state.currentMode === MODES.ZEN) {
game.fallingHearts.forEach(h => {
h.y += h.vy * dt * 0.06;
h.wobble += h.wobbleSpeed * dt;
const wobbleX = Math.sin(h.wobble) * 20;
ctx.save();
ctx.translate(h.x + wobbleX, h.y);
let drawSize = h.size;
if (h.golden) drawSize += Math.sin(now * 0.01) * 2;
drawHeart(0, 0, drawSize, h.golden ? '#ffb703' : '#ff7fc8');
ctx.restore();
});
if (state.currentMode === MODES.ARCADE) {
game.fallingHearts = game.fallingHearts.filter(h => {
if (h.y >= H + 60) {
const penalty = h.golden ? 5 : 2;
state.score = Math.max(0, state.score - penalty);
scoreValue.textContent = state.score.toLocaleString();
spawnPop(`-${penalty}`, h.x, H - 40, '#ff4757', 24, true);
save();
return false;
}
return true;
});
} else {
game.fallingHearts = game.fallingHearts.filter(h => {
if (h.y >= H - 230) {
if (game.flowers.length > 60) game.flowers.shift();
const flowerBaseY = H - 305 + Math.random() * 62;
game.flowers.push({
x: Math.max(34, Math.min(W - 34, h.x + Math.sin(h.wobble) * 20)),
y: flowerBaseY,
size: h.golden ? 14 : 10 + Math.random() * 4,
color: h.golden ? '#ffe58a' : Math.random() < 0.5 ? '#ff9ed8' : '#b888ff'
});
return false;
}
return true;
});
}
}
if (state.currentMode === MODES.BUBBLE) {
game.bubbles.forEach(b => {
b.wobble += b.wobbleSpeed * dt;
b.x += b.vx * dt * 0.06;
b.y += b.vy * dt * 0.06;
b.x = Math.max(40, Math.min(W - 40, b.x));
drawBubble(b);
});
game.bubbles = game.bubbles.filter(b => b.y + b.r > -40);
}
game.hearts.forEach(h => {
h.wobble += dt * 0.01;
h.x += (h.vx + Math.sin(h.wobble) * h.drift) * dt * 0.06;
h.y += h.vy * dt * 0.06;
h.life -= 0.012 * dt * 0.06;
ctx.save();
ctx.globalAlpha = Math.max(0, h.life);
ctx.translate(h.x, h.y);
ctx.rotate(Math.sin(h.wobble) * 0.15);
drawHeart(0, 0, h.size, h.golden ? '#ffd84d' : '#ff7fc8');
ctx.restore();
});
game.hearts = game.hearts.filter(h => h.life > 0);
const starColors = ['#ffe58a', '#b888ff', '#91f3d1'];
game.stars.forEach(s => {
s.x += s.vx * dt * 0.06;
s.y += s.vy * dt * 0.06;
s.vy += 0.035 * dt * 0.06;
s.life -= 0.018 * dt * 0.06;
ctx.save();
ctx.globalAlpha = Math.max(0, s.life);
drawStar(s.x, s.y, s.size, starColors[s.hue]);
ctx.restore();
});
game.stars = game.stars.filter(s => s.life > 0);
game.pops.forEach(p => {
p.x += p.driftX;
p.y -= (p.big ? 0.55 : 0.3) * dt * 0.06;
p.life -= (p.big ? 0.014 : 0.018) * dt * 0.06;
ctx.save();
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.textAlign = 'center';
ctx.lineWidth = p.big ? 8 : 4;
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
ctx.font = `900 ${p.size}px Inter, sans-serif`;
if (p.big) ctx.strokeText(p.text, p.x, p.y);
ctx.fillText(p.text, p.x, p.y);
ctx.restore();
});
game.pops = game.pops.filter(p => p.life > 0);
}
function tick(now) {
const dt = Math.min(32, now - last);
last = now;
pulse = Math.max(0, pulse - 0.028 * dt * 0.06);
game.cutie.bounce = Math.max(0, game.cutie.bounce - 0.03 * dt * 0.06);
game.cutie.tilt = Math.max(0, game.cutie.tilt - 0.032 * dt * 0.06);
game.cutie.gallop *= 0.992;
sparkleTimer += dt;
switch (state.currentMode) {
case MODES.ZEN:
updateZen(dt, now);
break;
case MODES.BUBBLE:
updateBubble(dt, now);
break;
case MODES.ARCADE:
default:
updateArcade(dt, now);
break;
}
drawBackground(dt);
drawCutie(now);
updateAndDrawEffects(dt, now);
if (sparkleTimer > 1200) {
sparkleTimer = 0;
if (Math.random() < 0.7) {
spawnStars(game.cutie.x + (Math.random() - 0.5) * 90, game.cutie.y - 140 + Math.random() * 40, 3);
}
}
requestAnimationFrame(tick);
}
function resetGame() {
const mode = state.currentMode;
state = freshState();
state.currentMode = mode;
scoreValue.textContent = '0';
game.hearts = [];
game.pops = [];
game.stars = [];
game.fallingHearts = [];
game.flowers = [];
game.bubbles = [];
game.nextHeartSpawn = performance.now() + 900;
game.nextBubbleSpawn = performance.now() + 800;
centerTip.style.display = 'block';
layoutByMode();
save();
}
canvas.addEventListener('pointerdown', tapped, { passive: false });
modeArcade.addEventListener('click', () => setMode(MODES.ARCADE));
modeZen.addEventListener('click', () => setMode(MODES.ZEN));
modeBubble.addEventListener('click', () => setMode(MODES.BUBBLE));
resetBtn.addEventListener('click', resetGame);
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', resize);
resize();
scoreValue.textContent = state.score.toLocaleString();
layoutByMode();
requestAnimationFrame(tick);
</script>
</body>
</html>