← back to Games Agentabrams
games/constellation-clock/ConstellationClock.aa
365 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Constellation Clock</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background: #05060f;
}
#sky {
display: block;
width: 100vw;
height: 100vh;
}
#digital {
position: fixed;
right: 22px;
bottom: 18px;
font-family: "Courier New", ui-monospace, monospace;
color: rgba(200, 220, 255, 0.85);
letter-spacing: 3px;
font-size: 26px;
text-shadow: 0 0 8px rgba(120,170,255,0.6), 0 0 18px rgba(80,120,255,0.35);
user-select: none;
z-index: 5;
}
#digital .date {
display: block;
font-size: 12px;
letter-spacing: 4px;
text-align: right;
margin-top: 4px;
color: rgba(150, 180, 230, 0.6);
}
#label {
position: fixed;
left: 22px;
top: 16px;
font-family: "Courier New", ui-monospace, monospace;
color: rgba(160, 190, 240, 0.5);
font-size: 12px;
letter-spacing: 4px;
text-transform: uppercase;
user-select: none;
z-index: 5;
}
</style>
</head>
<body>
<canvas id="sky"></canvas>
<div id="label">Celestial Chronometer</div>
<div id="digital"><span id="clock">00:00:00</span><span class="date" id="date"></span></div>
<script>
(function () {
"use strict";
const canvas = document.getElementById("sky");
const ctx = canvas.getContext("2d");
const clockEl = document.getElementById("clock");
const dateEl = document.getElementById("date");
let W = 0, H = 0, CX = 0, CY = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
CX = W / 2;
CY = H / 2;
buildStars();
}
// ---------- Background starfield ----------
let bgStars = [];
function buildStars() {
bgStars = [];
const count = Math.round((W * H) / 5200);
for (let i = 0; i < count; i++) {
bgStars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.3 + 0.2,
base: Math.random() * 0.5 + 0.2,
amp: Math.random() * 0.5 + 0.1,
speed: Math.random() * 2 + 0.5,
phase: Math.random() * Math.PI * 2,
hue: 210 + Math.random() * 40
});
}
}
function drawBackground(t) {
// subtle vertical nebula gradient
const g = ctx.createRadialGradient(CX, CY, 0, CX, CY, Math.max(W, H) * 0.75);
g.addColorStop(0, "#0a1230");
g.addColorStop(0.5, "#070a1c");
g.addColorStop(1, "#03040c");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < bgStars.length; i++) {
const s = bgStars[i];
const tw = s.base + s.amp * Math.sin(t * s.speed + s.phase);
const a = Math.max(0, Math.min(1, tw));
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = "hsla(" + s.hue + ",70%,85%," + a + ")";
ctx.fill();
}
}
// ---------- Constellation hand definitions ----------
// Each hand is a chain of stars at given radius fractions along the hand.
// Points: {r: fraction of handRadius, o: sideways offset fraction, mag: star size}
const secondShape = [
{ r: 0.00, o: 0.00, mag: 2.0 },
{ r: 0.30, o: 0.05, mag: 1.6 },
{ r: 0.55, o: -0.05, mag: 1.8 },
{ r: 0.78, o: 0.04, mag: 1.5 },
{ r: 0.95, o: 0.00, mag: 2.6 }
];
const minuteShape = [
{ r: 0.00, o: 0.00, mag: 2.4 },
{ r: 0.22, o: 0.07, mag: 2.0 },
{ r: 0.42, o: -0.06, mag: 2.2 },
{ r: 0.60, o: 0.08, mag: 2.0 },
{ r: 0.78, o: -0.04, mag: 2.4 },
{ r: 0.92, o: 0.05, mag: 3.2 }
];
const hourShape = [
{ r: 0.00, o: 0.00, mag: 2.8 },
{ r: 0.25, o: 0.10, mag: 2.4 },
{ r: 0.30, o: -0.10, mag: 2.4 },
{ r: 0.52, o: 0.06, mag: 2.6 },
{ r: 0.70, o: -0.08, mag: 2.6 },
{ r: 0.88, o: 0.00, mag: 3.6 }
];
function drawConstellation(shape, angle, handRadius, color, glow, t, twinkle) {
// angle: 0 = 12 o'clock (up). rotate coordinate.
const ca = Math.cos(angle - Math.PI / 2);
const sa = Math.sin(angle - Math.PI / 2);
const pts = [];
for (let i = 0; i < shape.length; i++) {
const p = shape[i];
const along = p.r * handRadius;
const side = p.o * handRadius;
// local coords: along axis + perpendicular offset
const lx = along;
const ly = side;
const x = CX + lx * ca - ly * sa;
const y = CY + lx * sa + ly * ca;
pts.push({ x: x, y: y, mag: p.mag });
}
// connecting lines
ctx.save();
ctx.lineWidth = 1.2;
ctx.strokeStyle = color.line;
ctx.shadowColor = color.glow;
ctx.shadowBlur = glow;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
// stars
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
const tw = twinkle ? (0.75 + 0.25 * Math.sin(t * 3 + i * 1.7)) : 1;
const rad = p.mag * tw;
ctx.save();
ctx.shadowColor = color.glow;
ctx.shadowBlur = glow * 1.4;
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, rad * 3);
grd.addColorStop(0, color.core);
grd.addColorStop(0.4, color.star);
grd.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(p.x, p.y, rad * 3, 0, Math.PI * 2);
ctx.fill();
// bright core
ctx.shadowBlur = 0;
ctx.fillStyle = color.core;
ctx.beginPath();
ctx.arc(p.x, p.y, rad * 0.8, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
return pts;
}
// ---------- Hour ticks as faint stars around the dial ----------
function drawDialStars(t) {
const R = Math.min(W, H) * 0.44;
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
const x = CX + Math.cos(a) * R;
const y = CY + Math.sin(a) * R;
const tw = 0.5 + 0.5 * Math.sin(t * 1.5 + i);
const big = (i % 3 === 0);
ctx.beginPath();
ctx.arc(x, y, big ? 2.4 : 1.4, 0, Math.PI * 2);
ctx.fillStyle = "rgba(180,205,255," + (0.35 + 0.35 * tw) + ")";
ctx.shadowColor = "rgba(140,180,255,0.8)";
ctx.shadowBlur = big ? 10 : 5;
ctx.fill();
ctx.shadowBlur = 0;
}
// center star (the pole)
const cg = ctx.createRadialGradient(CX, CY, 0, CX, CY, 14);
cg.addColorStop(0, "rgba(255,255,255,0.95)");
cg.addColorStop(0.4, "rgba(180,210,255,0.6)");
cg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = cg;
ctx.beginPath();
ctx.arc(CX, CY, 14, 0, Math.PI * 2);
ctx.fill();
}
// ---------- Shooting stars ----------
let shootings = [];
let nextShoot = 2 + Math.random() * 4;
function spawnShoot() {
const edge = Math.random();
const startX = Math.random() * W;
const startY = Math.random() * H * 0.5;
const angle = Math.PI * 0.15 + Math.random() * Math.PI * 0.2; // downward-right-ish
const speed = 500 + Math.random() * 400;
shootings.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed * (Math.random() < 0.5 ? 1 : -1),
vy: Math.sin(angle) * speed,
life: 0,
maxLife: 0.9 + Math.random() * 0.5,
len: 80 + Math.random() * 120
});
}
function updateShoot(dt) {
nextShoot -= dt;
if (nextShoot <= 0) {
spawnShoot();
nextShoot = 4 + Math.random() * 7;
}
for (let i = shootings.length - 1; i >= 0; i--) {
const s = shootings[i];
s.life += dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
if (s.life > s.maxLife) shootings.splice(i, 1);
}
}
function drawShoot() {
for (let i = 0; i < shootings.length; i++) {
const s = shootings[i];
const prog = s.life / s.maxLife;
const alpha = Math.sin(prog * Math.PI); // fade in/out
const mag = Math.hypot(s.vx, s.vy) || 1;
const ux = s.vx / mag, uy = s.vy / mag;
const tx = s.x - ux * s.len;
const ty = s.y - uy * s.len;
const grd = ctx.createLinearGradient(s.x, s.y, tx, ty);
grd.addColorStop(0, "rgba(255,255,255," + (0.9 * alpha) + ")");
grd.addColorStop(1, "rgba(255,255,255,0)");
ctx.strokeStyle = grd;
ctx.lineWidth = 2;
ctx.shadowColor = "rgba(200,220,255,0.9)";
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(tx, ty);
ctx.stroke();
ctx.shadowBlur = 0;
// head
ctx.beginPath();
ctx.arc(s.x, s.y, 2, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255," + alpha + ")";
ctx.fill();
}
}
// ---------- Main loop ----------
const colHour = {
line: "rgba(255,200,120,0.45)",
glow: "rgba(255,180,90,0.9)",
star: "rgba(255,190,110,0.9)",
core: "rgba(255,240,210,1)"
};
const colMinute = {
line: "rgba(140,200,255,0.5)",
glow: "rgba(120,180,255,0.9)",
star: "rgba(150,200,255,0.9)",
core: "rgba(235,245,255,1)"
};
const colSecond = {
line: "rgba(180,255,220,0.45)",
glow: "rgba(140,255,200,0.85)",
star: "rgba(170,255,210,0.9)",
core: "rgba(240,255,250,1)"
};
let lastTime = null;
function frame(now) {
if (lastTime === null) lastTime = now;
let dt = (now - lastTime) / 1000;
if (dt > 0.1) dt = 0.1;
lastTime = now;
const t = now / 1000;
const d = new Date();
const ms = d.getMilliseconds();
const sec = d.getSeconds() + ms / 1000;
const min = d.getMinutes() + sec / 60;
const hr = (d.getHours() % 12) + min / 60;
const secAngle = (sec / 60) * Math.PI * 2;
const minAngle = (min / 60) * Math.PI * 2;
const hrAngle = (hr / 12) * Math.PI * 2;
const base = Math.min(W, H);
const hourR = base * 0.20;
const minR = base * 0.33;
const secR = base * 0.40;
drawBackground(t);
drawDialStars(t);
updateShoot(dt);
drawShoot();
drawConstellation(hourShape, hrAngle, hourR, colHour, 16, t, true);
drawConstellation(minuteShape, minAngle, minR, colMinute, 14, t, true);
drawConstellation(secondShape, secAngle, secR, colSecond, 12, t, true);
// digital readout
const pad = (n) => (n < 10 ? "0" + n : "" + n);
clockEl.textContent = pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
dateEl.textContent = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
requestAnimationFrame(frame);
}
window.addEventListener("resize", resize);
resize();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>