console
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>圣诞树</title>
<style>
*{margin:0;box-sizing:border-box}
html,body{height:100%}
canvas{display:block;width:100%;height:100%;}
body{overflow:hidden;background:#020308;}
</style>
</head>
<body>
<canvas id="scene"></canvas>
<script>
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const BLUE = ['#8efbff','#8b8dff','#5a86ff'];
const PINK = ['#ffc3ba','#ffadff','#ff60ff'];
const WHITE = ['#ffffff','#fffdad','#efffcf'];
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
let W, DPR, cx, flooY, topY, baseR, treeH;
let particles = [], ornaments = [], bgStars = [], floorDots = [], floaters = [];
const RIBBONS = [
{color:'#8efbff',turns:2.7,offset:0,width:1.7,sample:210},
{color:'#ffb0e2',turns:2.7,offset:Math.PI,width:1.7,sample:210},
{color:'#ffadff',turns:2.7,offset:Math.PI/2,width:1.2,sample:210},
];
function build(){
particles = []; ornaments = []; bgStars = []; floorDots = []; floaters = [];
const nP = Math.max(1000,Math.min(1800,Math.floor(W * H / 750)));
for(let i=0;i<nP;i++){
particles.push({
t:Math.random(),
theta:Math.random()*Math.PI*2,
rho:Math.random(),
size:0.6 + Math.random()*1.3,
color:Math.random()<0.42 ? pick(BLUE) : Math.random()<0.82 ? PINK : WHITE,
phase:Math.random()*Math.PI*2,
speed:1.5 + Math.random()*2.5
});
}
for(let i=0;i<36;i++){
ornaments.push({
t:0.12+Math.random()*0.88,
theta:Math.random()*Math.PI*2,
rho:0.2 + Math.random()*0.42,
size:1.8 + Math.random()*2.6,
color:Math.random()<0.5 ? pick(BLUE) : pick(PINK),
phase:Math.random()*Math.PI*2,
speed:1 + Math.random()*2
});
}
const nB = Math.max(70,Math.floor(W * H / 14000));
for(let i=0;i<nB;i++){
bgStars.push({
nx:Math.random(), ny:Math.random()*0.85,
size:0.5 + Math.random()*1.6,
phase:Math.random()*Math.PI*2,
speed:0.8 + Math.random()*1.8,
big:Math.random()<0.12
});
}
for(let i=0;i<70;i++){
floorDots.push({
ang:Math.random()*Math.PI*2,
dist:0.15 + Math.random()*1.15,
yJit:(Math.random()-0.5)*0.12,
size:1.2 + Math.random()*3.2,
color:Math.random()<0.45 ? pick(BLUE):Math.random()<0.9 ? pick(PINK):pick(WHITE),
phase:Math.random()*Math.PI*2,
speed:0.6 + Math.random()*1.4
});
}
for(let i=0;i<42;i++){
floaters.push({
ang:Math.random()*Math.PI*2,
dist:0.3 + Math.random()*1.2,
t:Math.random(),
size:0.5 + Math.random()*1.1,
color:Math.random()<0.5 ? pick(BLUE) : pick(PINK),
phase:Math.random()*Math.PI*2,
speed:0.04 + Math.random()*0.08
});
}
}
function resize(){
DPR = Math.min(window.devicePixelRatio || 1,2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
const scale = Math.min(W,H);
treeH = H * 0.66;
baseR = Math.min(scale * 0.30, W * 0.36);
floorY = H * 0.86;
topY = floorY - treeH;
cx = W / 2;
build();
}
function conePos(t, theta, rho, rot){
const r = Math.max(baseR * t,2);
const th = theta + rot;
const x3 = Math.cos(th) * r * rho;
const z3 = Math.sin(th) * r;
const depth = Math.max(0,Math.min(1,(z3 / r + 1)/2));
return { x: cx + x3, y: topY + t * treeH, depth: depth }
}
function paintBg(time){
const g = ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#060312');
g.addColorStop(0.55,'#04020c');
g.addColorStop(1,'#010007');
ctx.fillStyle = g;
ctx.fillRect(0,0,W,H);
ctx.globalCompositeOperation = 'lighter';
let h1 = ctx.createRadialGradient(cx - baseR * 0.4, topY + treeH * 0.45,0, cx - baseR * 0.4, topY + treeH * 0.45, treeH * 0.6);
h1.addColorStop(0,'rgba(90,140,255,0.10)');
ctx.fillStyle = h1;
ctx.fillRect(0,0,W,H);
let h2 = ctx.createRadialGradient(cx + baseR * 0.4, topY + treeH * 0.6,0, cx + baseR * 0.4, topY + treeH * 0.6, treeH * 0.6);
h2.addColorStop(1,'rgba(255,140,210,0.09)');
ctx.fillStyle = h2;
ctx.fillRect(0,0,W,H);
ctx.globalCompositeOperation = 'source-over';
}
function drawBgStars(time){
ctx.globalCompositeOperation = 'lighter';
for(const s of bgStars){
const a = 0.25 + 0.75 * (0.5 + 0.5 * Math.sin(time * s.speed + s.phase));
const x = s.nx * W, y = s.ny * H;
ctx.globalAlpha = a * 0.8;
ctx.fillStyle = '#fdfeff';
ctx.beginPath();
ctx.arc(x,y,s.size,0,Math.PI*2);
ctx.fill();
if(s.big){
ctx.globalAlpha = a * 0.5;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 0.6;
const l = s.size * 4;
ctx.beginPath();
ctx.moveTo(x - l, y); ctx.lineTo(x + l, y);
ctx.moveTo(x, y - l); ctx.lineTo(x, y + l);
ctx.stroke();
}
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawFloor(time){
ctx.globalCompositeOperation = 'lighter';
ctx.save();
ctx.translate(cx, floorY + baseR * 0.05);
ctx.scale(1,0.26);
const g = ctx.createRadialGradient(0,0,0,0,0,baseR * 1.35);
g.addColorStop(0,'rgba(160,190,255,0.20)');
g.addColorStop(0.5,'rgba(220,150,230,0.08)');
g.addColorStop(1,'rgba(160,190,255,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0,0,baseR*1.35,0,Math.PI*2);
ctx.fill();
ctx.restore();
for(const d of floorDots){
const sway = Math.sin(time * 0.3 + d.phase) * 0.05;
const x = cx + Math.cos(d.ang) * d.dist * baseR * (1 + sway);
const y = floorY + d.yJit * baseR + Math.sin(d.ang * 2) * baseR * 0.05;
const a = 0.15 + 0.6 * (0.5 + 0.5 * Math.sin(time * d.speed + d.phase));
ctx.globalAlpha = a;
ctx.fillStyle = d.color;
ctx.beginPath();
ctx.arc(x,y,d.size,0,Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawConeGlow(){
ctx.globalCompositeOperation = 'lighter';
const g = ctx.createLinearGradient(0,topY,0,floorY);
g.addColorStop(0,'rgba(180,160,255,0.05)');
g.addColorStop(1,'rgba(255,170,225,0.10)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(cx, topY);
ctx.lineTo(cx - baseR * 1.02, floorY);
ctx.lineTo(cx + baseR * 1.02, floorY);
ctx.closePath();
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
}
function drawTree(time, rot){
ctx.globalCompositeOperation = 'lighter';
for(const p of particles){
const q = conePos(p.t, p.theta, p.rho, rot);
const tw = 0.55 + 0.45 * (0.5 + 0.5 * Math.sin(time * p.speed + p.phase));
const depth = 0.45 + 0.55 * q.depth;
ctx.globalAlpha = tw * depth * 0.9;
ctx.fillStyle = p.color;
const s = p.size * (0.7 + 0.5 * q.depth);
ctx.beginPath();
ctx.arc(q.x,q.y,s,0,Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawRibbons(time, rot){
ctx.globalCompositeOperation = 'lighter';
for(const rb of RIBBONS){
for(let i=0;i<rb.sample;i++){
const s = i / (rb.sample - 1);
const t = 0.05 + s * 0.93;
const r = baseR * t * 1.02;
const th = s * rb.turns * Math.PI * 2 + rb.offset + rot;
const x = cx + Math.cos(th) * r;
const y = topY + t * treeH;
const z3 = Math.sin(th) * r;
const depth = Math.max(0,(z3 / r + 1)/2);
const tw = 0.6 + 0.4 * (0.5 + 0.5 * Math.sin(time * 2 + s * 6 + rb.offset));
const alpha = tw * (0.35 + 0.65 * depth);
ctx.fillStyle = rb.color;
ctx.globalAlpha = alpha * 0.32;
ctx.beginPath();
ctx.arc(x,y,rb.width * 2.2,0,Math.PI*2);
ctx.fill();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(x,y,rb.width * (0.8 + 0.4 * depth),0,Math.PI*2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawOrnaments(time, rot){
ctx.globalCompositeOperation = 'lighter';
for(const o of ornaments){
const q = conePos(o.t, o.theta, o.rho, rot);
const tw = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(time * o.speed + o.phase));
ctx.globalAlpha = 0.35 + 0.65 * q.depth;
ctx.shadowBlur = 12 * q.depth;
ctx.shadowColor = o.color;
ctx.fillStyle = o.color;
ctx.beginPath();
ctx.arc(q.x,q.y,o.size*(0.8+0.4*q.depth),0,Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = tw * 0.9;
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(q.x - o.size*0.25, q.y - o.size*0.25, o.size*0.3,0,Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawFloaters(time){
ctx.globalCompositeOperation = 'lighter';
for(const f of floaters){
f.t -= f.speed * 0.016;
if(f.t < 0){ f.t = 1; f.ang = Math.random()*Math.PI*2; }
const y = topY + f.t * treeH;
const x = cx + Math.cos(f.ang + time * 0.2) * f.dist * baseR * (1.15 - f.t * 0.4);
const a = 0.25 + 0.5 * (1 - f.t);
ctx.globalAlpha = a * (0.5 + 0.5 * Math.sin(time * 2 + f.phase));
ctx.fillStyle = f.color;
ctx.beginPath();
ctx.arc(x,y,f.size,0,Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function starPath(x, y, spikes, outer, inner, rot){
ctx.beginPath();
let ang = rot;
const step = Math.PI / spikes;
for(let i=0;i<spikes*2;i++){
const r = i%2 ===0 ? outer : inner;
const px = x + Math.cos(ang)*r;
const py = y + Math.sin(ang)*r;
if(i===0) ctx.moveTo(px,py); else ctx.lineTo(px,py);
ang += step;
}
ctx.closePath();
}
function drawTopStar(time){
const pulse = 1 + 0.12 * Math.sin(time * 2.2);
const x = cx, y = topY - treeH * 0.02;
ctx.globalCompositeOperation = 'lighter';
const g = ctx.createRadialGradient(x,y,0,x,y,baseR*0.5*pulse);
g.addColorStop(0,'rgba(255,235,250,0.5)');
g.addColorStop(0.4,'rgba(255,190,230,0.18)');
g.addColorStop(1,'rgba(255,190,230,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x,y,baseR*0.5*pulse,0,Math.PI*2);
ctx.fill();
ctx.shadowColor = '#ffe9fb';
ctx.shadowBlur = 22;
ctx.fillStyle = '#fff6fd';
starPath(x,y,5,baseR*0.14*pulse,baseR*0.06*pulse,-Math.PI/2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = 'source-over';
}
function drawGreeting(){
ctx.globalCompositeOperation = 'source-over';
const text = 'Merry Christmas';
const size = Math.max(14,Math.round(H*0.018));
ctx.font = '300 ' + size + 'px "Trebuchet MS", Georgia, serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const g = ctx.createLinearGradient(cx-120,0,cx+120,0);
g.addColorStop(0,'#9ecbff');
g.addColorStop(0.5,'#ffd0ee');
g.addColorStop(1,'#b7d3ff');
ctx.fillStyle = g;
ctx.globalAlpha = 0.75;
ctx.fillText(text,cx,H*0.955);
ctx.globalAlpha = 1;
}
function render(time, rot){
paintBg(time);
drawBgStars(time);
drawFloor(time);
drawConeGlow();
drawTree(time, rot);
drawRibbons(time, rot);
drawOrnaments(time, rot);
drawFloaters(time);
drawTopStar(time);
drawGreeting();
}
window.addEventListener('resize', resize);
resize();
render(0.6,0.2);
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if(reduceMotion){
window.setTimeout(()=>render(1.2,0.3),120);
}else{
const t0 = performance.now();
function frame(now){
const time = (now - t0)/1000;
render(time, time * 0.18);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
</script>
</body>
</html>