fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 9m17s

This commit is contained in:
doomtube 2026-01-11 14:54:44 -05:00
parent 33c20bf59d
commit a92bfc1d22
14 changed files with 2629 additions and 59 deletions

View file

@ -25,12 +25,14 @@
"@fingerprintjs/fingerprintjs": "^4.5.1",
"chess.js": "^1.0.0-beta.8",
"@types/dompurify": "^3.0.5",
"@types/three": "^0.160.0",
"dompurify": "^3.3.0",
"foliate-js": "^1.0.1",
"hls.js": "^1.6.7",
"marked": "^17.0.1",
"openpgp": "^6.0.0-alpha.0",
"ovenplayer": "^0.10.43"
"ovenplayer": "^0.10.43",
"three": "^0.160.0"
},
"type": "module"
}

View file

@ -0,0 +1,89 @@
<script>
import { pyramidStore, colorPalette, selectedColor } from '$lib/stores/pyramid';
export let selected = '#E50000';
function selectColor(color) {
selected = color;
pyramidStore.setSelectedColor(color);
}
</script>
<div class="color-palette">
<h3>Colors</h3>
<div class="colors-grid">
{#each $colorPalette as colorItem}
<button
class="color-swatch"
class:selected={selected === colorItem.color}
style="background-color: {colorItem.color}"
title={colorItem.name}
on:click={() => selectColor(colorItem.color)}
/>
{/each}
</div>
<div class="selected-color">
<div class="preview" style="background-color: {selected}" />
<span>{selected}</span>
</div>
</div>
<style>
.color-palette {
background: #1a1a1a;
border-radius: 8px;
padding: 1rem;
}
h3 {
margin: 0 0 0.75rem 0;
font-size: 1rem;
color: #fff;
}
.colors-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 1rem;
}
.color-swatch {
width: 100%;
aspect-ratio: 1;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: transform 0.1s, border-color 0.1s;
}
.color-swatch:hover {
transform: scale(1.1);
}
.color-swatch.selected {
border-color: #fff;
transform: scale(1.1);
}
.selected-color {
display: flex;
align-items: center;
gap: 8px;
padding-top: 0.5rem;
border-top: 1px solid #333;
}
.preview {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #444;
}
.selected-color span {
font-family: monospace;
color: #888;
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,236 @@
<script>
import { pyramidStore, FACE_NAMES } from '$lib/stores/pyramid';
import { onDestroy } from 'svelte';
let selectedPixel = null;
let pixelInfo = null;
let loading = false;
let error = null;
const unsubscribe = pyramidStore.subscribe(state => {
if (state.selectedPixel !== selectedPixel) {
selectedPixel = state.selectedPixel;
if (selectedPixel) {
loadPixelInfo(selectedPixel);
} else {
pixelInfo = null;
}
}
});
onDestroy(unsubscribe);
async function loadPixelInfo(pixel) {
if (!pixel) return;
loading = true;
error = null;
try {
const res = await fetch(
`/api/pyramid/pixel/${pixel.faceId}/${pixel.x}/${pixel.y}`,
{ credentials: 'include' }
);
if (!res.ok) throw new Error('Failed to load pixel info');
const data = await res.json();
pixelInfo = data;
} catch (e) {
error = e.message;
pixelInfo = null;
} finally {
loading = false;
}
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString();
}
function clearSelection() {
pyramidStore.setSelectedPixel(null);
}
</script>
<div class="pixel-info">
<h3>Pixel Info</h3>
{#if !selectedPixel}
<p class="hint">Click a pixel to see who placed it</p>
{:else if loading}
<p class="loading">Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else if pixelInfo}
<div class="info-content">
<div class="position">
<strong>{FACE_NAMES[pixelInfo.faceId]}</strong>
<span>({pixelInfo.x}, {pixelInfo.y})</span>
</div>
{#if pixelInfo.isEmpty}
<p class="empty">No pixel placed here</p>
{:else}
<div class="color-display">
<div class="color-preview" style="background-color: {pixelInfo.color}" />
<span class="color-code">{pixelInfo.color}</span>
</div>
<div class="user-info">
{#if pixelInfo.user?.avatarUrl}
<img src={pixelInfo.user.avatarUrl} alt="" class="avatar" />
{:else}
<div class="avatar placeholder" />
{/if}
<div class="user-details">
<a
href="/profile/{pixelInfo.user?.username}"
class="username"
style="color: {pixelInfo.user?.userColor || '#888'}"
>
{pixelInfo.user?.username || 'Unknown'}
</a>
<span class="time">{formatDate(pixelInfo.placedAt)}</span>
</div>
</div>
{/if}
<button class="close-btn" on:click={clearSelection}>Clear</button>
</div>
{/if}
</div>
<style>
.pixel-info {
background: #1a1a1a;
border-radius: 8px;
padding: 1rem;
}
h3 {
margin: 0 0 0.75rem 0;
font-size: 1rem;
color: #fff;
}
.hint {
color: #666;
font-size: 0.9rem;
margin: 0;
}
.loading {
color: #888;
font-size: 0.9rem;
margin: 0;
}
.error {
color: #e50000;
font-size: 0.9rem;
margin: 0;
}
.empty {
color: #666;
font-style: italic;
margin: 0.5rem 0;
}
.info-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.position {
display: flex;
align-items: center;
gap: 8px;
}
.position strong {
color: #fff;
}
.position span {
color: #888;
font-family: monospace;
}
.color-display {
display: flex;
align-items: center;
gap: 8px;
}
.color-preview {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #444;
}
.color-code {
font-family: monospace;
color: #888;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
padding-top: 0.5rem;
border-top: 1px solid #333;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.avatar.placeholder {
background: #333;
}
.user-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.username {
text-decoration: none;
font-weight: 500;
}
.username:hover {
text-decoration: underline;
}
.time {
color: #666;
font-size: 0.8rem;
}
.close-btn {
margin-top: 0.5rem;
padding: 6px 12px;
background: #333;
border: none;
border-radius: 4px;
color: #888;
cursor: pointer;
font-size: 0.85rem;
}
.close-btn:hover {
background: #444;
color: #fff;
}
</style>

View file

@ -0,0 +1,384 @@
<script>
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { pyramidStore, FACE_SIZE, FACE_NAMES } from '$lib/stores/pyramid';
export let selectedColor = '#E50000';
const dispatch = createEventDispatcher();
let container;
let scene, camera, renderer, controls;
let pyramidGroup;
let faceMeshes = [];
let faceTextures = [];
let raycaster, mouse;
let animationId;
// Track if we're hovering over a face
let hoveredFace = null;
let hoveredPixel = null;
// Unsubscribe function for store
let unsubscribePyramid;
onMount(() => {
initScene();
createPyramid();
setupControls();
setupRaycasting();
animate();
// Subscribe to store updates for real-time pixel changes
unsubscribePyramid = pyramidStore.subscribe(state => {
if (!state.loading) {
updateAllTextures(state.faces);
}
});
// Handle window resize
window.addEventListener('resize', onWindowResize);
});
onDestroy(() => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (unsubscribePyramid) {
unsubscribePyramid();
}
window.removeEventListener('resize', onWindowResize);
// Cleanup Three.js resources
if (renderer) {
renderer.dispose();
}
faceMeshes.forEach(mesh => {
mesh.geometry.dispose();
mesh.material.dispose();
});
faceTextures.forEach(texture => {
texture.dispose();
});
});
function initScene() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
const aspect = container.clientWidth / container.clientHeight;
camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000);
camera.position.set(250, 200, 250);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// Lighting
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight2.position.set(-1, 0.5, -1);
scene.add(directionalLight2);
// Grid helper for reference
const gridHelper = new THREE.GridHelper(400, 20, 0x222222, 0x111111);
gridHelper.position.y = -1;
scene.add(gridHelper);
}
function createDataTexture(faceData) {
const size = FACE_SIZE;
const data = new Uint8Array(size * size * 4);
// Fill with default color (dark gray)
for (let i = 0; i < size * size; i++) {
data[i * 4] = 34; // R
data[i * 4 + 1] = 34; // G
data[i * 4 + 2] = 34; // B
data[i * 4 + 3] = 255; // A
}
// Apply pixel data from the face map
if (faceData) {
faceData.forEach((color, key) => {
const [x, y] = key.split(',').map(Number);
const idx = (y * size + x) * 4;
const rgb = hexToRgb(color);
if (rgb) {
data[idx] = rgb.r;
data[idx + 1] = rgb.g;
data[idx + 2] = rgb.b;
}
});
}
const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
texture.needsUpdate = true;
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
return texture;
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function createPyramid() {
pyramidGroup = new THREE.Group();
// Create base (face 0) - square at y=0
const baseGeometry = new THREE.PlaneGeometry(FACE_SIZE, FACE_SIZE);
const baseTexture = createDataTexture(null);
faceTextures[0] = baseTexture;
const baseMaterial = new THREE.MeshStandardMaterial({
map: baseTexture,
side: THREE.DoubleSide
});
const baseMesh = new THREE.Mesh(baseGeometry, baseMaterial);
baseMesh.rotation.x = -Math.PI / 2;
baseMesh.position.y = 0;
baseMesh.userData = { faceId: 0 };
pyramidGroup.add(baseMesh);
faceMeshes[0] = baseMesh;
// Create four triangular faces (faces 1-4)
const halfBase = FACE_SIZE / 2;
const pyramidHeight = FACE_SIZE * 0.8;
const apex = new THREE.Vector3(0, pyramidHeight, 0);
const baseCorners = [
new THREE.Vector3(-halfBase, 0, -halfBase), // NW
new THREE.Vector3(halfBase, 0, -halfBase), // NE
new THREE.Vector3(halfBase, 0, halfBase), // SE
new THREE.Vector3(-halfBase, 0, halfBase) // SW
];
// Face indices: 1=North, 2=East, 3=South, 4=West
const faceIndices = [
[0, 1], // North: NW-NE
[1, 2], // East: NE-SE
[2, 3], // South: SE-SW
[3, 0] // West: SW-NW
];
for (let i = 0; i < 4; i++) {
const [idx1, idx2] = faceIndices[i];
const v1 = baseCorners[idx1];
const v2 = baseCorners[idx2];
// Create triangle geometry
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
apex.x, apex.y, apex.z,
v1.x, v1.y, v1.z,
v2.x, v2.y, v2.z
]);
// UV mapping for triangular face
const uvs = new Float32Array([
0.5, 0, // apex at top center
0, 1, // left corner at bottom left
1, 1 // right corner at bottom right
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
const texture = createDataTexture(null);
faceTextures[i + 1] = texture;
const material = new THREE.MeshStandardMaterial({
map: texture,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData = { faceId: i + 1 };
pyramidGroup.add(mesh);
faceMeshes[i + 1] = mesh;
}
scene.add(pyramidGroup);
}
function setupControls() {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 100;
controls.maxDistance = 500;
controls.maxPolarAngle = Math.PI / 2 - 0.1;
controls.target.set(0, 50, 0);
controls.update();
}
function setupRaycasting() {
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
container.addEventListener('mousemove', onMouseMove);
container.addEventListener('click', onClick);
}
function onMouseMove(event) {
const rect = container.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(faceMeshes);
if (intersects.length > 0) {
const intersect = intersects[0];
const faceId = intersect.object.userData.faceId;
const uv = intersect.uv;
if (uv) {
const x = Math.floor(uv.x * FACE_SIZE);
const y = Math.floor((1 - uv.y) * FACE_SIZE);
hoveredFace = faceId;
hoveredPixel = { faceId, x, y };
dispatch('hover', { faceId, x, y });
pyramidStore.setHoveredPixel({ faceId, x, y });
}
} else {
hoveredFace = null;
hoveredPixel = null;
pyramidStore.setHoveredPixel(null);
}
}
function onClick(event) {
if (!hoveredPixel) return;
const { faceId, x, y } = hoveredPixel;
// Dispatch place event for parent to handle
dispatch('place', { faceId, x, y, color: selectedColor });
// Also set as selected for info panel
pyramidStore.setSelectedPixel({ faceId, x, y });
}
function updateAllTextures(faces) {
for (let i = 0; i < 5; i++) {
updateFaceTexture(i, faces[i]);
}
}
function updateFaceTexture(faceId, faceData) {
if (!faceTextures[faceId]) return;
const texture = faceTextures[faceId];
const size = FACE_SIZE;
// Reset to default color
for (let i = 0; i < size * size; i++) {
texture.image.data[i * 4] = 34;
texture.image.data[i * 4 + 1] = 34;
texture.image.data[i * 4 + 2] = 34;
}
// Apply pixel data
if (faceData) {
faceData.forEach((color, key) => {
const [x, y] = key.split(',').map(Number);
const idx = (y * size + x) * 4;
const rgb = hexToRgb(color);
if (rgb) {
texture.image.data[idx] = rgb.r;
texture.image.data[idx + 1] = rgb.g;
texture.image.data[idx + 2] = rgb.b;
}
});
}
texture.needsUpdate = true;
}
// Public method to update a single pixel (for real-time updates)
export function updatePixel(faceId, x, y, color) {
if (!faceTextures[faceId]) return;
const texture = faceTextures[faceId];
const idx = (y * FACE_SIZE + x) * 4;
const rgb = hexToRgb(color);
if (rgb) {
texture.image.data[idx] = rgb.r;
texture.image.data[idx + 1] = rgb.g;
texture.image.data[idx + 2] = rgb.b;
texture.needsUpdate = true;
}
}
function onWindowResize() {
if (!container || !camera || !renderer) return;
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
function animate() {
animationId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
</script>
<div class="pyramid-canvas" bind:this={container}>
{#if hoveredPixel}
<div class="hover-info">
{FACE_NAMES[hoveredPixel.faceId]} ({hoveredPixel.x}, {hoveredPixel.y})
</div>
{/if}
</div>
<style>
.pyramid-canvas {
width: 100%;
height: 100%;
min-height: 400px;
position: relative;
background: #0a0a0a;
border-radius: 8px;
overflow: hidden;
}
.pyramid-canvas :global(canvas) {
cursor: crosshair;
}
.hover-info {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,118 @@
<script>
import { pyramidStore, remainingPixels, isConnected, DAILY_LIMIT } from '$lib/stores/pyramid';
import { onDestroy } from 'svelte';
let remaining = DAILY_LIMIT;
let connected = false;
const unsubscribeRemaining = remainingPixels.subscribe(val => {
remaining = val;
});
const unsubscribeConnected = isConnected.subscribe(val => {
connected = val;
});
onDestroy(() => {
unsubscribeRemaining();
unsubscribeConnected();
});
$: percentage = (remaining / DAILY_LIMIT) * 100;
$: barColor = remaining > 200 ? '#02BE01' : remaining > 50 ? '#E5D900' : '#E50000';
</script>
<div class="user-stats">
<div class="stat-header">
<h3>Your Pixels</h3>
<span class="status" class:connected>
{connected ? 'Live' : 'Offline'}
</span>
</div>
<div class="remaining">
<span class="count">{remaining}</span>
<span class="label">/ {DAILY_LIMIT} remaining today</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
style="width: {percentage}%; background-color: {barColor}"
/>
</div>
<p class="note">
Resets at midnight UTC
</p>
</div>
<style>
.user-stats {
background: #1a1a1a;
border-radius: 8px;
padding: 1rem;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
h3 {
margin: 0;
font-size: 1rem;
color: #fff;
}
.status {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 10px;
background: #333;
color: #666;
}
.status.connected {
background: rgba(2, 190, 1, 0.2);
color: #02BE01;
}
.remaining {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 0.5rem;
}
.count {
font-size: 1.5rem;
font-weight: bold;
color: #fff;
}
.label {
color: #888;
font-size: 0.9rem;
}
.progress-bar {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
transition: width 0.3s ease, background-color 0.3s ease;
}
.note {
margin: 0.5rem 0 0 0;
color: #666;
font-size: 0.8rem;
}
</style>

View file

@ -7,12 +7,17 @@
let animationId;
let width, height;
let hue = 0;
let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving'
let phase = 'growing'; // 'growing' | 'shattering'
let phaseStartTime = 0;
let branches = []; // Active growing branch tips
let crystalPoints = []; // All drawn points for shatter effect
let shatterParticles = [];
// Collision detection grid
let occupiedGrid = [];
const GRID_CELL_SIZE = 4;
let gridWidth, gridHeight;
const CONFIG = {
seedCount: 5, // Number of initial seed points
branchSpeed: 2, // Pixels per frame
@ -20,9 +25,8 @@
branchChance: 0.03, // Chance to spawn new branch per frame
turnAngle: 0.3, // Max random turn per frame (radians)
hueShiftSpeed: 0.2, // Color cycling speed
maxPoints: 15000, // Max crystal points before shatter
maxPoints: 15000, // Max crystal points (kept for reference)
shatterDuration: 2500, // Milliseconds for shatter effect
dissolveDuration: 2000, // Milliseconds for dissolve effect
lineWidth: 2
};
@ -33,6 +37,38 @@
canvas.width = width;
canvas.height = height;
ctx = canvas.getContext('2d');
initGrid();
}
function initGrid() {
gridWidth = Math.ceil(width / GRID_CELL_SIZE);
gridHeight = Math.ceil(height / GRID_CELL_SIZE);
occupiedGrid = Array(gridHeight).fill(null).map(() => Array(gridWidth).fill(false));
}
function isOccupied(x, y) {
const gx = Math.floor(x / GRID_CELL_SIZE);
const gy = Math.floor(y / GRID_CELL_SIZE);
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) return true;
return occupiedGrid[gy][gx];
}
function markOccupied(x, y) {
const gx = Math.floor(x / GRID_CELL_SIZE);
const gy = Math.floor(y / GRID_CELL_SIZE);
if (gx >= 0 && gx < gridWidth && gy >= 0 && gy < gridHeight) {
occupiedGrid[gy][gx] = true;
}
}
function getCoverage() {
let filled = 0;
for (let row of occupiedGrid) {
for (let cell of row) {
if (cell) filled++;
}
}
return filled / (gridWidth * gridHeight);
}
function initCrystal() {
@ -42,6 +78,9 @@
phase = 'growing';
phaseStartTime = performance.now();
// Reset collision grid
initGrid();
// Clear canvas
if (ctx) {
ctx.fillStyle = 'black';
@ -74,8 +113,6 @@
updateGrowing();
} else if (phase === 'shattering') {
updateShattering();
} else if (phase === 'dissolving') {
updateDissolving();
}
}
@ -100,6 +137,15 @@
continue;
}
// Check collision - kill branch if cell is already occupied
if (isOccupied(newX, newY)) {
branches.splice(i, 1);
continue;
}
// Mark cell as occupied
markOccupied(newX, newY);
// Store point for shatter effect
crystalPoints.push({
x: newX,
@ -133,37 +179,42 @@
generation: branch.generation + 1
});
}
// Chance to die (increases with age and generation)
const deathChance = 0.001 + branch.age * 0.0001 + branch.generation * 0.002;
if (Math.random() < deathChance) {
branches.splice(i, 1);
}
}
// Add new branches
branches.push(...newBranches);
// Check if we should shatter (too many points or no more branches)
if (crystalPoints.length > CONFIG.maxPoints || (branches.length === 0 && crystalPoints.length > 100)) {
// Check if we should shatter (coverage threshold reached or no more branches)
const coverage = getCoverage();
if (coverage > 0.75 || (branches.length === 0 && crystalPoints.length > 100)) {
startShatter();
}
// Spawn new seeds occasionally if branches are dying off
if (branches.length < 10 && crystalPoints.length < CONFIG.maxPoints * 0.5) {
const x = Math.random() * width;
const y = Math.random() * height;
const branchCount = 2 + Math.floor(Math.random() * 3);
for (let j = 0; j < branchCount; j++) {
const angle = Math.random() * Math.PI * 2;
branches.push({
x,
y,
angle,
hue: Math.random() * 360,
age: 0,
generation: 0
});
// Spawn new seeds if branches are dying off and we haven't filled the screen
if (branches.length < 10 && coverage < 0.7) {
// Find an unoccupied spot for the new seed
let attempts = 0;
let x, y;
do {
x = Math.random() * width;
y = Math.random() * height;
attempts++;
} while (isOccupied(x, y) && attempts < 50);
// Only spawn if we found an unoccupied spot
if (!isOccupied(x, y)) {
const branchCount = 2 + Math.floor(Math.random() * 3);
for (let j = 0; j < branchCount; j++) {
const angle = Math.random() * Math.PI * 2;
branches.push({
x,
y,
angle,
hue: Math.random() * 360,
age: 0,
generation: 0
});
}
}
}
}
@ -214,32 +265,7 @@
}
if (elapsed >= CONFIG.shatterDuration) {
phase = 'dissolving';
phaseStartTime = performance.now();
}
}
function updateDissolving() {
const elapsed = performance.now() - phaseStartTime;
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
ctx.fillRect(0, 0, width, height);
for (const p of shatterParticles) {
p.x += p.vx * 0.5;
p.y += p.vy * 0.5;
p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration));
if (p.alpha > 0.01) {
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha * 0.5})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2);
ctx.fill();
}
}
if (elapsed >= CONFIG.dissolveDuration) {
initCrystal(); // Regrow
initCrystal(); // Go directly to regrow
}
}

