console
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#0a1622">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>鲸鱼娘 · 手机桌宠</title>
<style>
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{
margin:0;height:100%;overflow:hidden;overscroll-behavior:none;
background:radial-gradient(circle at 50% 38%, #1b3a57 0%, #0a1622 72%);
touch-action:none;
user-select:none;-webkit-user-select:none;
font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
}
canvas{display:block;position:fixed;inset:0;touch-action:none;}
/* 台词气泡 */
#bubble{
position:fixed;left:0;top:0;
transform:translate(-50%,-100%) scale(.7);
background:rgba(255,255,255,.95);color:#12324a;
padding:8px 15px;border-radius:15px;
font-size:15px;white-space:nowrap;
opacity:0;pointer-events:none;
box-shadow:0 8px 22px rgba(0,0,0,.4);
transition:transform .22s cubic-bezier(.3,1.6,.6,1),opacity .22s;
will-change:transform,opacity;
}
#bubble.show{opacity:1;transform:translate(-50%,-100%) scale(1);}
#bubble::after{
content:"";position:absolute;left:50%;bottom:-7px;
transform:translateX(-50%);
border:8px solid transparent;border-top-color:rgba(255,255,255,.95);border-bottom:0;
}
/* 底部提示 */
#hint{
position:fixed;left:50%;bottom:calc(24px + env(safe-area-inset-bottom));
transform:translateX(-50%);
color:rgba(190,232,255,.75);font-size:13px;letter-spacing:.5px;
white-space:nowrap;pointer-events:none;
transition:opacity .8s;text-shadow:0 2px 10px rgba(0,0,0,.5);
}
#hint.hide{opacity:0;}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="bubble"></div>
<div id="hint">轻点鲸鱼娘 · 拖动让她看你</div>
<script>
(function(){
'use strict';
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const bubble = document.getElementById('bubble');
const hint = document.getElementById('hint');
let vw = 0, vh = 0, scale = 1, dpr = 1;
function resize(){
vw = window.innerWidth;
vh = window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2.5);
canvas.width = Math.round(vw * dpr);
canvas.height = Math.round(vh * dpr);
canvas.style.width = vw + 'px';
canvas.style.height = vh + 'px';
scale = Math.min(vw / 340, vh / 430);
scale = Math.max(scale, 0.45);
placeBubble();
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', () => setTimeout(resize, 220));
function originY(){ return vh / 2 + 15 * scale; }
function headTopY(){ return originY() - 158 * scale; }
function placeBubble(){
bubble.style.left = (vw / 2) + 'px';
bubble.style.top = headTopY() + 'px';
}
const t0 = performance.now();
let last = t0;
let blinkP = 0, blinkLeft = 0, nextBlink = 1.6;
const look = { x: 0, y: 0 };
const lookT = { x: 0, y: 0 };
let jumpY = 0, jumpV = 0;
let spoutAcc = 0;
const particles = [];
let sayTimer = null, pressTimer = null, lookResetT = null;
const LINES = [
'噗噜噜~',
'今天也要加油哦!',
'摸头…有点害羞啦',
'要一起游泳吗?',
'咕噜咕噜…',
'累了就歇一会儿嘛~',
'我是鲸鱼娘小汐!',
'呼——喷水!',
'海水咸咸的~',
'手指…在跟着你哦'
];
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
canvas.addEventListener('pointermove', e => {
const dx = (e.clientX - vw / 2) / (150 * scale);
const dy = (e.clientY - originY()) / (150 * scale);
lookT.x = clamp(dx, -1, 1);
lookT.y = clamp(dy, -1, 1);
});
canvas.addEventListener('pointerdown', e => {
e.preventDefault();
if (jumpY === 0 && jumpV === 0) jumpV = -330;
burst(24, 1);
say(LINES[(Math.random() * LINES.length) | 0]);
hint.classList.add('hide');
clearInterval(pressTimer);
pressTimer = setInterval(() => burst(10, 0.75), 320);
clearTimeout(lookResetT);
});
function endPress(){
clearInterval(pressTimer);
pressTimer = null;
clearTimeout(lookResetT);
lookResetT = setTimeout(() => { lookT.x = 0; lookT.y = 0; }, 420);
}
window.addEventListener('pointerup', endPress);
window.addEventListener('pointercancel', endPress);
window.addEventListener('keydown', e => {
if (e.code === 'Space'){ e.preventDefault(); burst(20, 1); say('噗——!'); }
});
setTimeout(() => hint.classList.add('hide'), 5000);
function say(text){
bubble.textContent = text;
placeBubble();
bubble.classList.add('show');
clearTimeout(sayTimer);
sayTimer = setTimeout(() => bubble.classList.remove('show'), 1900);
}
function burst(n, power){
for (let i = 0; i < n; i++){
const life = 1.0 + Math.random() * 0.7;
particles.push({
x: (Math.random() - 0.5) * 16,
y: -156 + Math.random() * 8,
vx: (Math.random() - 0.5) * 230 * power,
vy: (-160 - Math.random() * 180) * power,
life, max: life,
size: 2 + Math.random() * 3.4,
hue: 190 + Math.random() * 25
});
}
if (particles.length > 220) particles.splice(0, particles.length - 220);
}
function frame(now){
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const t = (now - t0) / 1000;
nextBlink -= dt;
if (nextBlink <= 0 && blinkLeft <= 0){
blinkLeft = 0.16;
nextBlink = 2 + Math.random() * 4;
}
if (blinkLeft > 0){
blinkLeft -= dt;
const p = Math.min(1, Math.max(0, 1 - blinkLeft / 0.16));
blinkP = Math.sin(Math.PI * p);
} else blinkP = 0;
const k = 1 - Math.pow(0.001, dt);
look.x += (lookT.x - look.x) * k;
look.y += (lookT.y - look.y) * k;
if (jumpY < 0 || jumpV !== 0){
jumpV += 1400 * dt;
jumpY += jumpV * dt;
if (jumpY >= 0 && jumpV > 0){ jumpY = 0; jumpV = 0; }
}
spoutAcc += dt * 7;
while (spoutAcc >= 1){
spoutAcc -= 1;
const life = 0.85 + Math.random() * 0.5;
particles.push({
x: (Math.random() - 0.5) * 14,
y: -158 + Math.random() * 6,
vx: (Math.random() - 0.5) * 55,
vy: -135 - Math.random() * 75,
life, max: life,
size: 1.4 + Math.random() * 2.3,
hue: 190 + Math.random() * 25
});
}
if (particles.length > 220) particles.splice(0, particles.length - 220);
for (let i = particles.length - 1; i >= 0; i--){
const p = particles[i];
p.vy += 430 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) particles.splice(i, 1);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, vw, vh);
ctx.save();
ctx.translate(vw / 2, originY());
ctx.scale(scale, scale);
drawBackground(t);
drawGirl(t, blinkP);
drawParticles();
ctx.restore();
requestAnimationFrame(frame);
}
function drawBackground(t){
ctx.save();
ctx.strokeStyle = '#9FE8FF';
ctx.lineWidth = 1.4;
for (let i = 0; i < 14; i++){
const seed = i * 2.399;
const x = Math.sin(seed) * 165 + Math.sin(t * 0.6 + i) * 8;
const y = 210 - ((t * 20 + i * 88) % 470);
const r = 3 + (i % 4) * 2.6;
ctx.globalAlpha = 0.10 + (i % 3) * 0.05;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
const HY = -88;
const FY = -80;
function drawArm(s, t){
ctx.save();
ctx.translate(s * 40, 12);
ctx.rotate(s * (0.25 + Math.sin(t * 2 + s) * 0.12));
ctx.fillStyle = '#CDEEFC';
ctx.beginPath();
ctx.ellipse(0, 14, 10, 17, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawEye(cx, cy, blink, lx, ly){
const open = 1 - blink;
if (open < 0.12){
ctx.beginPath();
ctx.moveTo(cx - 11, cy);
ctx.quadraticCurveTo(cx, cy + 7, cx + 11, cy);
ctx.strokeStyle = '#2C5570';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.stroke();
return;
}
const ry = 15 * open;
ctx.beginPath();
ctx.ellipse(cx, cy, 12, ry, 0, 0, Math.PI * 2);
ctx.fillStyle = '#FFFFFF';
ctx.fill();
ctx.save();
ctx.beginPath();
ctx.ellipse(cx, cy, 12, ry, 0, 0, Math.PI * 2);
ctx.clip();
const g = ctx.createLinearGradient(0, cy - ry, 0, cy + ry);
g.addColorStop(0, '#3E9BD6');
g.addColorStop(0.55, '#155C8F');
g.addColorStop(1, '#0A2B44');
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(cx + lx * 4, cy + ly * 3, 9, ry * 0.92, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.beginPath();
ctx.arc(cx + lx * 4 - 3.2, cy + ly * 3 - ry * 0.4, 3.4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.beginPath();
ctx.arc(cx + lx * 4 + 3.5, cy + ly * 3 + ry * 0.35, 1.9, 0, Math.PI * 2);
ctx.fill();
}
function drawGirl(t, blink){
const lx = look.x, ly = look.y;
ctx.beginPath();
ctx.ellipse(0, 118, 42, 9, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.20)';
ctx.fill();
ctx.save();
ctx.translate(0, 40);
ctx.rotate(Math.sin(t * 1.6) * 0.16);
const tg = ctx.createLinearGradient(0, 0, 0, 80);
tg.addColorStop(0, '#8FE3FF');
tg.addColorStop(1, '#2AA0DC');
ctx.fillStyle = tg;
ctx.beginPath();
ctx.moveTo(0, -12);
ctx.bezierCurveTo(-8, 18, -28, 44, -52, 58);
ctx.bezierCurveTo(-32, 64, -10, 52, 0, 36);
ctx.bezierCurveTo(10, 52, 32, 64, 52, 58);
ctx.bezierCurveTo(28, 44, 8, 18, 0, -12);
ctx.closePath();
ctx.fill();
ctx.restore();
const bodyG = ctx.createLinearGradient(0, -20, 0, 80);
bodyG.addColorStop(0, '#F0FBFF');
bodyG.addColorStop(1, '#A9DFF7');
ctx.fillStyle = bodyG;
ctx.beginPath();
ctx.ellipse(0, 26, 44, 48, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.ellipse(0, 36, 30, 32, 0, 0, Math.PI * 2);
ctx.fill();
drawArm(-1, t);
drawArm( 1, t);
const hairG = ctx.createLinearGradient(0, HY - 72, 0, HY + 72);
hairG.addColorStop(0, '#7FD6F7');
hairG.addColorStop(1, '#2A9BD6');
ctx.fillStyle = hairG;
ctx.beginPath();
ctx.ellipse(0, HY, 70, 70, 0, 0, Math.PI * 2);
ctx.fill();
[-1, 1].forEach(s => {
ctx.save();
ctx.translate(s * 64, HY + 14);
ctx.rotate(s * (0.4 + Math.sin(t * 2 + s * 1.3) * 0.1));
ctx.fillStyle = '#A5E7FF';
ctx.beginPath();
ctx.ellipse(s * 8, 0, 22, 13, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
[-1, 1].forEach(s => {
ctx.fillStyle = hairG;
ctx.beginPath();
ctx.moveTo(s * 56, FY - 32);
ctx.quadraticCurveTo(s * 76, FY + 18, s * 62, FY + 62);
ctx.quadraticCurveTo(s * 50, FY + 28, s * 44, FY - 12);
ctx.closePath();
ctx.fill();
});
ctx.fillStyle = '#FFE9DF';
ctx.beginPath();
ctx.ellipse(0, FY, 58, 54, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = hairG;
ctx.beginPath();
ctx.arc(0, FY, 60, Math.PI, 0);
ctx.quadraticCurveTo(58, FY - 4, 40, FY - 16);
ctx.quadraticCurveTo(28, FY - 40, 16, FY - 12);
ctx.quadraticCurveTo(4, FY - 44,-10, FY - 12);
ctx.quadraticCurveTo(-24, FY - 40,-36, FY - 16);
ctx.quadraticCurveTo(-56, FY - 4, -60, FY);
ctx.closePath();
ctx.fill();
drawEye(-26, FY + 20, blink, lx, ly);
drawEye( 26, FY + 20, blink, lx, ly);
[-1, 1].forEach(s => {
ctx.beginPath();
ctx.ellipse(s * 40, FY + 34, 11, 6.5, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,145,170,0.42)';
ctx.fill();
});
ctx.beginPath();
ctx.moveTo(-8, FY + 40);
ctx.quadraticCurveTo(0, FY + 49, 8, FY + 40);
ctx.strokeStyle = '#D9737D';
ctx.lineWidth = 2.6;
ctx.lineCap = 'round';
ctx.stroke();
}
function drawParticles(){
for (const p of particles){
const a = Math.max(0, p.life / p.max);
ctx.globalAlpha = a;
ctx.fillStyle = 'hsl(' + p.hue + ',100%,78%)';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * a), 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
resize();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>