Files
BrokenClock/BrokenClock.html

785 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Broken Time — Roman Clock</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600&family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Special+Elite&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #003399; }
body {
font-family: 'Cinzel', serif;
color: #fff;
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 48 48'%3E%3Cg transform='rotate(-45 12 12)'%3E%3Crect x='2' y='6' width='20' height='12' fill='%23a8a8a8' stroke='%23222' stroke-width='1' rx='1'/%3E%3Crect x='3' y='7' width='18' height='3' fill='%23e8e8e8'/%3E%3Crect x='9' y='16' width='8' height='3' fill='%23333'/%3E%3Crect x='10' y='18' width='6' height='24' fill='%238B5A2B' stroke='%233D2611' stroke-width='0.5'/%3E%3Cline x1='10' y1='24' x2='16' y2='24' stroke='%233D2611' stroke-width='0.5'/%3E%3Cline x1='10' y1='30' x2='16' y2='30' stroke='%233D2611' stroke-width='0.5'/%3E%3Cline x1='10' y1='36' x2='16' y2='36' stroke='%233D2611' stroke-width='0.5'/%3E%3Crect x='9' y='40' width='8' height='3' fill='%233D2611' rx='1'/%3E%3C/g%3E%3C/svg%3E") 8 8, auto;
}
canvas { display: block; position: absolute; top: 0; left: 0; }
.corner {
position: fixed; color: rgba(255,255,255,0.22);
font-family: 'Special Elite', 'Courier New', monospace;
font-size: 11px; letter-spacing: 2px;
pointer-events: none; user-select: none; line-height: 1.7; z-index: 5;
}
.corner.tl { top: 28px; left: 32px; }
.corner.tr { top: 28px; right: 32px; text-align: right; }
.corner.bl { bottom: 28px; left: 32px; }
.corner.br { bottom: 28px; right: 32px; text-align: right; }
.hint {
position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.4); font-size: 11px; letter-spacing: 6px;
text-transform: uppercase; font-family: 'Courier New', monospace;
pointer-events: none; user-select: none; z-index: 10;
transition: opacity 0.6s; animation: pulse 3s ease-in-out infinite;
}
.hint.hidden { opacity: 0; animation: none; }
@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 0.78; } }
.reform {
position: fixed; bottom: 32px; left: 50%; transform: translateX(-50%);
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.3);
color: rgba(255,255,255,0.8); padding: 11px 28px;
font-family: 'Courier New', monospace; font-size: 11px; letter-spacing: 5px;
cursor: inherit; opacity: 0; pointer-events: none;
transition: opacity 0.6s, background 0.2s, border-color 0.2s, color 0.2s;
text-transform: uppercase; z-index: 10;
}
.reform.show { opacity: 1; pointer-events: auto; }
.reform:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.65); color: #fff; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="corner tl">BSOD // 0x0000007B<br>SESSION: FRAGMENTED</div>
<div class="corner tr">A problem has been detected<br>and the clock has stopped</div>
<div class="corner bl">ROMAN.PROTOCOL // XII<br>MEMENTO · TEMPUS · CARPE</div>
<div class="corner br">fragment.time<br>v.0.7.3 — struck</div>
<div class="hint" id="hint">Strike the dial to shatter</div>
<button class="reform" id="reform">Reform Time</button>
<script>
'use strict';
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const hint = document.getElementById('hint');
const reformBtn = document.getElementById('reform');
const clockCanvas = document.createElement('canvas');
const clockCtx = clockCanvas.getContext('2d');
const textCanvas = document.createElement('canvas');
const textCtx = textCanvas.getContext('2d');
let W, H, cx, cy, radius, MIN_AREA;
let state = 'intact';
let shards = [];
let struck = false;
let strikeTime = 0;
let strikeX = 0, strikeY = 0;
let textDirty = true;
let isStriking = false;
let reformState = null;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
cx = W / 2;
cy = H / 2;
radius = Math.max(160, Math.min(W, H) * 0.34);
MIN_AREA = radius * radius * 0.006;
clockCanvas.width = W; clockCanvas.height = H;
textCanvas.width = W; textCanvas.height = H;
textDirty = true;
if (state !== 'intact') reset();
}
resize();
window.addEventListener('resize', resize);
canvas.addEventListener('mousedown', e => {
if (isStriking) return;
if (state !== 'intact' && state !== 'shattered') return;
const x = e.clientX, y = e.clientY;
if (state === 'intact') {
const dx = x - cx, dy = y - cy;
if (dx*dx + dy*dy <= radius * radius * 1.05) {
isStriking = true;
triggerStrike(x, y, () => {
shatterInitial(strikeX, strikeY);
isStriking = false;
setTimeout(() => reformBtn.classList.add('show'), 800);
});
}
} else if (state === 'shattered') {
const shard = findShardAt(x, y);
if (shard && shard.area > MIN_AREA) {
isStriking = true;
triggerStrike(x, y, () => {
if (shards.indexOf(shard) < 0) { isStriking = false; return; }
shatterShard(shard, strikeX, strikeY);
isStriking = false;
});
}
}
});
function triggerStrike(x, y, callback) {
strikeX = x;
strikeY = y;
struck = true;
strikeTime = performance.now();
hint.classList.add('hidden');
setTimeout(callback, 130);
}
reformBtn.addEventListener('click', () => {
if (state === 'shattered') startReform();
});
function reset() {
state = 'intact';
shards = [];
struck = false;
isStriking = false;
reformState = null;
hint.classList.remove('hidden');
reformBtn.classList.remove('show');
}
function startReform() {
state = 'reforming';
reformBtn.classList.remove('show');
reformState = {
startTime: performance.now(),
moveDuration: 1000, // 阶段1:位移归位
fadeDuration: 600, // 阶段2:内容渐隐
initialStates: shards.map(s => ({
shard: s,
startOffsetX: s.offsetX,
startOffsetY: s.offsetY,
startRotation: s.rotation,
})),
};
}
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
const ROMANS = ['XII', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI'];
function drawClockTo(c) {
c.save();
c.fillStyle = 'rgba(255, 255, 255, 0.95)';
c.font = `${Math.floor(radius * 0.14)}px "Cinzel", serif`;
c.textAlign = 'center';
c.textBaseline = 'middle';
for (let i = 0; i < 12; i++) {
const angle = i * Math.PI / 6 - Math.PI / 2;
c.fillText(ROMANS[i], cx + Math.cos(angle) * radius * 0.78, cy + Math.sin(angle) * radius * 0.78);
}
c.strokeStyle = 'rgba(255, 255, 255, 0.5)';
for (let i = 0; i < 60; i++) {
const angle = i * Math.PI / 30 - Math.PI / 2;
const r1 = i % 5 === 0 ? radius * 0.88 : radius * 0.92;
c.lineWidth = i % 5 === 0 ? 1.4 : 0.6;
c.beginPath();
c.moveTo(cx + Math.cos(angle) * r1, cy + Math.sin(angle) * r1);
c.lineTo(cx + Math.cos(angle) * radius * 0.96, cy + Math.sin(angle) * radius * 0.96);
c.stroke();
}
const now = new Date();
const h = now.getHours() % 12;
const m = now.getMinutes();
const s = now.getSeconds();
const ms = now.getMilliseconds();
const hourAngle = (h + m/60 + s/3600) * Math.PI / 6 - Math.PI/2;
const minAngle = (m + s/60 + ms/60000) * Math.PI / 30 - Math.PI/2;
const secAngle = (s + ms/1000) * Math.PI / 30 - Math.PI/2;
c.lineCap = 'round';
c.strokeStyle = 'rgba(180, 220, 255, 0.35)';
c.lineWidth = 6;
c.beginPath();
c.moveTo(cx - Math.cos(hourAngle) * radius * 0.08, cy - Math.sin(hourAngle) * radius * 0.08);
c.lineTo(cx + Math.cos(hourAngle) * radius * 0.45, cy + Math.sin(hourAngle) * radius * 0.45);
c.stroke();
c.strokeStyle = '#ffffff';
c.lineWidth = 2.8;
c.beginPath();
c.moveTo(cx - Math.cos(hourAngle) * radius * 0.08, cy - Math.sin(hourAngle) * radius * 0.08);
c.lineTo(cx + Math.cos(hourAngle) * radius * 0.45, cy + Math.sin(hourAngle) * radius * 0.45);
c.stroke();
c.strokeStyle = 'rgba(200, 230, 255, 0.35)';
c.lineWidth = 4.5;
c.beginPath();
c.moveTo(cx - Math.cos(minAngle) * radius * 0.08, cy - Math.sin(minAngle) * radius * 0.08);
c.lineTo(cx + Math.cos(minAngle) * radius * 0.65, cy + Math.sin(minAngle) * radius * 0.65);
c.stroke();
c.strokeStyle = '#ffffff';
c.lineWidth = 1.8;
c.beginPath();
c.moveTo(cx - Math.cos(minAngle) * radius * 0.08, cy - Math.sin(minAngle) * radius * 0.08);
c.lineTo(cx + Math.cos(minAngle) * radius * 0.65, cy + Math.sin(minAngle) * radius * 0.65);
c.stroke();
c.strokeStyle = 'rgba(255, 200, 100, 0.4)';
c.lineWidth = 3.5;
c.beginPath();
c.moveTo(cx - Math.cos(secAngle) * radius * 0.12, cy - Math.sin(secAngle) * radius * 0.12);
c.lineTo(cx + Math.cos(secAngle) * radius * 0.78, cy + Math.sin(secAngle) * radius * 0.78);
c.stroke();
c.strokeStyle = '#fff5cc';
c.lineWidth = 1;
c.beginPath();
c.moveTo(cx - Math.cos(secAngle) * radius * 0.12, cy - Math.sin(secAngle) * radius * 0.12);
c.lineTo(cx + Math.cos(secAngle) * radius * 0.78, cy + Math.sin(secAngle) * radius * 0.78);
c.stroke();
c.fillStyle = '#ffffff';
c.beginPath(); c.arc(cx, cy, 4, 0, Math.PI * 2); c.fill();
c.fillStyle = '#003399';
c.beginPath(); c.arc(cx, cy, 1.5, 0, Math.PI * 2); c.fill();
c.restore();
}
const TEXT_LINES = [
'Time is the fire in which we burn.',
'The dial shatters; the hours scatter like stars.',
'Each tick a heartbeat, each tock a wound.',
'What remains when the numbers fall?',
'Only silence between the broken seconds.',
'Memory is a cracked mirror reflecting nothing.',
'We are fragments of a forgotten whole.',
'The hammer falls — the clock dies — we with it.',
'I II III IV V VI VII VIII IX X XI XII',
'Tempus fugit. Time flees; we follow.',
'Memento mori. Remember you must die.',
'Carpe noctem. Seize the fractured night.',
'The center cannot hold; things fall apart.',
'A second is a lie the clock once told.',
'Hours bleed through cracks of shattered glass.',
'VII hours to sleep, III to dream, XII to forget.',
'The past is broken glass; each shard, a memory.',
'Tempus edax rerum — time, devourer of all.',
'What is time but the shadow of desire?',
'The hour hand trembles; the minute hand falls.',
'Clockwork broken, gears without time, we wait.',
'In the silence between ticks, eternity hides.',
'Fractured moment, memory fragment, broken now.',
'We measure our lives in broken dials and lost hours.',
'The clock face cracks; the numerals bleed away.',
'Each second a shard, each minute a wound unhealed.',
'Time is a circle broken by the hand that strikes.',
'Beneath the hammer of consequence, we break.',
'Of a larger breaking, we are but echoes.',
'The hours bleed away through the cracks of time.',
'XII hours to dawn, XII to dusk, XII to nothing.',
'The clock dies, and in its dying, speaks.',
'What the hammer strikes, time cannot mend.',
'Each numeral a wound; each wound, a number.',
'In shards we read the silence of the hours.',
'Strike the dial — shatter the lie — free the hour.',
'Memento — tempus — carpe — fragmentum.',
'A fractured moment, a memory, a broken now.',
'The dial breaks; the time bleeds; we remain.',
'IX lives, VII sleepers, III in the trinity of dusk.',
'When the XII fall, only I remains — and I am nothing.',
'Tempus omnia revelat. Time reveals all things.',
'And in the end, the clock was always already broken.',
];
function drawTextShardTo(c) {
c.save();
const fontSize = Math.max(9, Math.floor(radius * 0.052));
c.fillStyle = 'rgba(255, 255, 255, 0.78)';
c.font = `italic 400 ${fontSize}px "EB Garamond", "Cormorant Garamond", serif`;
c.textAlign = 'left';
c.textBaseline = 'middle';
const lineH = fontSize * 1.5;
let textIdx = 0;
let y = cy - radius + lineH * 0.5;
while (y < cy + radius) {
const dy = y - cy;
const absDy = Math.abs(dy);
if (absDy < radius) {
const halfWidth = Math.sqrt(Math.max(0, radius * radius - dy * dy));
const xStart = cx - halfWidth + 8;
const xEnd = cx + halfWidth - 8;
let x = xStart;
while (x < xEnd) {
const text = TEXT_LINES[textIdx % TEXT_LINES.length];
c.fillText(text, x, y);
textIdx++;
const textWidth = c.measureText(text).width;
x += textWidth + fontSize * 0.9;
}
}
y += lineH;
}
c.restore();
}
function shatterInitial(clickX, clickY) {
state = 'shattered';
const cp = { x: clickX, y: clickY };
const numShards = 5;
const intervals = [];
let total = 0;
for (let i = 0; i < numShards; i++) {
const v = 0.6 + Math.random() * 1.4;
intervals.push(v);
total += v;
}
const angles = [];
const startAngle = Math.random() * Math.PI * 2;
angles.push(startAngle);
let cum = 0;
for (let i = 0; i < numShards - 1; i++) {
cum += intervals[i] / total * Math.PI * 2;
angles.push((startAngle + cum) % (Math.PI * 2));
}
angles.sort((a, b) => a - b);
const edges = [];
for (let i = 0; i < numShards; i++) {
const a = angles[i];
const bx = cx + radius * Math.cos(a);
const by = cy + radius * Math.sin(a);
edges.push(zigzagPath(cp.x, cp.y, bx, by));
}
const arcs = [];
for (let i = 0; i < numShards; i++) {
const a1 = angles[i];
const a2 = angles[(i + 1) % numShards];
const arc = [];
const steps = 30;
let aDiff = a2 - a1;
if (aDiff < 0) aDiff += Math.PI * 2;
for (let j = 0; j <= steps; j++) {
const a = a1 + aDiff * j / steps;
arc.push([cx + radius * Math.cos(a), cy + radius * Math.sin(a)]);
}
arcs.push(arc);
}
for (let i = 0; i < numShards; i++) {
const nextI = (i + 1) % numShards;
const points = [];
for (let k = 0; k < edges[i].length; k++) points.push(edges[i][k]);
for (let k = 1; k < arcs[i].length; k++) points.push(arcs[i][k]);
for (let k = edges[nextI].length - 2; k >= 0; k--) points.push(edges[nextI][k]);
const centroid = computeCentroid(points);
const area = computeArea(points);
const fdx = centroid[0] - cp.x;
const fdy = centroid[1] - cp.y;
const fdist = Math.max(0.1, Math.sqrt(fdx * fdx + fdy * fdy));
shards.push({
points, centroid, area,
vx: (fdx / fdist) * (0.8 + Math.random() * 0.9),
vy: (fdy / fdist) * (0.8 + Math.random() * 0.9),
rotation: 0,
rotationSpeed: (Math.random() - 0.5) * 0.012,
offsetX: 0, offsetY: 0,
type: 'clock',
effRadius: Math.sqrt(area / Math.PI),
birthTime: performance.now(),
});
}
shards.sort((a, b) => b.area - a.area);
shards[1].type = 'text';
shards.forEach((s, i) => {
const speedFactor = 0.4 + i * 0.1;
s.vx *= speedFactor;
s.vy *= speedFactor;
});
}
function shatterShard(shard, clickX, clickY) {
const [lcx, lcy] = pointToLocal(shard, clickX, clickY);
const numRays = 3 + Math.floor(Math.random() * 2);
const baseAngle = Math.random() * Math.PI * 2;
const intersections = [];
for (let i = 0; i < numRays; i++) {
const angle = baseAngle + (i / numRays) * Math.PI * 2 + (Math.random() - 0.5) * 0.5;
const dx = Math.cos(angle);
const dy = Math.sin(angle);
const inter = rayPolygonIntersect(lcx, lcy, dx, dy, shard.points);
if (inter) intersections.push({ ...inter, angle });
}
if (intersections.length < 2) return;
intersections.sort((a, b) => a.angle - b.angle);
const numNew = intersections.length;
const edges = [];
for (let i = 0; i < numNew; i++) {
edges.push(zigzagPath(lcx, lcy, intersections[i].point[0], intersections[i].point[1]));
}
const n = shard.points.length;
const newShards = [];
for (let i = 0; i < numNew; i++) {
const nextI = (i + 1) % numNew;
const start = intersections[i];
const end = intersections[nextI];
const points = [];
for (let k = 0; k < edges[i].length; k++) points.push(edges[i][k]);
if (start.edgeIndex !== end.edgeIndex) {
let ei = (start.edgeIndex + 1) % n;
while (ei !== end.edgeIndex) {
points.push(shard.points[ei]);
ei = (ei + 1) % n;
}
points.push(shard.points[ei]);
}
for (let k = edges[nextI].length - 1; k >= 1; k--) points.push(edges[nextI][k]);
const centroid = computeCentroid(points);
const area = computeArea(points);
if (area < 50) continue;
const [scx, scy] = pointToScreen(shard, centroid[0], centroid[1]);
const fdx = scx - clickX;
const fdy = scy - clickY;
const fdist = Math.max(0.1, Math.sqrt(fdx * fdx + fdy * fdy));
const baseSpeed = 0.5 + Math.random() * 0.5;
newShards.push({
points, centroid, area,
offsetX: shard.offsetX, offsetY: shard.offsetY,
rotation: shard.rotation,
vx: (fdx / fdist) * baseSpeed + shard.vx * 0.4,
vy: (fdy / fdist) * baseSpeed + shard.vy * 0.4,
rotationSpeed: (Math.random() - 0.5) * 0.012 + shard.rotationSpeed * 0.3,
type: shard.type,
effRadius: Math.sqrt(area / Math.PI),
birthTime: performance.now(),
});
}
if (newShards.length < 2) return;
const idx = shards.indexOf(shard);
if (idx >= 0) shards.splice(idx, 1, ...newShards);
}
function resolveCollisions() {
for (let i = 0; i < shards.length; i++) {
for (let j = i + 1; j < shards.length; j++) {
const a = shards[i], b = shards[j];
const ax = a.centroid[0] + a.offsetX;
const ay = a.centroid[1] + a.offsetY;
const bx = b.centroid[0] + b.offsetX;
const by = b.centroid[1] + b.offsetY;
const dx = bx - ax, dy = by - ay;
const distSq = dx * dx + dy * dy;
const minDist = (a.effRadius + b.effRadius) * 0.82;
if (distSq < minDist * minDist && distSq > 0.01) {
const dist = Math.sqrt(distSq);
const overlap = minDist - dist;
const nx = dx / dist, ny = dy / dist;
const totalArea = a.area + b.area;
a.offsetX -= nx * overlap * (b.area / totalArea);
a.offsetY -= ny * overlap * (b.area / totalArea);
b.offsetX += nx * overlap * (a.area / totalArea);
b.offsetY += ny * overlap * (a.area / totalArea);
const sep = 0.08;
a.vx -= nx * sep; a.vy -= ny * sep;
b.vx += nx * sep; b.vy += ny * sep;
}
}
}
}
function pointToScreen(shard, lx, ly) {
const dx = lx - shard.centroid[0];
const dy = ly - shard.centroid[1];
const cos = Math.cos(shard.rotation);
const sin = Math.sin(shard.rotation);
return [
dx * cos - dy * sin + shard.centroid[0] + shard.offsetX,
dx * sin + dy * cos + shard.centroid[1] + shard.offsetY
];
}
function pointToLocal(shard, sx, sy) {
const dx = sx - shard.centroid[0] - shard.offsetX;
const dy = sy - shard.centroid[1] - shard.offsetY;
const cos = Math.cos(shard.rotation);
const sin = Math.sin(shard.rotation);
return [
dx * cos + dy * sin + shard.centroid[0],
-dx * sin + dy * cos + shard.centroid[1]
];
}
function pointInPolygon(x, y, points) {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i][0], yi = points[i][1];
const xj = points[j][0], yj = points[j][1];
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
inside = !inside;
}
}
return inside;
}
function findShardAt(x, y) {
for (let i = shards.length - 1; i >= 0; i--) {
const s = shards[i];
const [lx, ly] = pointToLocal(s, x, y);
if (pointInPolygon(lx, ly, s.points)) return s;
}
return null;
}
function rayPolygonIntersect(ox, oy, dx, dy, points) {
let closestT = Infinity, closestEdge = -1, closestPoint = null;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
const ax = points[i][0], ay = points[i][1];
const bx = points[j][0], by = points[j][1];
const ex = bx - ax, ey = by - ay;
const det = dy * ex - dx * ey;
if (Math.abs(det) < 1e-10) continue;
const t = (ex * (ay - oy) - ey * (ax - ox)) / det;
const u = (dx * (ay - oy) - dy * (ax - ox)) / det;
if (t > 1e-6 && u >= 0 && u <= 1 && t < closestT) {
closestT = t; closestEdge = i;
closestPoint = [ox + t * dx, oy + t * dy];
}
}
return closestEdge >= 0 ? { point: closestPoint, edgeIndex: closestEdge } : null;
}
function zigzagPath(x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) return [[x1, y1], [x2, y2]];
const nx = -dy / dist, ny = dx / dist;
const points = [[x1, y1]];
const segments = 2 + Math.floor(Math.random() * 2);
for (let i = 1; i < segments; i++) {
const t = i / segments;
const offset = (Math.random() - 0.5) * dist * 0.18;
points.push([x1 + dx * t + nx * offset, y1 + dy * t + ny * offset]);
}
points.push([x2, y2]);
return points;
}
function computeArea(points) {
let area = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
area += points[i][0] * points[j][1];
area -= points[j][0] * points[i][1];
}
return Math.abs(area / 2);
}
function computeCentroid(points) {
let cx_ = 0, cy_ = 0;
for (const p of points) { cx_ += p[0]; cy_ += p[1]; }
return [cx_ / points.length, cy_ / points.length];
}
// reformProgress: 0=完全破碎态, 1=完全归位且文字渐隐完成
// 在阶段1(位移归位)时 reformProgress=0,阶段2(内容渐隐)时从0升到1
function drawShard(shard, reformProgress = 0, bgAlpha = 0.45) {
const age = (performance.now() - shard.birthTime) / 1000;
let glow = 0;
if (reformProgress === 0 && age < 1.5) {
glow = Math.max(0, 1 - age / 1.5);
glow = Math.pow(glow, 1.2);
}
ctx.save();
ctx.translate(shard.centroid[0] + shard.offsetX, shard.centroid[1] + shard.offsetY);
ctx.rotate(shard.rotation);
ctx.translate(-shard.centroid[0], -shard.centroid[1]);
ctx.beginPath();
ctx.moveTo(shard.points[0][0], shard.points[0][1]);
for (let i = 1; i < shard.points.length; i++) {
ctx.lineTo(shard.points[i][0], shard.points[i][1]);
}
ctx.closePath();
if (bgAlpha > 0.01) {
ctx.fillStyle = `rgba(255, 255, 255, ${bgAlpha})`;
ctx.fill();
}
if (glow > 0.05) {
ctx.strokeStyle = `rgba(150, 210, 255, ${glow * 0.25})`;
ctx.lineWidth = 10;
ctx.stroke();
ctx.strokeStyle = `rgba(200, 230, 255, ${glow * 0.5})`;
ctx.lineWidth = 4.5;
ctx.stroke();
ctx.strokeStyle = `rgba(255, 255, 255, ${glow})`;
ctx.lineWidth = 1.2;
ctx.stroke();
}
ctx.save();
ctx.clip();
// 阶段2:文字碎片内容渐隐为时钟
if (shard.type === 'text' && reformProgress > 0) {
ctx.globalAlpha = 1 - reformProgress;
ctx.drawImage(textCanvas, 0, 0);
ctx.globalAlpha = reformProgress;
ctx.drawImage(clockCanvas, 0, 0);
ctx.globalAlpha = 1;
} else {
const srcCanvas = shard.type === 'text' ? textCanvas : clockCanvas;
ctx.drawImage(srcCanvas, 0, 0);
}
ctx.restore();
ctx.restore();
}
function drawStrikeEffect() {
if (!struck) return;
const elapsed = performance.now() - strikeTime;
if (elapsed > 130) return;
const t = elapsed / 130;
ctx.save();
ctx.globalAlpha = (1 - t) * 0.9;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3 * (1 - t);
ctx.beginPath();
ctx.arc(strikeX, strikeY, t * 95, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1 - t;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(strikeX, strikeY, t * 50, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
function animate() {
clockCtx.clearRect(0, 0, W, H);
drawClockTo(clockCtx);
if (textDirty) {
textCtx.clearRect(0, 0, W, H);
drawTextShardTo(textCtx);
textDirty = false;
}
ctx.clearRect(0, 0, W, H);
if (state === 'intact') {
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.drawImage(clockCanvas, 0, 0);
} else if (state === 'reforming') {
const elapsed = performance.now() - reformState.startTime;
const moveDur = reformState.moveDuration;
const fadeDur = reformState.fadeDuration;
// 判断当前处于哪个阶段
let phase, moveT = 0, fadeT = 0;
if (elapsed <= moveDur) {
phase = 1;
moveT = elapsed / moveDur;
} else if (elapsed <= moveDur + fadeDur) {
phase = 2;
moveT = 1;
fadeT = (elapsed - moveDur) / fadeDur;
} else {
phase = 2;
moveT = 1;
fadeT = 1;
}
const easedMove = easeInOutCubic(moveT);
// 更新每个碎片的位移和旋转,平滑归位
for (const init of reformState.initialStates) {
const s = init.shard;
s.offsetX = init.startOffsetX * (1 - easedMove);
s.offsetY = init.startOffsetY * (1 - easedMove);
// 旋转走最短路径归零
let rot = init.startRotation;
while (rot > Math.PI) rot -= 2 * Math.PI;
while (rot < -Math.PI) rot += 2 * Math.PI;
s.rotation = rot * (1 - easedMove);
s.vx = 0; s.vy = 0; s.rotationSpeed = 0;
}
// 统一背景圆透明度:阶段1从0升到0.45(填补缝隙),阶段2保持0.45
const bgAlpha = 0.45 * easedMove;
if (bgAlpha > 0.01) {
ctx.fillStyle = `rgba(255, 255, 255, ${bgAlpha})`;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
}
// 碎片各自背景填充:阶段1保持0.45,阶段2从0.45降到0(已被统一背景接管)
const shardBgAlpha = phase === 1 ? 0.45 : 0.45 * (1 - fadeT);
// 内容渐隐只在阶段2进行
const reformProgress = phase === 1 ? 0 : fadeT;
for (const s of shards) {
drawShard(s, reformProgress, shardBgAlpha);
}
// 动画结束,切回完整状态
if (elapsed > moveDur + fadeDur) {
state = 'intact';
shards = [];
reformState = null;
hint.classList.remove('hidden');
}
} else { // shattered
for (const s of shards) {
s.offsetX += s.vx;
s.offsetY += s.vy;
s.rotation += s.rotationSpeed;
s.vx *= 0.95;
s.vy *= 0.95;
s.rotationSpeed *= 0.97;
}
resolveCollisions();
for (const s of shards) {
drawShard(s, 0, 0.45);
}
}
drawStrikeEffect();
requestAnimationFrame(animate);
}
animate();
</script>
</body>
</html>