View file

@ -0,0 +1,236 @@
import { pyramidStore } from '$lib/stores/pyramid';
class PyramidWebSocket {
constructor() {
this.ws = null;
this.token = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.reconnectResetTimer = null;
}
async connect(token = null) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return;
}
this.token = token;
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws/pyramid`;
console.log('[PyramidWebSocket] Connecting to:', wsUrl);
pyramidStore.setConnected(false);
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('[PyramidWebSocket] Connected');
pyramidStore.setConnected(true);
this.reconnectAttempts = 0;
// Send auth token if available
if (this.token) {
this.ws.send(JSON.stringify({ type: 'auth', token: this.token }));
}
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleMessage(data);
} catch (error) {
console.error('[PyramidWebSocket] Failed to parse message:', error);
}
};
this.ws.onerror = (error) => {
console.error('[PyramidWebSocket] Error:', error);
};
this.ws.onclose = () => {
console.log('[PyramidWebSocket] Disconnected');
pyramidStore.setConnected(false);
this.attemptReconnect();
};
} catch (error) {
console.error('[PyramidWebSocket] Failed to create WebSocket:', error);
pyramidStore.setConnected(false);
}
}
handleMessage(data) {
switch (data.type) {
case 'welcome':
console.log('[PyramidWebSocket] Welcome:', data.message);
break;
case 'auth_success':
console.log('[PyramidWebSocket] Authenticated');
if (data.remainingPixels !== undefined) {
pyramidStore.setRemaining(data.remainingPixels);
}
break;
case 'pixel_update':
// Another user placed a pixel
pyramidStore.updatePixel(
data.faceId,
data.x,
data.y,
data.color
);
break;
case 'pixel_placed':
// Confirmation of our own pixel placement
if (data.remainingPixels !== undefined) {
pyramidStore.setRemaining(data.remainingPixels);
}
break;
case 'rollback':
// Moderator rolled back a pixel
pyramidStore.updatePixel(
data.faceId,
data.x,
data.y,
data.newColor || null
);
console.log(`[PyramidWebSocket] Pixel rolled back by ${data.moderator}`);
break;
case 'error':
console.error('[PyramidWebSocket] Server error:', data.message);
pyramidStore.setError(data.message);
break;
default:
console.log('[PyramidWebSocket] Unknown message type:', data.type);
}
}
placePixel(faceId, x, y, color) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.error('[PyramidWebSocket] Not connected');
return false;
}
this.ws.send(JSON.stringify({
type: 'place_pixel',
faceId,
x,
y,
color
}));
return true;
}
async attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[PyramidWebSocket] Max reconnect attempts reached');
// Try again after 60 seconds
if (this.reconnectResetTimer) {
clearTimeout(this.reconnectResetTimer);
}
this.reconnectResetTimer = setTimeout(() => {
console.log('[PyramidWebSocket] Resetting reconnect attempts');
this.reconnectAttempts = 0;
this.attemptReconnect();
}, 60000);
return;
}
this.reconnectAttempts++;
const delay = Math.min(
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
this.maxReconnectDelay
);
console.log(`[PyramidWebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(async () => {
// Try to refresh token before reconnecting
if (this.token) {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.token) {
this.token = data.token;
}
}
} catch (e) {
console.warn('[PyramidWebSocket] Token refresh failed:', e);
}
}
this.connect(this.token);
}, delay);
}
async manualReconnect(token = null) {
console.log('[PyramidWebSocket] Manual reconnect');
if (this.reconnectResetTimer) {
clearTimeout(this.reconnectResetTimer);
this.reconnectResetTimer = null;
}
this.reconnectAttempts = 0;
// Get fresh token if not provided
let freshToken = token;
if (!freshToken) {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.token) {
freshToken = data.token;
}
}
} catch (e) {
console.warn('[PyramidWebSocket] Could not refresh token:', e);
}
}
this.token = freshToken;
// Close existing connection
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
this.connect(this.token);
}
disconnect() {
if (this.reconnectResetTimer) {
clearTimeout(this.reconnectResetTimer);
this.reconnectResetTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.token = null;
this.reconnectAttempts = 0;
pyramidStore.setConnected(false);
}
}
// Singleton instance
export const pyramidWebSocket = new PyramidWebSocket();

