785 lines
29 KiB
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> |