View file

@ -11,6 +11,9 @@ let refreshInterval = null;
let isRefreshing = false;
let refreshPromise = null;
let consecutiveFailures = 0;
let lastVisibilityRefresh = 0;
const VISIBILITY_REFRESH_COOLDOWN_MS = 60 * 1000; // 1 minute cooldown
let visibilityHandlerSetup = false;
// Silent token refresh function with retry logic
// Returns: { success: boolean, isAuthError: boolean }
@ -90,6 +93,9 @@ export async function refreshAccessToken() {
function startTokenRefresh() {
if (!browser) return;
// Setup visibility handler to refresh when tab becomes visible
setupVisibilityHandler();
// Clear any existing interval
if (refreshInterval) {
clearInterval(refreshInterval);
@ -122,6 +128,24 @@ function stopTokenRefresh() {
}
}
// Setup visibility change handler to refresh token when tab becomes visible
function setupVisibilityHandler() {
if (!browser || visibilityHandlerSetup) return;
visibilityHandlerSetup = true;
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
const now = Date.now();
// Only refresh if enough time has passed since last refresh
if (now - lastVisibilityRefresh > VISIBILITY_REFRESH_COOLDOWN_MS) {
lastVisibilityRefresh = now;
console.log('Tab became visible, refreshing token...');
await refreshAccessToken();
}
}
});
}
function createAuthStore() {
const { subscribe, set, update } = writable({
user: null,
@ -136,11 +160,24 @@ function createAuthStore() {
// Use cookie-based auth - no localStorage tokens
try {
const response = await fetch('/api/user/me', {
let response = await fetch('/api/user/me', {
credentials: 'include', // Send cookies
cache: 'no-store' // Always fetch fresh data
});
// If access token expired, try to refresh before giving up
if (response.status === 401) {
console.log('Access token expired, attempting refresh...');
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry with new token
response = await fetch('/api/user/me', {
credentials: 'include',
cache: 'no-store'
});
}
}
if (response.ok) {
const data = await response.json();
set({ user: data.user, loading: false });

View file

@ -0,0 +1,174 @@
import { writable, derived } from 'svelte/store';
// Face configuration
export const FACE_SIZE = 200;
export const FACE_NAMES = ['Base', 'North', 'East', 'South', 'West'];
export const DAILY_LIMIT = 1000;
function createPyramidStore() {
const { subscribe, set, update } = writable({
// Canvas state - 5 faces, each is a Map of "x,y" -> color
faces: [new Map(), new Map(), new Map(), new Map(), new Map()],
// Loading states
loading: true,
error: null,
// User limits
remainingPixels: DAILY_LIMIT,
pixelsPlacedToday: 0,
// Selection state
selectedColor: '#E50000',
hoveredPixel: null,
selectedPixel: null,
// Color palette
colors: [],
// WebSocket connection status
connected: false
});
return {
subscribe,
// Load initial canvas state from API
async loadState() {
update(state => ({ ...state, loading: true, error: null }));
try {
const [stateRes, colorsRes, limitsRes] = await Promise.all([
fetch('/api/pyramid/state', { credentials: 'include' }),
fetch('/api/pyramid/colors', { credentials: 'include' }),
fetch('/api/pyramid/limits', { credentials: 'include' })
]);
if (!stateRes.ok) throw new Error('Failed to load canvas state');
if (!colorsRes.ok) throw new Error('Failed to load colors');
const stateData = await stateRes.json();
const colorsData = await colorsRes.json();
const limitsData = limitsRes.ok ? await limitsRes.json() : null;
// Build face maps from pixel data
const faces = [new Map(), new Map(), new Map(), new Map(), new Map()];
for (const pixel of stateData.pixels || []) {
const key = `${pixel.x},${pixel.y}`;
faces[pixel.faceId].set(key, pixel.color);
}
update(state => ({
...state,
faces,
colors: colorsData.colors || [],
remainingPixels: limitsData?.remainingToday ?? DAILY_LIMIT,
pixelsPlacedToday: limitsData?.pixelsPlacedToday ?? 0,
loading: false,
selectedColor: colorsData.colors?.[5]?.color || '#E50000'
}));
} catch (error) {
console.error('Failed to load pyramid state:', error);
update(state => ({
...state,
loading: false,
error: error.message
}));
}
},
// Update a single pixel (from WebSocket or local placement)
updatePixel(faceId, x, y, color) {
update(state => {
const newFaces = [...state.faces];
const key = `${x},${y}`;
if (color) {
newFaces[faceId] = new Map(newFaces[faceId]);
newFaces[faceId].set(key, color);
} else {
// Empty color means delete
newFaces[faceId] = new Map(newFaces[faceId]);
newFaces[faceId].delete(key);
}
return { ...state, faces: newFaces };
});
},
// Set selected color
setSelectedColor(color) {
update(state => ({ ...state, selectedColor: color }));
},
// Set hovered pixel
setHoveredPixel(pixel) {
update(state => ({ ...state, hoveredPixel: pixel }));
},
// Set selected pixel (for info panel)
setSelectedPixel(pixel) {
update(state => ({ ...state, selectedPixel: pixel }));
},
// Decrement remaining pixels after successful placement
decrementRemaining() {
update(state => ({
...state,
remainingPixels: Math.max(0, state.remainingPixels - 1),
pixelsPlacedToday: state.pixelsPlacedToday + 1
}));
},
// Update remaining from server
setRemaining(remaining) {
update(state => ({
...state,
remainingPixels: remaining,
pixelsPlacedToday: DAILY_LIMIT - remaining
}));
},
// Set connection status
setConnected(connected) {
update(state => ({ ...state, connected }));
},
// Set error
setError(error) {
update(state => ({ ...state, error }));
},
// Get pixel color from face
getPixelColor(faceId, x, y) {
let color = null;
const unsubscribe = subscribe(state => {
const key = `${x},${y}`;
color = state.faces[faceId]?.get(key) || null;
});
unsubscribe();
return color;
},
// Reset store
reset() {
set({
faces: [new Map(), new Map(), new Map(), new Map(), new Map()],
loading: true,
error: null,
remainingPixels: DAILY_LIMIT,
pixelsPlacedToday: 0,
selectedColor: '#E50000',
hoveredPixel: null,
selectedPixel: null,
colors: [],
connected: false
});
}
};
}
export const pyramidStore = createPyramidStore();
// Derived stores for convenience
export const isLoading = derived(pyramidStore, $store => $store.loading);
export const pyramidError = derived(pyramidStore, $store => $store.error);
export const remainingPixels = derived(pyramidStore, $store => $store.remainingPixels);
export const selectedColor = derived(pyramidStore, $store => $store.selectedColor);
export const colorPalette = derived(pyramidStore, $store => $store.colors);
export const isConnected = derived(pyramidStore, $store => $store.connected);

View file

@ -50,7 +50,8 @@
if (state.user) {
screensaver.init({
screensaverEnabled: state.user.screensaverEnabled,
screensaverTimeoutMinutes: state.user.screensaverTimeoutMinutes
screensaverTimeoutMinutes: state.user.screensaverTimeoutMinutes,
screensaverType: state.user.screensaverType
});
}
});
@ -58,7 +59,8 @@
if ($auth.user) {
screensaver.init({
screensaverEnabled: $auth.user.screensaverEnabled,
screensaverTimeoutMinutes: $auth.user.screensaverTimeoutMinutes
screensaverTimeoutMinutes: $auth.user.screensaverTimeoutMinutes,
screensaverType: $auth.user.screensaverType
});
}
});
@ -562,6 +564,13 @@
</svg>
Games
</a>
<a href="/pyramid" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 22h20L12 2z"/>
<path d="M12 2v20M2 22h20"/>
</svg>
World
</a>
<a href="/stats" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6"/>

View file

@ -0,0 +1,267 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth';
import { pyramidStore, isLoading, pyramidError, selectedColor } from '$lib/stores/pyramid';
import { pyramidWebSocket } from '$lib/pyramid/pyramidWebSocket';
import PyramidCanvas from '$lib/components/pyramid/PyramidCanvas.svelte';
import ColorPalette from '$lib/components/pyramid/ColorPalette.svelte';
import PixelInfo from '$lib/components/pyramid/PixelInfo.svelte';
import UserStats from '$lib/components/pyramid/UserStats.svelte';
let pyramidCanvas;
let currentColor = '#E50000';
let authLoaded = false;
// Subscribe to auth store
const unsubscribeAuth = auth.subscribe(value => {
if (!value.loading) {
authLoaded = true;
if (!value.user) {
goto('/login');
}
}
});
// Subscribe to selected color
const unsubscribeColor = selectedColor.subscribe(val => {
currentColor = val;
});
onMount(async () => {
// Wait for auth to load
if (!authLoaded) {
const checkAuth = setInterval(() => {
if (!$auth.loading) {
clearInterval(checkAuth);
if (!$auth.user) {
goto('/login');
} else {
initPyramid();
}
}
}, 100);
} else if ($auth.user) {
initPyramid();
}
});
async function initPyramid() {
// Load initial state
await pyramidStore.loadState();
// Connect WebSocket with auth token
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.token) {
await pyramidWebSocket.connect(data.token);
}
} else {
// Try connecting without token (will be guest)
await pyramidWebSocket.connect();
}
} catch (e) {
console.error('Failed to get auth token:', e);
await pyramidWebSocket.connect();
}
}
onDestroy(() => {
unsubscribeAuth();
unsubscribeColor();
pyramidWebSocket.disconnect();
pyramidStore.reset();
});
async function handlePlacePixel(event) {
const { faceId, x, y, color } = event.detail;
// Optimistic update
pyramidStore.updatePixel(faceId, x, y, color);
pyramidStore.decrementRemaining();
// Send via WebSocket
const success = pyramidWebSocket.placePixel(faceId, x, y, color);
if (!success) {
// WebSocket not connected, fall back to REST API
try {
const res = await fetch('/api/pyramid/pixel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ faceId, x, y, color })
});
if (!res.ok) {
const data = await res.json();
pyramidStore.setError(data.error || 'Failed to place pixel');
// Reload state to revert optimistic update
pyramidStore.loadState();
} else {
const data = await res.json();
if (data.remainingToday !== undefined) {
pyramidStore.setRemaining(data.remainingToday);
}
}
} catch (e) {
pyramidStore.setError('Network error');
pyramidStore.loadState();
}
}
}
</script>
<svelte:head>
<title>Pyramid World - Realms</title>
</svelte:head>
<div class="pyramid-page">
<header class="pyramid-header">
<h1>Pyramid World</h1>
<p class="subtitle">Collaborative pixel art on a 3D pyramid</p>
</header>
{#if $isLoading}
<div class="loading-container">
<div class="loading">Loading pyramid...</div>
</div>
{:else if $pyramidError}
<div class="error-container">
<div class="error">{$pyramidError}</div>
<button on:click={() => pyramidStore.loadState()}>Retry</button>
</div>
{:else}
<div class="pyramid-layout">
<div class="canvas-container">
<PyramidCanvas
bind:this={pyramidCanvas}
selectedColor={currentColor}
on:place={handlePlacePixel}
/>
</div>
<aside class="sidebar">
<UserStats />
<ColorPalette bind:selected={currentColor} />
<PixelInfo />
</aside>
</div>
<div class="controls-hint">
<p>Drag to rotate | Scroll to zoom | Click to place pixel</p>
</div>
{/if}
</div>
<style>
.pyramid-page {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
.pyramid-header {
text-align: center;
margin-bottom: 1.5rem;
}
.pyramid-header h1 {
font-size: 2rem;
margin: 0 0 0.5rem 0;
background: linear-gradient(135deg, #561D5E, #8b3a92);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: #888;
margin: 0;
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1rem;
}
.loading {
color: #888;
font-size: 1.2rem;
}
.error {
color: #E50000;
font-size: 1.1rem;
}
.error-container button {
padding: 8px 16px;
background: #561D5E;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
}
.error-container button:hover {
background: #6d2473;
}
.pyramid-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1.5rem;
}
.canvas-container {
background: #111;
border-radius: 12px;
overflow: hidden;
min-height: 500px;
aspect-ratio: 16 / 10;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.controls-hint {
text-align: center;
margin-top: 1rem;
}
.controls-hint p {
color: #666;
font-size: 0.9rem;
margin: 0;
}
@media (max-width: 900px) {
.pyramid-layout {
grid-template-columns: 1fr;
}
.sidebar {
flex-direction: row;
flex-wrap: wrap;
}
.sidebar > :global(*) {
flex: 1;
min-width: 200px;
}
}
</style>