Compare commits
7 Commits
2c5f64a769
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
99e4e65d92
|
|||
|
11f05e0d6f
|
|||
|
367470b54e
|
|||
|
78f1bc2ef6
|
|||
|
174ca69dcd
|
|||
|
f6f9c15e0c
|
|||
|
16902f00f4
|
@@ -1,5 +0,0 @@
|
|||||||
timmypidashev.local {
|
|
||||||
tls internal
|
|
||||||
|
|
||||||
reverse_proxy timmypidashev.dev:4321
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
timmypidashev.dev {
|
|
||||||
tls pidashev.tim@gmail.com
|
|
||||||
|
|
||||||
reverse_proxy timmypidashev.dev:3000
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
FROM node:22-alpine
|
|
||||||
|
|
||||||
ARG CONTAINER_WEB_VERSION
|
|
||||||
ARG ENVIRONMENT
|
|
||||||
ARG BUILD_DATE
|
|
||||||
ARG GIT_COMMIT
|
|
||||||
|
|
||||||
RUN set -eux \
|
|
||||||
& apk add \
|
|
||||||
--no-cache \
|
|
||||||
nodejs \
|
|
||||||
curl
|
|
||||||
|
|
||||||
RUN curl -L https://unpkg.com/@pnpm/self-installer | node
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN echo "PUBLIC_VERSION=${CONTAINER_WEB_VERSION}" > /app/.env && \
|
|
||||||
echo "PUBLIC_ENVIRONMENT=${ENVIRONMENT}" >> /app/.env && \
|
|
||||||
echo "PUBLIC_BUILD_DATE=${BUILD_DATE}" >> /app/.env && \
|
|
||||||
echo "PUBLIC_GIT_COMMIT=${GIT_COMMIT}" >> /app/.env
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["pnpm", "run", "dev"]
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Stage 1: Build and install dependencies
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install necessary dependencies, including pnpm
|
|
||||||
RUN set -eux \
|
|
||||||
&& apk add --no-cache nodejs curl \
|
|
||||||
&& npm install -g pnpm
|
|
||||||
|
|
||||||
# Copy package files first (for better caching)
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Now copy the rest of your source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Set build arguments
|
|
||||||
ARG CONTAINER_WEB_VERSION
|
|
||||||
ARG ENVIRONMENT
|
|
||||||
ARG BUILD_DATE
|
|
||||||
ARG GIT_COMMIT
|
|
||||||
|
|
||||||
# Create .env file with build-time environment variables
|
|
||||||
RUN echo "PUBLIC_VERSION=${CONTAINER_WEB_VERSION}" > /app/.env && \
|
|
||||||
echo "PUBLIC_ENVIRONMENT=${ENVIRONMENT}" >> /app/.env && \
|
|
||||||
echo "PUBLIC_BUILD_DATE=${BUILD_DATE}" >> /app/.env && \
|
|
||||||
echo "PUBLIC_GIT_COMMIT=${GIT_COMMIT}" >> /app/.env
|
|
||||||
|
|
||||||
# Build the project
|
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
# Stage 2: Serve static files
|
|
||||||
FROM node:22-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy built files
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
|
|
||||||
# Expose port 3000
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Deployment command
|
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
|
||||||
83
.github/scripts/deploy_release.sh
vendored
@@ -1,83 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Set variables
|
|
||||||
BRANCH_NAME="$1"
|
|
||||||
COMMIT_HASH="$2"
|
|
||||||
GHCR_USERNAME="$3"
|
|
||||||
GHCR_TOKEN="$4"
|
|
||||||
DEPLOY_TYPE="$5"
|
|
||||||
REPO_OWNER="$6"
|
|
||||||
COMPOSE_FILE="$7"
|
|
||||||
CADDYFILE="$8"
|
|
||||||
MAKEFILE="$9"
|
|
||||||
|
|
||||||
# Echo out variable names and their content on single lines
|
|
||||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
|
||||||
echo "COMMIT_HASH: $COMMIT_HASH"
|
|
||||||
echo "GHCR_USERNAME: $GHCR_USERNAME"
|
|
||||||
echo "DEPLOY_TYPE: $DEPLOY_TYPE"
|
|
||||||
echo "REPO_OWNER: $REPO_OWNER"
|
|
||||||
echo "COMPOSE_FILE: $COMPOSE_FILE"
|
|
||||||
echo "CADDYFILE: $CADDYFILE"
|
|
||||||
echo "MAKEFILE: $MAKEFILE"
|
|
||||||
|
|
||||||
# Set the staging directory
|
|
||||||
STAGING_DIR="/root/deployments/.staging-${COMMIT_HASH}"
|
|
||||||
|
|
||||||
# Set the tmux session name for release
|
|
||||||
TMUX_SESSION="deployment-release"
|
|
||||||
|
|
||||||
# Function to cleanup existing release deployment
|
|
||||||
cleanup_release_deployment() {
|
|
||||||
echo "Cleaning up existing release deployment..."
|
|
||||||
# Stop and remove all release containers
|
|
||||||
docker-compose -f "/root/deployments/release/docker-compose.yml" down -v 2>/dev/null
|
|
||||||
# Remove release images
|
|
||||||
docker rmi $(docker images "ghcr.io/$REPO_OWNER/*:release" -q) 2>/dev/null
|
|
||||||
# Kill release tmux session if it exists
|
|
||||||
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null
|
|
||||||
# Remove release deployment directory
|
|
||||||
rm -rf /root/deployments/release
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to create deployment directory
|
|
||||||
create_deployment_directory() {
|
|
||||||
echo "Creating deployment directory..."
|
|
||||||
mkdir -p /root/deployments/release
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to pull Docker images
|
|
||||||
pull_docker_images() {
|
|
||||||
echo "Pulling Docker images..."
|
|
||||||
docker pull ghcr.io/$REPO_OWNER/web:release
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to copy and prepare files
|
|
||||||
copy_and_prepare_files() {
|
|
||||||
echo "Copying and preparing files..."
|
|
||||||
# Copy files preserving names and locations
|
|
||||||
install -D "$STAGING_DIR/$COMPOSE_FILE" "/root/deployments/release/$COMPOSE_FILE"
|
|
||||||
install -D "$STAGING_DIR/$CADDYFILE" "/root/deployments/release/$CADDYFILE"
|
|
||||||
install -D "$STAGING_DIR/$MAKEFILE" "/root/deployments/release/$MAKEFILE"
|
|
||||||
# Replace {$COMMIT_HASH} with $COMMIT_HASH in $CADDYFILE
|
|
||||||
sed -i "s/{\$COMMIT_HASH}/$COMMIT_HASH/g" "/root/deployments/release/$CADDYFILE"
|
|
||||||
# Replace {commit_hash} with $COMMIT_HASH in $COMPOSE_FILE
|
|
||||||
sed -i "s/{commit_hash}/$COMMIT_HASH/g" "/root/deployments/release/$COMPOSE_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to start the deployment
|
|
||||||
start_deployment() {
|
|
||||||
echo "Starting deployment..."
|
|
||||||
# Create new tmux session with specific name
|
|
||||||
tmux new-session -d -s "$TMUX_SESSION"
|
|
||||||
tmux send-keys -t "$TMUX_SESSION" "cd /root/deployments/release && make run release" Enter
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main execution
|
|
||||||
cleanup_release_deployment
|
|
||||||
create_deployment_directory
|
|
||||||
copy_and_prepare_files
|
|
||||||
cd "/root/deployments/release"
|
|
||||||
pull_docker_images
|
|
||||||
start_deployment
|
|
||||||
echo "Release build $COMMIT_HASH deployed successfully!"
|
|
||||||
4
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "src/public/scripts"]
|
[submodule "public/scripts"]
|
||||||
path = src/public/scripts
|
path = public/scripts
|
||||||
url = https://github.com/timmypidashev/scripts
|
url = https://github.com/timmypidashev/scripts
|
||||||
|
|||||||
186
Makefile
@@ -1,186 +0,0 @@
|
|||||||
PROJECT_NAME := "timmypidashev.dev"
|
|
||||||
PROJECT_AUTHORS := "Timothy Pidashev (timmypidashev) <pidashev.tim@gmail.com>"
|
|
||||||
PROJECT_VERSION := "v2.1.1"
|
|
||||||
PROJECT_LICENSE := "MIT"
|
|
||||||
PROJECT_SOURCES := "https://github.com/timmypidashev/web"
|
|
||||||
PROJECT_REGISTRY := "ghcr.io/timmypidashev"
|
|
||||||
PROJECT_ORGANIZATION := "org.opencontainers"
|
|
||||||
|
|
||||||
CONTAINER_WEB_NAME := "timmypidashev.dev"
|
|
||||||
CONTAINER_WEB_VERSION := "v2.1.1"
|
|
||||||
CONTAINER_WEB_LOCATION := "src/"
|
|
||||||
CONTAINER_WEB_DESCRIPTION := "My portfolio website!"
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
|
||||||
.PHONY: watch run build push prune bump exec
|
|
||||||
.SILENT: watch run build push prune bump exec
|
|
||||||
|
|
||||||
help:
|
|
||||||
@echo "Available targets:"
|
|
||||||
@echo " run - Runs the docker compose file with the specified environment (local or release)"
|
|
||||||
@echo " build - Builds the specified docker image with the appropriate environment"
|
|
||||||
@echo " push - Pushes the built docker image to the registry"
|
|
||||||
@echo " prune - Removes all built and cached docker images and containers"
|
|
||||||
@echo " bump - Bumps the project and container versions"
|
|
||||||
|
|
||||||
run:
|
|
||||||
# Arguments:
|
|
||||||
# [environment]: 'local' or 'release'
|
|
||||||
#
|
|
||||||
# Explanation:
|
|
||||||
# * Runs the docker compose file with the specified environment(compose.local.yml, or compose.release.yml)
|
|
||||||
# * Passes all generated arguments to the compose file.
|
|
||||||
|
|
||||||
# Make sure we have been given proper arguments.
|
|
||||||
@if [ "$(word 2,$(MAKECMDGOALS))" = "local" ]; then \
|
|
||||||
echo "Running in local environment"; \
|
|
||||||
elif [ "$(word 2,$(MAKECMDGOALS))" = "release" ]; then \
|
|
||||||
echo "Running in release environment"; \
|
|
||||||
else \
|
|
||||||
echo "Invalid usage. Please use 'make run local' or 'make run release'"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker compose -f compose.$(word 2,$(MAKECMDGOALS)).yml up --remove-orphans
|
|
||||||
|
|
||||||
|
|
||||||
build:
|
|
||||||
# Arguments
|
|
||||||
# [container]: Build context(which container to build ['all' to build every container defined])
|
|
||||||
# [environment]: 'local' or 'release'
|
|
||||||
#
|
|
||||||
# Explanation:
|
|
||||||
# * Builds the specified docker image with the appropriate environment.
|
|
||||||
# * Passes all generated arguments to docker build-kit.
|
|
||||||
|
|
||||||
# Extract container and environment inputted.
|
|
||||||
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
|
|
||||||
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
|
|
||||||
$(eval INPUT_ENVIRONMENT := $(lastword $(subst :, ,$(INPUT_TARGET))))
|
|
||||||
|
|
||||||
# Call function container_build either through a for loop for each container
|
|
||||||
# if all is called, or singularly to build the container.
|
|
||||||
$(if $(filter $(strip $(INPUT_CONTAINER)),all), \
|
|
||||||
$(foreach container,$(containers),$(call container_build,$(container) $(INPUT_ENVIRONMENT))), \
|
|
||||||
$(call container_build,$(INPUT_CONTAINER) $(INPUT_ENVIRONMENT)))
|
|
||||||
|
|
||||||
push:
|
|
||||||
# Arguments
|
|
||||||
# [container]: Push context(which container to push to the registry)
|
|
||||||
# [version]: Container version to push.
|
|
||||||
#
|
|
||||||
# Explanation:
|
|
||||||
# * Pushes the specified container version to the registry defined in the user configuration.
|
|
||||||
|
|
||||||
# Extract container and version inputted.
|
|
||||||
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
|
|
||||||
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
|
|
||||||
$(eval INPUT_VERSION := $(lastword $(subst :, ,$(INPUT_TARGET))))
|
|
||||||
|
|
||||||
# Push the specified container version to the registry.
|
|
||||||
# NOTE: docker will complain if the container tag is invalid, no need to validate here.
|
|
||||||
@docker push $(PROJECT_REGISTRY)/$(INPUT_CONTAINER):$(INPUT_VERSION)
|
|
||||||
|
|
||||||
prune:
|
|
||||||
# Removes all built and cached docker images and containers.
|
|
||||||
|
|
||||||
bump:
|
|
||||||
# Arguments
|
|
||||||
# [container]: Bump context(which container version to bump)
|
|
||||||
# [semantic_type]: Semantic type context(major, minor, patch)
|
|
||||||
#
|
|
||||||
# Explanation:
|
|
||||||
# * Bumps the specified container version within the makefile config and the container's package.json.
|
|
||||||
# * Bumps the global project version in the makefile, and creates a new git tag with said version.
|
|
||||||
|
|
||||||
# Extract container and semantic_type inputted.
|
|
||||||
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
|
|
||||||
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
|
|
||||||
$(eval INPUT_SEMANTIC_TYPE := $(lastword $(subst :, ,$(INPUT_TARGET))))
|
|
||||||
|
|
||||||
# Extract old container and project versions.
|
|
||||||
$(eval OLD_CONTAINER_VERSION := $(subst v,,$(CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION)))
|
|
||||||
$(eval OLD_PROJECT_VERSION := $(subst v,,$(PROJECT_VERSION)))
|
|
||||||
|
|
||||||
# Pull docker semver becsause the normal command doesn't seem to work; also we don't need to worry about dependencies.
|
|
||||||
docker pull usvc/semver:latest
|
|
||||||
|
|
||||||
# Bump npm package.json file for selected container
|
|
||||||
cd $(call container_location,$(INPUT_CONTAINER)) && npm version $(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_CONTAINER_VERSION))
|
|
||||||
|
|
||||||
# Bump the git tag to match the new global version
|
|
||||||
git tag v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_PROJECT_VERSION))
|
|
||||||
|
|
||||||
# Bump the container version and global version in the Makefile
|
|
||||||
perl -pi -e 's/^PROJECT_VERSION\s*:=\s*\K.*/"v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_PROJECT_VERSION))"/ if /^PROJECT_VERSION\s*:=/' Makefile;
|
|
||||||
perl -pi -e 's/^CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION\s*:=\s*\K.*/"v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_CONTAINER_VERSION))"/ if /^CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION\s*:=/' Makefile;
|
|
||||||
|
|
||||||
# This function generates Docker build arguments based on variables defined in the Makefile.
|
|
||||||
# It extracts variable assignments, removes whitespace, and formats them as build arguments.
|
|
||||||
# Additionally, it appends any custom shell generated arguments defined below.
|
|
||||||
define args
|
|
||||||
$(shell \
|
|
||||||
grep -E '^[[:alnum:]_]+[[:space:]]*[:?]?[[:space:]]*=' $(MAKEFILE_LIST) | \
|
|
||||||
awk 'BEGIN {FS = ":="} { \
|
|
||||||
gsub(/^[[:space:]]+|[[:space:]]+$$/, "", $$2); \
|
|
||||||
gsub(/^/, "\x27", $$2); \
|
|
||||||
gsub(/$$/, "\x27", $$2); \
|
|
||||||
gsub(/[[:space:]]+/, "", $$1); \
|
|
||||||
gsub(":", "", $$1); \
|
|
||||||
printf "--build-arg %s=%s ", $$1, $$2 \
|
|
||||||
}') \
|
|
||||||
--build-arg BUILD_DATE='"$(shell date)"' \
|
|
||||||
--build-arg GIT_COMMIT='"$(shell git rev-parse HEAD)"'
|
|
||||||
endef
|
|
||||||
|
|
||||||
# This function generates labels based on variables defined in the Makefile.
|
|
||||||
# It extracts only the selected container variables and is used to echo this information
|
|
||||||
# to the docker buildx engine in the command line.
|
|
||||||
define labels
|
|
||||||
--label $(PROJECT_ORGANIZATION).image.title=$(CONTAINER_$(1)_NAME) \
|
|
||||||
--label $(PROJECT_ORGANIZATION).image.description=$(CONTAINER_$(1)_DESCRIPTION) \
|
|
||||||
--label $(PROJECT_ORGANIZATION).image.authors=$(PROJECT_AUTHORS) \
|
|
||||||
--label $(PROJECT_ORGANIZATION).image.url=$(PROJECT_SOURCES) \
|
|
||||||
--label $(PROJECT_ORGANIZATION).image.source=$(PROJECT_SOURCES)/$(CONTAINER_$(1)_LOCATION)
|
|
||||||
endef
|
|
||||||
|
|
||||||
# This function returns a list of container names defined in the Makefile.
|
|
||||||
# In order for this function to return a container, it needs to have this format: CONTAINER_%_NAME!
|
|
||||||
define containers
|
|
||||||
$(strip $(filter-out $(_NL),$(foreach var,$(.VARIABLES),$(if $(filter CONTAINER_%_NAME,$(var)),$(strip $($(var)))))))
|
|
||||||
endef
|
|
||||||
|
|
||||||
define container_build
|
|
||||||
$(eval CONTAINER := $(word 1,$1))
|
|
||||||
$(eval ENVIRONMENT := $(word 2,$1))
|
|
||||||
$(eval ARGS := $(shell echo $(args)))
|
|
||||||
$(eval VERSION := $(strip $(call container_version,$(CONTAINER))))
|
|
||||||
$(eval TAG := $(PROJECT_NAME):$(ENVIRONMENT))
|
|
||||||
|
|
||||||
@echo "Building container: $(CONTAINER)"
|
|
||||||
@echo "Environment: $(ENVIRONMENT)"
|
|
||||||
@echo "Version: $(VERSION)"
|
|
||||||
|
|
||||||
@if [ "$(strip $(ENVIRONMENT))" != "local" ] && [ "$(strip $(ENVIRONMENT))" != "release" ]; then \
|
|
||||||
echo "Invalid environment. Please specify 'local' or 'release'"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
$(if $(filter $(strip $(ENVIRONMENT)),release), \
|
|
||||||
$(eval TAG := $(PROJECT_REGISTRY)/$(PROJECT_NAME):$(VERSION)), \
|
|
||||||
)
|
|
||||||
|
|
||||||
docker buildx build --load -t $(TAG) -f .docker/Dockerfile.$(ENVIRONMENT) ./$(strip $(subst $(SPACE),,$(call container_location,$(CONTAINER))))/. $(ARGS) $(call labels,$(shell echo $(CONTAINER_NAME) | tr '[:lower:]' '[:upper:]')) --no-cache
|
|
||||||
endef
|
|
||||||
|
|
||||||
define container_location
|
|
||||||
$(strip $(eval CONTAINER_NAME := $(shell echo $(1) | tr '[:lower:]' '[:upper:]'))) \
|
|
||||||
$(CONTAINER_$(CONTAINER_NAME)_LOCATION)
|
|
||||||
endef
|
|
||||||
|
|
||||||
define container_version
|
|
||||||
$(strip $(eval CONTAINER_NAME := $(shell echo $(1) | tr '[:lower:]' '[:upper:]'))) \
|
|
||||||
$(if $(CONTAINER_$(CONTAINER_NAME)_VERSION), \
|
|
||||||
$(shell echo $(strip $(strip $(CONTAINER_$(CONTAINER_NAME)_VERSION))) | tr -d '[:space:]'), \
|
|
||||||
$(error Version data for container $(1) not found))
|
|
||||||
endef
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
import node from "@astrojs/node";
|
import vercel from "@astrojs/vercel";
|
||||||
import tailwind from "@astrojs/tailwind";
|
import tailwind from "@astrojs/tailwind";
|
||||||
import react from "@astrojs/react";
|
import react from "@astrojs/react";
|
||||||
import mdx from "@astrojs/mdx";
|
import mdx from "@astrojs/mdx";
|
||||||
@@ -10,13 +10,7 @@ import sitemap from "@astrojs/sitemap";
|
|||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: "server",
|
output: "server",
|
||||||
server: {
|
adapter: vercel(),
|
||||||
host: true,
|
|
||||||
port: 3000,
|
|
||||||
},
|
|
||||||
adapter: node({
|
|
||||||
mode: "standalone",
|
|
||||||
}),
|
|
||||||
site: "https://timmypidashev.dev",
|
site: "https://timmypidashev.dev",
|
||||||
build: {
|
build: {
|
||||||
// Enable build-time optimizations
|
// Enable build-time optimizations
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
services:
|
|
||||||
caddy:
|
|
||||||
container_name: caddy
|
|
||||||
image: caddy:latest
|
|
||||||
ports:
|
|
||||||
- 80:80
|
|
||||||
- 443:443
|
|
||||||
volumes:
|
|
||||||
- ./.caddy/Caddyfile.release:/etc/caddy/Caddyfile:rw
|
|
||||||
networks:
|
|
||||||
- proxy_network
|
|
||||||
depends_on:
|
|
||||||
- timmypidashev.dev
|
|
||||||
|
|
||||||
watchtower:
|
|
||||||
container_name: updates
|
|
||||||
image: containrrr/watchtower
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- $HOME/.docker/config.json:/config.json
|
|
||||||
command: --interval 120 --cleanup --label-enable
|
|
||||||
|
|
||||||
timmypidashev.dev:
|
|
||||||
container_name: timmypidashev.dev
|
|
||||||
image: ghcr.io/timmypidashev/timmypidashev.dev:latest
|
|
||||||
networks:
|
|
||||||
- proxy_network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
proxy_network:
|
|
||||||
name: proxy_network
|
|
||||||
external: true
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "src",
|
"name": "timmypidashev-web",
|
||||||
"version": "2.1.1",
|
"version": "3.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev --host",
|
"dev": "astro dev --host",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^5.0.3",
|
"@astrojs/mdx": "^5.0.3",
|
||||||
"@astrojs/node": "^10.0.4",
|
"@astrojs/vercel": "^10.0.3",
|
||||||
"@astrojs/rss": "^4.0.18",
|
"@astrojs/rss": "^4.0.18",
|
||||||
"@astrojs/sitemap": "^3.7.2",
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
"@giscus/react": "^3.1.0",
|
"@giscus/react": "^3.1.0",
|
||||||
530
src/pnpm-lock.yaml → pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.8 MiB After Width: | Height: | Size: 6.8 MiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 642 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
1
public/scripts
Submodule
@@ -1,11 +0,0 @@
|
|||||||
# Astro with Tailwind
|
|
||||||
|
|
||||||
```
|
|
||||||
npm init astro -- --template with-tailwindcss
|
|
||||||
```
|
|
||||||
|
|
||||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-tailwindcss)
|
|
||||||
|
|
||||||
Astro comes with [Tailwind](https://tailwindcss.com) support out of the box. This example showcases how to style your Astro project with Tailwind.
|
|
||||||
|
|
||||||
For complete setup instructions, please see our [Styling Guide](https://docs.astro.build/guides/styling#-tailwind).
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
const GlitchText = () => {
|
const GlitchText = () => {
|
||||||
const originalText = 'Error 404';
|
const originalText = 'Error 404';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ChevronDownIcon } from "@/components/icons";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
export default function Intro() {
|
export default function Intro() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
@@ -97,7 +97,7 @@ export default function Intro() {
|
|||||||
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
|
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
|
||||||
aria-label="Scroll to next section"
|
aria-label="Scroll to next section"
|
||||||
>
|
>
|
||||||
<ChevronDownIcon size={40} className="animate-bounce" />
|
<ChevronDown size={40} className="animate-bounce" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface ActivityDay {
|
interface ActivityDay {
|
||||||
grand_total: { total_seconds: number };
|
grand_total: { total_seconds: number };
|
||||||
@@ -117,7 +117,14 @@ const Stats = () => {
|
|||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.bg-gradient-text {
|
.bg-gradient-text {
|
||||||
background: linear-gradient(90deg, #fbbf24, #f59e0b, #d97706, #b45309, #f59e0b, #fbbf24);
|
background: linear-gradient(90deg,
|
||||||
|
rgb(var(--color-yellow-bright)),
|
||||||
|
rgb(var(--color-orange-bright)),
|
||||||
|
rgb(var(--color-orange)),
|
||||||
|
rgb(var(--color-yellow)),
|
||||||
|
rgb(var(--color-orange-bright)),
|
||||||
|
rgb(var(--color-yellow-bright))
|
||||||
|
);
|
||||||
background-size: 200% auto;
|
background-size: 200% auto;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface AnimateInProps {
|
interface AnimateInProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
58
src/components/animation-switcher/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
getStoredAnimationId,
|
||||||
|
getNextAnimation,
|
||||||
|
saveAnimation,
|
||||||
|
} from "@/lib/animations/engine";
|
||||||
|
import { ANIMATION_LABELS } from "@/lib/animations";
|
||||||
|
|
||||||
|
export default function AnimationSwitcher() {
|
||||||
|
const [hovering, setHovering] = useState(false);
|
||||||
|
const [currentLabel, setCurrentLabel] = useState("");
|
||||||
|
const committedRef = useRef("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
committedRef.current = getStoredAnimationId();
|
||||||
|
setCurrentLabel(ANIMATION_LABELS[committedRef.current]);
|
||||||
|
|
||||||
|
const handleSwap = () => {
|
||||||
|
const id = getStoredAnimationId();
|
||||||
|
committedRef.current = id;
|
||||||
|
setCurrentLabel(ANIMATION_LABELS[id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("astro:after-swap", handleSwap);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("astro:after-swap", handleSwap);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const nextId = getNextAnimation(
|
||||||
|
committedRef.current as Parameters<typeof getNextAnimation>[0]
|
||||||
|
);
|
||||||
|
saveAnimation(nextId);
|
||||||
|
committedRef.current = nextId;
|
||||||
|
setCurrentLabel(ANIMATION_LABELS[nextId]);
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("animation-changed", { detail: { id: nextId } })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block"
|
||||||
|
onMouseEnter={() => setHovering(true)}
|
||||||
|
onMouseLeave={() => setHovering(false)}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||||
|
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||||
|
>
|
||||||
|
{currentLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
574
src/components/background/engines/asciiquarium.ts
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
// --- ASCII Art ---
|
||||||
|
|
||||||
|
interface AsciiPattern {
|
||||||
|
lines: string[];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pat(lines: string[]): AsciiPattern {
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
width: Math.max(...lines.map((l) => l.length)),
|
||||||
|
height: lines.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FISH_DEFS: {
|
||||||
|
size: "small" | "medium";
|
||||||
|
weight: number;
|
||||||
|
right: AsciiPattern;
|
||||||
|
left: AsciiPattern;
|
||||||
|
}[] = [
|
||||||
|
{ size: "small", weight: 30, right: pat(["><>"]), left: pat(["<><"]) },
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
weight: 30,
|
||||||
|
right: pat(["><(('>"]),
|
||||||
|
left: pat(["<'))><"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: "medium",
|
||||||
|
weight: 20,
|
||||||
|
right: pat(["><((o>"]),
|
||||||
|
left: pat(["<o))><"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: "medium",
|
||||||
|
weight: 10,
|
||||||
|
right: pat(["><((('>"]),
|
||||||
|
left: pat(["<')))><"]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOTAL_FISH_WEIGHT = FISH_DEFS.reduce((s, d) => s + d.weight, 0);
|
||||||
|
|
||||||
|
const BUBBLE_CHARS = [".", "o", "O"];
|
||||||
|
|
||||||
|
// --- Entity Interfaces ---
|
||||||
|
|
||||||
|
interface FishEntity {
|
||||||
|
kind: "fish";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
pattern: AsciiPattern;
|
||||||
|
size: "small" | "medium";
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
opacity: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
staggerDelay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BubbleEntity {
|
||||||
|
kind: "bubble";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vy: number;
|
||||||
|
wobblePhase: number;
|
||||||
|
wobbleAmplitude: number;
|
||||||
|
char: string;
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
opacity: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
staggerDelay: number;
|
||||||
|
burst: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AquariumEntity = FishEntity | BubbleEntity;
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
|
||||||
|
const BASE_AREA = 1920 * 1080;
|
||||||
|
const BASE_FISH = 16;
|
||||||
|
const BASE_BUBBLES = 12;
|
||||||
|
|
||||||
|
const TARGET_FPS = 60;
|
||||||
|
const FONT_SIZE_MIN = 24;
|
||||||
|
const FONT_SIZE_MAX = 36;
|
||||||
|
const FONT_SIZE_REF_WIDTH = 1920;
|
||||||
|
const LINE_HEIGHT_RATIO = 1.15;
|
||||||
|
const STAGGER_INTERVAL = 15;
|
||||||
|
const PI_2 = Math.PI * 2;
|
||||||
|
|
||||||
|
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||||
|
const ELEVATION_FACTOR = 6;
|
||||||
|
const ELEVATION_LERP_SPEED = 0.05;
|
||||||
|
const COLOR_SHIFT_AMOUNT = 30;
|
||||||
|
const SHADOW_OFFSET_RATIO = 1.1;
|
||||||
|
|
||||||
|
const FISH_SPEED: Record<string, { min: number; max: number }> = {
|
||||||
|
small: { min: 0.8, max: 1.4 },
|
||||||
|
medium: { min: 0.5, max: 0.9 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUBBLE_SPEED_MIN = 0.3;
|
||||||
|
const BUBBLE_SPEED_MAX = 0.7;
|
||||||
|
const BUBBLE_WOBBLE_MIN = 0.3;
|
||||||
|
const BUBBLE_WOBBLE_MAX = 1.0;
|
||||||
|
|
||||||
|
const BURST_BUBBLE_COUNT = 10;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function range(a: number, b: number): number {
|
||||||
|
return (b - a) * Math.random() + a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFishDef() {
|
||||||
|
let r = Math.random() * TOTAL_FISH_WEIGHT;
|
||||||
|
for (const def of FISH_DEFS) {
|
||||||
|
r -= def.weight;
|
||||||
|
if (r <= 0) return def;
|
||||||
|
}
|
||||||
|
return FISH_DEFS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Engine ---
|
||||||
|
|
||||||
|
export class AsciiquariumEngine implements AnimationEngine {
|
||||||
|
id = "asciiquarium";
|
||||||
|
name = "Asciiquarium";
|
||||||
|
|
||||||
|
private fish: FishEntity[] = [];
|
||||||
|
private bubbles: BubbleEntity[] = [];
|
||||||
|
private exiting = false;
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private elapsed = 0;
|
||||||
|
private charWidth = 0;
|
||||||
|
private fontSize = FONT_SIZE_MAX;
|
||||||
|
private lineHeight = FONT_SIZE_MAX * LINE_HEIGHT_RATIO;
|
||||||
|
private font = `bold ${FONT_SIZE_MAX}px monospace`;
|
||||||
|
|
||||||
|
private computeFont(width: number): void {
|
||||||
|
const t = Math.sqrt(Math.min(1, width / FONT_SIZE_REF_WIDTH));
|
||||||
|
this.fontSize = Math.round(FONT_SIZE_MIN + (FONT_SIZE_MAX - FONT_SIZE_MIN) * t);
|
||||||
|
this.lineHeight = Math.round(this.fontSize * LINE_HEIGHT_RATIO);
|
||||||
|
this.font = `bold ${this.fontSize}px monospace`;
|
||||||
|
this.charWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
_bgColor: string
|
||||||
|
): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.palette = palette;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.computeFont(width);
|
||||||
|
this.initEntities();
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
// Stagger fade-out over 3 seconds
|
||||||
|
for (const f of this.fish) {
|
||||||
|
const delay = Math.random() * 3000;
|
||||||
|
setTimeout(() => {
|
||||||
|
f.staggerDelay = -2; // signal: fading out
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
for (const b of this.bubbles) {
|
||||||
|
const delay = Math.random() * 3000;
|
||||||
|
setTimeout(() => {
|
||||||
|
b.staggerDelay = -2;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
for (const f of this.fish) {
|
||||||
|
if (f.opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
for (const b of this.bubbles) {
|
||||||
|
if (b.opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.fish = [];
|
||||||
|
this.bubbles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomColor(): [number, number, number] {
|
||||||
|
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCounts(): { fish: number; bubbles: number } {
|
||||||
|
const ratio = (this.width * this.height) / BASE_AREA;
|
||||||
|
return {
|
||||||
|
fish: Math.max(5, Math.round(BASE_FISH * ratio)),
|
||||||
|
bubbles: Math.max(5, Math.round(BASE_BUBBLES * ratio)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private initEntities(): void {
|
||||||
|
this.fish = [];
|
||||||
|
this.bubbles = [];
|
||||||
|
|
||||||
|
const counts = this.getCounts();
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < counts.fish; i++) {
|
||||||
|
this.fish.push(this.spawnFish(idx++));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < counts.bubbles; i++) {
|
||||||
|
this.bubbles.push(this.spawnBubble(idx++, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnFish(staggerIdx: number): FishEntity {
|
||||||
|
const def = pickFishDef();
|
||||||
|
const goRight = Math.random() > 0.5;
|
||||||
|
const speed = range(FISH_SPEED[def.size].min, FISH_SPEED[def.size].max);
|
||||||
|
const pattern = goRight ? def.right : def.left;
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
const cw = this.charWidth || 9.6;
|
||||||
|
const pw = pattern.width * cw;
|
||||||
|
|
||||||
|
// Start off-screen on the side they swim from
|
||||||
|
const startX = goRight
|
||||||
|
? -pw - range(0, this.width * 0.5)
|
||||||
|
: this.width + range(0, this.width * 0.5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "fish",
|
||||||
|
x: startX,
|
||||||
|
y: range(this.height * 0.05, this.height * 0.9),
|
||||||
|
vx: goRight ? speed : -speed,
|
||||||
|
pattern,
|
||||||
|
size: def.size,
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 1,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnBubble(staggerIdx: number, burst: boolean): BubbleEntity {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
return {
|
||||||
|
kind: "bubble",
|
||||||
|
x: range(0, this.width),
|
||||||
|
y: burst ? 0 : this.height + range(10, this.height * 0.5),
|
||||||
|
vy: -range(BUBBLE_SPEED_MIN, BUBBLE_SPEED_MAX),
|
||||||
|
wobblePhase: range(0, PI_2),
|
||||||
|
wobbleAmplitude: range(BUBBLE_WOBBLE_MIN, BUBBLE_WOBBLE_MAX),
|
||||||
|
char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)],
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 1,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: -1,
|
||||||
|
burst,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
const dt = deltaTime / (1000 / TARGET_FPS);
|
||||||
|
this.elapsed += deltaTime;
|
||||||
|
|
||||||
|
const mouseX = this.mouseX;
|
||||||
|
const mouseY = this.mouseY;
|
||||||
|
const cw = this.charWidth || 9.6;
|
||||||
|
|
||||||
|
// Fish
|
||||||
|
for (let i = this.fish.length - 1; i >= 0; i--) {
|
||||||
|
const f = this.fish[i];
|
||||||
|
if (f.staggerDelay >= 0) {
|
||||||
|
if (this.elapsed >= f.staggerDelay) f.staggerDelay = -1;
|
||||||
|
else continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out during exit
|
||||||
|
if (f.staggerDelay === -2) {
|
||||||
|
f.opacity -= 0.02 * dt;
|
||||||
|
if (f.opacity <= 0) { f.opacity = 0; continue; }
|
||||||
|
} else if (f.opacity < 1) {
|
||||||
|
f.opacity = Math.min(1, f.opacity + 0.03 * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.x += f.vx * dt;
|
||||||
|
|
||||||
|
const pw = f.pattern.width * cw;
|
||||||
|
if (f.vx > 0 && f.x > this.width + pw) {
|
||||||
|
f.x = -pw;
|
||||||
|
} else if (f.vx < 0 && f.x < -pw) {
|
||||||
|
f.x = this.width + pw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = f.x + (f.pattern.width * cw) / 2;
|
||||||
|
const cy = f.y + (f.pattern.height * this.lineHeight) / 2;
|
||||||
|
this.applyMouseInfluence(f, cx, cy, mouseX, mouseY, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubbles (reverse iteration for safe splice)
|
||||||
|
for (let i = this.bubbles.length - 1; i >= 0; i--) {
|
||||||
|
const b = this.bubbles[i];
|
||||||
|
|
||||||
|
if (b.staggerDelay >= 0) {
|
||||||
|
if (this.elapsed >= b.staggerDelay) b.staggerDelay = -1;
|
||||||
|
else continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out during exit
|
||||||
|
if (b.staggerDelay === -2) {
|
||||||
|
b.opacity -= 0.02 * dt;
|
||||||
|
if (b.opacity <= 0) { b.opacity = 0; continue; }
|
||||||
|
} else if (b.opacity < 1) {
|
||||||
|
b.opacity = Math.min(1, b.opacity + 0.03 * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
b.y += b.vy * dt;
|
||||||
|
b.x +=
|
||||||
|
Math.sin(this.elapsed * 0.003 + b.wobblePhase) *
|
||||||
|
b.wobbleAmplitude *
|
||||||
|
dt;
|
||||||
|
|
||||||
|
if (b.y < -20) {
|
||||||
|
if (b.burst) {
|
||||||
|
this.bubbles.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
b.y = this.height + range(10, 40);
|
||||||
|
b.x = range(0, this.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyMouseInfluence(b, b.x, b.y, mouseX, mouseY, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyMouseInfluence(
|
||||||
|
entity: AquariumEntity,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
mouseX: number,
|
||||||
|
mouseY: number,
|
||||||
|
dt: number
|
||||||
|
): void {
|
||||||
|
const dx = cx - mouseX;
|
||||||
|
const dy = cy - mouseY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < MOUSE_INFLUENCE_RADIUS && entity.opacity > 0.1) {
|
||||||
|
const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2));
|
||||||
|
entity.targetElevation = ELEVATION_FACTOR * inf * inf;
|
||||||
|
|
||||||
|
const shift = inf * COLOR_SHIFT_AMOUNT * 0.5;
|
||||||
|
entity.color = [
|
||||||
|
Math.min(255, Math.max(0, entity.baseColor[0] + shift)),
|
||||||
|
Math.min(255, Math.max(0, entity.baseColor[1] + shift)),
|
||||||
|
Math.min(255, Math.max(0, entity.baseColor[2] + shift)),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
entity.targetElevation = 0;
|
||||||
|
entity.color[0] += (entity.baseColor[0] - entity.color[0]) * 0.1;
|
||||||
|
entity.color[1] += (entity.baseColor[1] - entity.color[1]) * 0.1;
|
||||||
|
entity.color[2] += (entity.baseColor[2] - entity.color[2]) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.elevation +=
|
||||||
|
(entity.targetElevation - entity.elevation) * ELEVATION_LERP_SPEED * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
_width: number,
|
||||||
|
_height: number
|
||||||
|
): void {
|
||||||
|
if (!this.charWidth) {
|
||||||
|
ctx.font = this.font;
|
||||||
|
this.charWidth = ctx.measureText("M").width;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.font = this.font;
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
|
||||||
|
// Fish
|
||||||
|
for (const f of this.fish) {
|
||||||
|
if (f.opacity <= 0.01 || f.staggerDelay >= 0) continue;
|
||||||
|
this.renderPattern(
|
||||||
|
ctx,
|
||||||
|
f.pattern,
|
||||||
|
f.x,
|
||||||
|
f.y,
|
||||||
|
f.color,
|
||||||
|
f.opacity,
|
||||||
|
f.elevation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubbles
|
||||||
|
for (const b of this.bubbles) {
|
||||||
|
if (b.opacity <= 0.01 || b.staggerDelay >= 0) continue;
|
||||||
|
this.renderChar(ctx, b.char, b.x, b.y, b.color, b.opacity, b.elevation);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPattern(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
pattern: AsciiPattern,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
color: [number, number, number],
|
||||||
|
opacity: number,
|
||||||
|
elevation: number
|
||||||
|
): void {
|
||||||
|
const drawY = y - elevation;
|
||||||
|
const [r, g, b] = color;
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||||
|
ctx.globalAlpha = shadowAlpha;
|
||||||
|
ctx.fillStyle = "rgb(0,0,0)";
|
||||||
|
for (let line = 0; line < pattern.height; line++) {
|
||||||
|
ctx.fillText(
|
||||||
|
pattern.lines[line],
|
||||||
|
x,
|
||||||
|
drawY + line * this.lineHeight + elevation * SHADOW_OFFSET_RATIO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main text
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
for (let line = 0; line < pattern.height; line++) {
|
||||||
|
ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight (top half of lines)
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||||
|
ctx.globalAlpha = highlightAlpha;
|
||||||
|
ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
const topLines = Math.ceil(pattern.height / 2);
|
||||||
|
for (let line = 0; line < topLines; line++) {
|
||||||
|
ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderChar(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
char: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
color: [number, number, number],
|
||||||
|
opacity: number,
|
||||||
|
elevation: number
|
||||||
|
): void {
|
||||||
|
const drawY = y - elevation;
|
||||||
|
const [r, g, b] = color;
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||||
|
ctx.globalAlpha = shadowAlpha;
|
||||||
|
ctx.fillStyle = "rgb(0,0,0)";
|
||||||
|
ctx.fillText(char, x, drawY + elevation * SHADOW_OFFSET_RATIO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.fillText(char, x, drawY);
|
||||||
|
|
||||||
|
// Highlight
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||||
|
ctx.globalAlpha = highlightAlpha;
|
||||||
|
ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
ctx.fillText(char, x, drawY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Events ---
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.exiting = false;
|
||||||
|
this.computeFont(width);
|
||||||
|
this.initEntities();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
for (let i = 0; i < BURST_BUBBLE_COUNT; i++) {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
const angle = (i / BURST_BUBBLE_COUNT) * PI_2 + range(-0.3, 0.3);
|
||||||
|
const speed = range(0.3, 1.0);
|
||||||
|
this.bubbles.push({
|
||||||
|
kind: "bubble",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vy: -Math.abs(Math.sin(angle) * speed) - 0.3,
|
||||||
|
wobblePhase: range(0, PI_2),
|
||||||
|
wobbleAmplitude: Math.cos(angle) * speed * 0.5,
|
||||||
|
char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)],
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 1,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: this.exiting ? -2 : -1,
|
||||||
|
burst: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(
|
||||||
|
palette: [number, number, number][],
|
||||||
|
_bgColor: string
|
||||||
|
): void {
|
||||||
|
this.palette = palette;
|
||||||
|
for (let i = 0; i < this.fish.length; i++) {
|
||||||
|
this.fish[i].baseColor = palette[i % palette.length];
|
||||||
|
}
|
||||||
|
for (let i = 0; i < this.bubbles.length; i++) {
|
||||||
|
this.bubbles[i].baseColor = palette[i % palette.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/components/background/engines/confetti.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
interface ConfettiParticle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
r: number;
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
opacity: number;
|
||||||
|
dop: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
staggerDelay: number;
|
||||||
|
burst: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_CONFETTI = 385;
|
||||||
|
const BASE_AREA = 1920 * 1080;
|
||||||
|
const PI_2 = 2 * Math.PI;
|
||||||
|
const TARGET_FPS = 60;
|
||||||
|
const SPEED_FACTOR = 0.15;
|
||||||
|
const STAGGER_INTERVAL = 12;
|
||||||
|
const COLOR_LERP_SPEED = 0.02;
|
||||||
|
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||||
|
const ELEVATION_FACTOR = 6;
|
||||||
|
const ELEVATION_LERP_SPEED = 0.05;
|
||||||
|
const COLOR_SHIFT_AMOUNT = 30;
|
||||||
|
const SHADOW_OFFSET_RATIO = 1.1;
|
||||||
|
|
||||||
|
function range(a: number, b: number): number {
|
||||||
|
return (b - a) * Math.random() + a;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConfettiEngine implements AnimationEngine {
|
||||||
|
id = "confetti";
|
||||||
|
name = "Confetti";
|
||||||
|
|
||||||
|
private particles: ConfettiParticle[] = [];
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private mouseXNorm = 0.5;
|
||||||
|
private elapsed = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
_bgColor: string
|
||||||
|
): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.palette = palette;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.mouseXNorm = 0.5;
|
||||||
|
this.initParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
// Stagger fade-out over 3 seconds
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
const p = this.particles[i];
|
||||||
|
p.staggerDelay = -1; // ensure visible
|
||||||
|
// Random delay before fade starts, stored as negative dop
|
||||||
|
const delay = Math.random() * 3000;
|
||||||
|
setTimeout(() => {
|
||||||
|
p.dop = -0.02;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
if (this.particles[i].opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.particles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomColor(): [number, number, number] {
|
||||||
|
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getParticleCount(): number {
|
||||||
|
const area = this.width * this.height;
|
||||||
|
return Math.max(20, Math.round(BASE_CONFETTI * (area / BASE_AREA)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private initParticles(): void {
|
||||||
|
this.particles = [];
|
||||||
|
const count = this.getParticleCount();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
const r = ~~range(3, 8);
|
||||||
|
this.particles.push({
|
||||||
|
x: range(-r * 2, this.width - r * 2),
|
||||||
|
y: range(-20, this.height - r * 2),
|
||||||
|
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
|
||||||
|
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
|
||||||
|
r,
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 0,
|
||||||
|
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: i * STAGGER_INTERVAL + range(0, STAGGER_INTERVAL),
|
||||||
|
burst: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceParticle(p: ConfettiParticle): void {
|
||||||
|
p.opacity = 0;
|
||||||
|
p.dop = 0.03 * range(1, 4) * SPEED_FACTOR;
|
||||||
|
p.x = range(-p.r * 2, this.width - p.r * 2);
|
||||||
|
p.y = range(-20, -p.r * 2);
|
||||||
|
p.vx = (range(0, 2) + 8 * this.mouseXNorm - 5) * SPEED_FACTOR;
|
||||||
|
p.vy = (0.7 * p.r + range(-1, 1)) * SPEED_FACTOR;
|
||||||
|
p.elevation = 0;
|
||||||
|
p.targetElevation = 0;
|
||||||
|
p.baseColor = this.randomColor();
|
||||||
|
p.color = [...p.baseColor];
|
||||||
|
p.burst = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
const dt = deltaTime / (1000 / TARGET_FPS);
|
||||||
|
this.elapsed += deltaTime;
|
||||||
|
|
||||||
|
const mouseX = this.mouseX;
|
||||||
|
const mouseY = this.mouseY;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
const p = this.particles[i];
|
||||||
|
|
||||||
|
// Stagger gate
|
||||||
|
if (p.staggerDelay >= 0) {
|
||||||
|
if (this.elapsed >= p.staggerDelay) {
|
||||||
|
p.staggerDelay = -1;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravity (capped so falling particles don't accelerate)
|
||||||
|
const maxVy = (0.7 * p.r + 1) * SPEED_FACTOR;
|
||||||
|
if (p.vy < maxVy) {
|
||||||
|
p.vy = Math.min(p.vy + 0.02 * dt, maxVy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position update
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
|
||||||
|
// Fade in, or fade out during exit
|
||||||
|
if (this.exiting && p.dop < 0) {
|
||||||
|
p.opacity += p.dop * dt;
|
||||||
|
if (p.opacity < 0) p.opacity = 0;
|
||||||
|
} else if (p.opacity < 1) {
|
||||||
|
p.opacity += Math.abs(p.dop) * dt;
|
||||||
|
if (p.opacity > 1) p.opacity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the bottom: burst particles removed, base particles recycle (or remove during exit)
|
||||||
|
if (p.y > this.height + p.r) {
|
||||||
|
if (p.burst || this.exiting) {
|
||||||
|
this.particles.splice(i, 1);
|
||||||
|
i--;
|
||||||
|
} else {
|
||||||
|
this.replaceParticle(p);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal wrap
|
||||||
|
const xmax = this.width - p.r;
|
||||||
|
if (p.x < 0 || p.x > xmax) {
|
||||||
|
p.x = ((p.x % xmax) + xmax) % xmax;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse proximity elevation
|
||||||
|
const dx = p.x - mouseX;
|
||||||
|
const dy = p.y - mouseY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < MOUSE_INFLUENCE_RADIUS && p.opacity > 0.1) {
|
||||||
|
const influenceFactor = Math.cos(
|
||||||
|
(dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
|
||||||
|
);
|
||||||
|
p.targetElevation =
|
||||||
|
ELEVATION_FACTOR * influenceFactor * influenceFactor;
|
||||||
|
|
||||||
|
const shift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
|
||||||
|
p.color = [
|
||||||
|
Math.min(255, Math.max(0, p.baseColor[0] + shift)),
|
||||||
|
Math.min(255, Math.max(0, p.baseColor[1] + shift)),
|
||||||
|
Math.min(255, Math.max(0, p.baseColor[2] + shift)),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
p.targetElevation = 0;
|
||||||
|
p.color[0] += (p.baseColor[0] - p.color[0]) * 0.1;
|
||||||
|
p.color[1] += (p.baseColor[1] - p.color[1]) * 0.1;
|
||||||
|
p.color[2] += (p.baseColor[2] - p.color[2]) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elevation lerp
|
||||||
|
p.elevation +=
|
||||||
|
(p.targetElevation - p.elevation) * ELEVATION_LERP_SPEED * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
_width: number,
|
||||||
|
_height: number
|
||||||
|
): void {
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
const p = this.particles[i];
|
||||||
|
if (p.opacity <= 0.01 || p.staggerDelay >= 0) continue;
|
||||||
|
|
||||||
|
const drawX = ~~p.x;
|
||||||
|
const drawY = ~~p.y - p.elevation;
|
||||||
|
const [r, g, b] = p.color;
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
if (p.elevation > 0.5) {
|
||||||
|
const shadowAlpha =
|
||||||
|
0.2 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
|
||||||
|
ctx.globalAlpha = shadowAlpha;
|
||||||
|
ctx.fillStyle = "rgb(0,0,0)";
|
||||||
|
ctx.shadowBlur = 2;
|
||||||
|
ctx.shadowColor = "rgba(0,0,0,0.1)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(
|
||||||
|
drawX,
|
||||||
|
drawY + p.elevation * SHADOW_OFFSET_RATIO,
|
||||||
|
p.r,
|
||||||
|
0,
|
||||||
|
PI_2
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.shadowColor = "transparent";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main circle
|
||||||
|
ctx.globalAlpha = p.opacity;
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(drawX, drawY, p.r, 0, PI_2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Highlight on elevated particles
|
||||||
|
if (p.elevation > 0.5) {
|
||||||
|
const highlightAlpha =
|
||||||
|
0.1 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
|
||||||
|
ctx.globalAlpha = highlightAlpha;
|
||||||
|
ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(drawX, drawY, p.r, Math.PI, 0);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.initParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
if (this.width > 0) {
|
||||||
|
this.mouseXNorm = Math.max(0, Math.min(1, x / this.width));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
const count = 12;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
const r = ~~range(3, 8);
|
||||||
|
const angle = (i / count) * PI_2 + range(-0.3, 0.3);
|
||||||
|
const speed = range(0.3, 1.2);
|
||||||
|
this.particles.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
r,
|
||||||
|
color: [...baseColor],
|
||||||
|
baseColor,
|
||||||
|
opacity: 1,
|
||||||
|
dop: this.exiting ? -0.02 : 0,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
staggerDelay: -1,
|
||||||
|
burst: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
this.mouseXNorm = 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], _bgColor: string): void {
|
||||||
|
this.palette = palette;
|
||||||
|
for (let i = 0; i < this.particles.length; i++) {
|
||||||
|
this.particles[i].baseColor = palette[i % palette.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
670
src/components/background/engines/game-of-life.ts
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
alive: boolean;
|
||||||
|
next: boolean;
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
targetX: number;
|
||||||
|
targetY: number;
|
||||||
|
opacity: number;
|
||||||
|
targetOpacity: number;
|
||||||
|
scale: number;
|
||||||
|
targetScale: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
transitioning: boolean;
|
||||||
|
transitionComplete: boolean;
|
||||||
|
rippleEffect: number;
|
||||||
|
rippleStartTime: number;
|
||||||
|
rippleDistance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Grid {
|
||||||
|
cells: Cell[][];
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CELL_SIZE_MOBILE = 15;
|
||||||
|
const CELL_SIZE_DESKTOP = 25;
|
||||||
|
const TARGET_FPS = 60;
|
||||||
|
const CYCLE_TIME = 3000;
|
||||||
|
const TRANSITION_SPEED = 0.05;
|
||||||
|
const SCALE_SPEED = 0.05;
|
||||||
|
const INITIAL_DENSITY = 0.15;
|
||||||
|
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||||
|
const COLOR_SHIFT_AMOUNT = 30;
|
||||||
|
const RIPPLE_ELEVATION_FACTOR = 4;
|
||||||
|
const ELEVATION_FACTOR = 8;
|
||||||
|
|
||||||
|
export class GameOfLifeEngine implements AnimationEngine {
|
||||||
|
id = "game-of-life";
|
||||||
|
name = "Game of Life";
|
||||||
|
|
||||||
|
private grid: Grid | null = null;
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private bgColor = "rgb(0, 0, 0)";
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private mouseIsDown = false;
|
||||||
|
private mouseCellX = -1;
|
||||||
|
private mouseCellY = -1;
|
||||||
|
private lastCycleTime = 0;
|
||||||
|
private timeAccumulator = 0;
|
||||||
|
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
private canvasWidth = 0;
|
||||||
|
private canvasHeight = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
bgColor: string
|
||||||
|
): void {
|
||||||
|
this.palette = palette;
|
||||||
|
this.bgColor = bgColor;
|
||||||
|
this.canvasWidth = width;
|
||||||
|
this.canvasHeight = height;
|
||||||
|
this.lastCycleTime = 0;
|
||||||
|
this.timeAccumulator = 0;
|
||||||
|
this.grid = this.initGrid(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
for (const id of this.pendingTimeouts) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.pendingTimeouts = [];
|
||||||
|
this.grid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCellSize(): number {
|
||||||
|
return this.canvasWidth <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomColor(): [number, number, number] {
|
||||||
|
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private initGrid(width: number, height: number): Grid {
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
const cols = Math.floor(width / cellSize);
|
||||||
|
const rows = Math.floor(height / cellSize);
|
||||||
|
const offsetX = Math.floor((width - cols * cellSize) / 2);
|
||||||
|
const offsetY = Math.floor((height - rows * cellSize) / 2);
|
||||||
|
|
||||||
|
const cells = Array(cols)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) =>
|
||||||
|
Array(rows)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, j) => {
|
||||||
|
const baseColor = this.randomColor();
|
||||||
|
return {
|
||||||
|
alive: Math.random() < INITIAL_DENSITY,
|
||||||
|
next: false,
|
||||||
|
color: [...baseColor] as [number, number, number],
|
||||||
|
baseColor,
|
||||||
|
currentX: i,
|
||||||
|
currentY: j,
|
||||||
|
targetX: i,
|
||||||
|
targetY: j,
|
||||||
|
opacity: 0,
|
||||||
|
targetOpacity: 0,
|
||||||
|
scale: 0,
|
||||||
|
targetScale: 0,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
transitioning: false,
|
||||||
|
transitionComplete: false,
|
||||||
|
rippleEffect: 0,
|
||||||
|
rippleStartTime: 0,
|
||||||
|
rippleDistance: 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const grid = { cells, cols, rows, offsetX, offsetY };
|
||||||
|
this.computeNextState(grid);
|
||||||
|
|
||||||
|
for (let i = 0; i < cols; i++) {
|
||||||
|
for (let j = 0; j < rows; j++) {
|
||||||
|
const cell = cells[i][j];
|
||||||
|
if (cell.next) {
|
||||||
|
cell.alive = true;
|
||||||
|
const tid = setTimeout(() => {
|
||||||
|
cell.targetOpacity = 1;
|
||||||
|
cell.targetScale = 1;
|
||||||
|
}, Math.random() * 1000);
|
||||||
|
this.pendingTimeouts.push(tid);
|
||||||
|
} else {
|
||||||
|
cell.alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private countNeighbors(
|
||||||
|
grid: Grid,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): { count: number; colors: [number, number, number][] } {
|
||||||
|
const neighbors = { count: 0, colors: [] as [number, number, number][] };
|
||||||
|
|
||||||
|
for (let i = -1; i <= 1; i++) {
|
||||||
|
for (let j = -1; j <= 1; j++) {
|
||||||
|
if (i === 0 && j === 0) continue;
|
||||||
|
|
||||||
|
const col = (x + i + grid.cols) % grid.cols;
|
||||||
|
const row = (y + j + grid.rows) % grid.rows;
|
||||||
|
|
||||||
|
if (grid.cells[col][row].alive) {
|
||||||
|
neighbors.count++;
|
||||||
|
neighbors.colors.push(grid.cells[col][row].baseColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private averageColors(
|
||||||
|
colors: [number, number, number][]
|
||||||
|
): [number, number, number] {
|
||||||
|
if (colors.length === 0) return [0, 0, 0];
|
||||||
|
const sum = colors.reduce(
|
||||||
|
(acc, color) => [acc[0] + color[0], acc[1] + color[1], acc[2] + color[2]],
|
||||||
|
[0, 0, 0]
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
Math.round(sum[0] / colors.length),
|
||||||
|
Math.round(sum[1] / colors.length),
|
||||||
|
Math.round(sum[2] / colors.length),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeNextState(grid: Grid): void {
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
const { count, colors } = this.countNeighbors(grid, i, j);
|
||||||
|
|
||||||
|
if (cell.alive) {
|
||||||
|
cell.next = count === 2 || count === 3;
|
||||||
|
} else {
|
||||||
|
cell.next = count === 3;
|
||||||
|
if (cell.next) {
|
||||||
|
cell.baseColor = this.averageColors(colors);
|
||||||
|
cell.color = [...cell.baseColor];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
|
||||||
|
if (cell.alive !== cell.next && !cell.transitioning) {
|
||||||
|
cell.transitioning = true;
|
||||||
|
cell.transitionComplete = false;
|
||||||
|
|
||||||
|
const delay = Math.random() * 800;
|
||||||
|
const tid = setTimeout(() => {
|
||||||
|
if (!cell.next) {
|
||||||
|
cell.targetScale = 0;
|
||||||
|
cell.targetOpacity = 0;
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
} else {
|
||||||
|
cell.targetScale = 1;
|
||||||
|
cell.targetOpacity = 1;
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
this.pendingTimeouts.push(tid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRippleEffect(
|
||||||
|
grid: Grid,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number
|
||||||
|
): void {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
|
||||||
|
const dx = i - centerX;
|
||||||
|
const dy = j - centerY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (cell.opacity > 0.1) {
|
||||||
|
cell.rippleStartTime = currentTime + distance * 100;
|
||||||
|
cell.rippleDistance = distance;
|
||||||
|
cell.rippleEffect = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnCellAtPosition(grid: Grid, x: number, y: number): void {
|
||||||
|
if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) {
|
||||||
|
const cell = grid.cells[x][y];
|
||||||
|
|
||||||
|
if (!cell.alive && !cell.transitioning) {
|
||||||
|
cell.alive = true;
|
||||||
|
cell.next = true;
|
||||||
|
cell.transitioning = true;
|
||||||
|
cell.transitionComplete = false;
|
||||||
|
cell.baseColor = this.randomColor();
|
||||||
|
cell.color = [...cell.baseColor];
|
||||||
|
cell.targetScale = 1;
|
||||||
|
cell.targetOpacity = 1;
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
|
||||||
|
this.createRippleEffect(grid, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting || !this.grid) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
// Cancel all pending GOL transitions so they don't revive cells
|
||||||
|
for (const id of this.pendingTimeouts) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.pendingTimeouts = [];
|
||||||
|
|
||||||
|
const grid = this.grid;
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
// Force cell into dying state, clear any pending transition
|
||||||
|
cell.next = false;
|
||||||
|
cell.transitioning = false;
|
||||||
|
cell.transitionComplete = false;
|
||||||
|
|
||||||
|
if (cell.opacity > 0.01) {
|
||||||
|
const delay = Math.random() * 3000;
|
||||||
|
const tid = setTimeout(() => {
|
||||||
|
cell.targetOpacity = 0;
|
||||||
|
cell.targetScale = 0;
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
}, delay);
|
||||||
|
this.pendingTimeouts.push(tid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
if (!this.grid) return true;
|
||||||
|
|
||||||
|
const grid = this.grid;
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
if (grid.cells[i][j].opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
if (!this.grid) return;
|
||||||
|
|
||||||
|
if (!this.exiting) {
|
||||||
|
this.timeAccumulator += deltaTime;
|
||||||
|
if (this.timeAccumulator >= CYCLE_TIME) {
|
||||||
|
this.computeNextState(this.grid);
|
||||||
|
this.timeAccumulator -= CYCLE_TIME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCellAnimations(this.grid, deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCellAnimations(grid: Grid, deltaTime: number): void {
|
||||||
|
const mouseX = this.mouseX;
|
||||||
|
const mouseY = this.mouseY;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
|
||||||
|
const transitionFactor =
|
||||||
|
TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
|
||||||
|
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
|
||||||
|
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
|
||||||
|
cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
|
||||||
|
cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
|
||||||
|
cell.elevation +=
|
||||||
|
(cell.targetElevation - cell.elevation) * scaleFactor;
|
||||||
|
|
||||||
|
const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
|
||||||
|
const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
|
||||||
|
const dx = cellCenterX - mouseX;
|
||||||
|
const dy = cellCenterY - mouseY;
|
||||||
|
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
|
||||||
|
const influenceFactor = Math.cos(
|
||||||
|
(distanceToMouse / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
|
||||||
|
);
|
||||||
|
cell.targetElevation =
|
||||||
|
ELEVATION_FACTOR * influenceFactor * influenceFactor;
|
||||||
|
|
||||||
|
const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
|
||||||
|
cell.color = [
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[2] + colorShift)),
|
||||||
|
] as [number, number, number];
|
||||||
|
} else {
|
||||||
|
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
|
||||||
|
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
|
||||||
|
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
|
||||||
|
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// During exit: snap to zero once close enough
|
||||||
|
if (this.exiting) {
|
||||||
|
if (cell.opacity < 0.05) {
|
||||||
|
cell.opacity = 0;
|
||||||
|
cell.scale = 0;
|
||||||
|
cell.elevation = 0;
|
||||||
|
cell.alive = false;
|
||||||
|
}
|
||||||
|
} else if (cell.transitioning) {
|
||||||
|
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
|
||||||
|
cell.alive = false;
|
||||||
|
cell.transitioning = false;
|
||||||
|
cell.transitionComplete = true;
|
||||||
|
cell.opacity = 0;
|
||||||
|
cell.scale = 0;
|
||||||
|
cell.elevation = 0;
|
||||||
|
} else if (cell.next && !cell.alive && !cell.transitionComplete) {
|
||||||
|
cell.alive = true;
|
||||||
|
cell.transitioning = false;
|
||||||
|
cell.transitionComplete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.rippleStartTime > 0) {
|
||||||
|
const elapsedTime = Date.now() - cell.rippleStartTime;
|
||||||
|
if (elapsedTime > 0) {
|
||||||
|
const rippleProgress = elapsedTime / 1000;
|
||||||
|
|
||||||
|
if (rippleProgress < 1) {
|
||||||
|
const wavePhase = rippleProgress * Math.PI * 2;
|
||||||
|
const waveHeight =
|
||||||
|
Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
|
||||||
|
|
||||||
|
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
|
||||||
|
cell.rippleEffect = waveHeight;
|
||||||
|
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
|
||||||
|
} else {
|
||||||
|
cell.rippleEffect = waveHeight * 0.3;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cell.rippleEffect = 0;
|
||||||
|
cell.rippleStartTime = 0;
|
||||||
|
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): void {
|
||||||
|
if (!this.grid) return;
|
||||||
|
|
||||||
|
const grid = this.grid;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
const displayCellSize = cellSize * 0.8;
|
||||||
|
const roundness = displayCellSize * 0.2;
|
||||||
|
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
if (
|
||||||
|
(cell.alive || cell.targetOpacity > 0) &&
|
||||||
|
cell.opacity > 0.01
|
||||||
|
) {
|
||||||
|
const [r, g, b] = cell.color;
|
||||||
|
|
||||||
|
ctx.globalAlpha = cell.opacity * 0.9;
|
||||||
|
|
||||||
|
const scaledSize = displayCellSize * cell.scale;
|
||||||
|
const xOffset = (displayCellSize - scaledSize) / 2;
|
||||||
|
const yOffset = (displayCellSize - scaledSize) / 2;
|
||||||
|
|
||||||
|
const elevationOffset = cell.elevation;
|
||||||
|
|
||||||
|
const x =
|
||||||
|
grid.offsetX +
|
||||||
|
i * cellSize +
|
||||||
|
(cellSize - displayCellSize) / 2 +
|
||||||
|
xOffset;
|
||||||
|
const y =
|
||||||
|
grid.offsetY +
|
||||||
|
j * cellSize +
|
||||||
|
(cellSize - displayCellSize) / 2 +
|
||||||
|
yOffset -
|
||||||
|
elevationOffset;
|
||||||
|
const scaledRoundness = roundness * cell.scale;
|
||||||
|
|
||||||
|
// Shadow for 3D effect
|
||||||
|
if (elevationOffset > 0.5) {
|
||||||
|
ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
|
||||||
|
ctx.lineTo(
|
||||||
|
x + scaledSize - scaledRoundness,
|
||||||
|
y + elevationOffset * 1.1
|
||||||
|
);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y + elevationOffset * 1.1,
|
||||||
|
x + scaledSize,
|
||||||
|
y + elevationOffset * 1.1 + scaledRoundness
|
||||||
|
);
|
||||||
|
ctx.lineTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
|
||||||
|
);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize,
|
||||||
|
x + scaledSize - scaledRoundness,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize
|
||||||
|
);
|
||||||
|
ctx.lineTo(
|
||||||
|
x + scaledRoundness,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize
|
||||||
|
);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize,
|
||||||
|
x,
|
||||||
|
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
|
||||||
|
);
|
||||||
|
ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x,
|
||||||
|
y + elevationOffset * 1.1,
|
||||||
|
x + scaledRoundness,
|
||||||
|
y + elevationOffset * 1.1
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main cell
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + scaledRoundness, y);
|
||||||
|
ctx.lineTo(x + scaledSize - scaledRoundness, y);
|
||||||
|
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
|
||||||
|
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y + scaledSize,
|
||||||
|
x + scaledSize - scaledRoundness,
|
||||||
|
y + scaledSize
|
||||||
|
);
|
||||||
|
ctx.lineTo(x + scaledRoundness, y + scaledSize);
|
||||||
|
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
|
||||||
|
ctx.lineTo(x, y + scaledRoundness);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Highlight on elevated cells
|
||||||
|
if (elevationOffset > 0.5) {
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + scaledRoundness, y);
|
||||||
|
ctx.lineTo(x + scaledSize - scaledRoundness, y);
|
||||||
|
ctx.quadraticCurveTo(
|
||||||
|
x + scaledSize,
|
||||||
|
y,
|
||||||
|
x + scaledSize,
|
||||||
|
y + scaledRoundness
|
||||||
|
);
|
||||||
|
ctx.lineTo(x + scaledSize, y + scaledSize / 3);
|
||||||
|
ctx.lineTo(x, y + scaledSize / 3);
|
||||||
|
ctx.lineTo(x, y + scaledRoundness);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.canvasWidth = width;
|
||||||
|
this.canvasHeight = height;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
if (
|
||||||
|
!this.grid ||
|
||||||
|
this.grid.cols !== Math.floor(width / cellSize) ||
|
||||||
|
this.grid.rows !== Math.floor(height / cellSize)
|
||||||
|
) {
|
||||||
|
for (const id of this.pendingTimeouts) {
|
||||||
|
clearTimeout(id);
|
||||||
|
}
|
||||||
|
this.pendingTimeouts = [];
|
||||||
|
this.grid = this.initGrid(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
this.mouseIsDown = isDown;
|
||||||
|
|
||||||
|
if (isDown && this.grid && !this.exiting) {
|
||||||
|
const grid = this.grid;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
||||||
|
const cellY = Math.floor((y - grid.offsetY) / cellSize);
|
||||||
|
|
||||||
|
if (cellX !== this.mouseCellX || cellY !== this.mouseCellY) {
|
||||||
|
this.mouseCellX = cellX;
|
||||||
|
this.mouseCellY = cellY;
|
||||||
|
|
||||||
|
if (
|
||||||
|
cellX >= 0 &&
|
||||||
|
cellX < grid.cols &&
|
||||||
|
cellY >= 0 &&
|
||||||
|
cellY < grid.rows
|
||||||
|
) {
|
||||||
|
const cell = grid.cells[cellX][cellY];
|
||||||
|
if (!cell.alive && !cell.transitioning) {
|
||||||
|
this.spawnCellAtPosition(grid, cellX, cellY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
this.mouseIsDown = true;
|
||||||
|
|
||||||
|
if (!this.grid || this.exiting) return;
|
||||||
|
const grid = this.grid;
|
||||||
|
const cellSize = this.getCellSize();
|
||||||
|
|
||||||
|
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
||||||
|
const cellY = Math.floor((y - grid.offsetY) / cellSize);
|
||||||
|
|
||||||
|
if (
|
||||||
|
cellX >= 0 &&
|
||||||
|
cellX < grid.cols &&
|
||||||
|
cellY >= 0 &&
|
||||||
|
cellY < grid.rows
|
||||||
|
) {
|
||||||
|
this.mouseCellX = cellX;
|
||||||
|
this.mouseCellY = cellY;
|
||||||
|
|
||||||
|
const cell = grid.cells[cellX][cellY];
|
||||||
|
if (cell.alive) {
|
||||||
|
this.createRippleEffect(grid, cellX, cellY);
|
||||||
|
} else {
|
||||||
|
this.spawnCellAtPosition(grid, cellX, cellY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {
|
||||||
|
this.mouseIsDown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseIsDown = false;
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], bgColor: string): void {
|
||||||
|
this.palette = palette;
|
||||||
|
this.bgColor = bgColor;
|
||||||
|
|
||||||
|
if (this.grid) {
|
||||||
|
const grid = this.grid;
|
||||||
|
for (let i = 0; i < grid.cols; i++) {
|
||||||
|
for (let j = 0; j < grid.rows; j++) {
|
||||||
|
const cell = grid.cells[i][j];
|
||||||
|
if (cell.alive && cell.opacity > 0.01) {
|
||||||
|
cell.baseColor = palette[(i * grid.rows + j) % palette.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
520
src/components/background/engines/lava-lamp.ts
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
interface Blob {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
baseRadius: number;
|
||||||
|
radiusScale: number;
|
||||||
|
targetRadiusScale: number;
|
||||||
|
color: [number, number, number];
|
||||||
|
targetColor: [number, number, number];
|
||||||
|
phase: number;
|
||||||
|
phaseSpeed: number;
|
||||||
|
staggerDelay: number; // -1 means already revealed
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOB_COUNT = 26;
|
||||||
|
const BASE_MAX_BLOBS = 80; // at 1080p; scales with canvas area
|
||||||
|
const MIN_SPEED = 0.1;
|
||||||
|
const MAX_SPEED = 0.35;
|
||||||
|
const RESOLUTION_SCALE = 5; // render at 1/5 resolution (was 1/4)
|
||||||
|
const FIELD_THRESHOLD = 1.0;
|
||||||
|
const SMOOTHSTEP_RANGE = 0.25;
|
||||||
|
const MOUSE_REPEL_RADIUS = 150;
|
||||||
|
const MOUSE_REPEL_FORCE = 0.2;
|
||||||
|
const COLOR_LERP_SPEED = 0.02;
|
||||||
|
const DRIFT_AMPLITUDE = 0.2;
|
||||||
|
const RADIUS_LERP_SPEED = 0.06;
|
||||||
|
const STAGGER_INTERVAL = 60;
|
||||||
|
const CYCLE_MIN_MS = 2000; // min time between natural spawn/despawn
|
||||||
|
const CYCLE_MAX_MS = 5000; // max time
|
||||||
|
|
||||||
|
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||||
|
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LavaLampEngine implements AnimationEngine {
|
||||||
|
id = "lava-lamp";
|
||||||
|
name = "Lava Lamp";
|
||||||
|
|
||||||
|
private blobs: Blob[] = [];
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private bgRgb: [number, number, number] = [0, 0, 0];
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private offCanvas: HTMLCanvasElement | null = null;
|
||||||
|
private offCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
private shadowCanvas: HTMLCanvasElement | null = null;
|
||||||
|
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
private elapsed = 0;
|
||||||
|
private nextCycleTime = 0;
|
||||||
|
private exiting = false;
|
||||||
|
|
||||||
|
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
|
||||||
|
private blobX: Float64Array = new Float64Array(0);
|
||||||
|
private blobY: Float64Array = new Float64Array(0);
|
||||||
|
private blobR: Float64Array = new Float64Array(0);
|
||||||
|
private blobCR: Float64Array = new Float64Array(0);
|
||||||
|
private blobCG: Float64Array = new Float64Array(0);
|
||||||
|
private blobCB: Float64Array = new Float64Array(0);
|
||||||
|
private activeBlobCount = 0;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
bgColor: string
|
||||||
|
): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.palette = palette;
|
||||||
|
this.parseBgColor(bgColor);
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||||
|
this.initBlobs();
|
||||||
|
this.initOffscreenCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBgColor(bgColor: string): void {
|
||||||
|
const match = bgColor.match(/(\d+)/g);
|
||||||
|
if (match && match.length >= 3) {
|
||||||
|
this.bgRgb = [parseInt(match[0]), parseInt(match[1]), parseInt(match[2])];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxBlobs(): number {
|
||||||
|
const area = this.width * this.height;
|
||||||
|
const scale = area / 2_073_600; // normalize to 1080p
|
||||||
|
return Math.max(BASE_MAX_BLOBS, Math.round(BASE_MAX_BLOBS * scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRadiusRange(): { min: number; max: number } {
|
||||||
|
const area = this.width * this.height;
|
||||||
|
const scale = Math.sqrt(area / 2_073_600);
|
||||||
|
const min = Math.max(8, Math.round(25 * scale));
|
||||||
|
const max = Math.max(15, Math.round(65 * scale));
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeBlob(x: number, y: number, radiusOverride?: number): Blob {
|
||||||
|
const { min, max } = this.getRadiusRange();
|
||||||
|
const color = this.palette[
|
||||||
|
Math.floor(Math.random() * this.palette.length)
|
||||||
|
] || [128, 128, 128];
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: (Math.random() - 0.5) * 2 * MAX_SPEED,
|
||||||
|
vy: (Math.random() - 0.5) * 2 * MAX_SPEED,
|
||||||
|
baseRadius: radiusOverride ?? (min + Math.random() * (max - min)),
|
||||||
|
radiusScale: 0,
|
||||||
|
targetRadiusScale: 1,
|
||||||
|
color: [...color],
|
||||||
|
targetColor: [...color],
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
phaseSpeed: 0.0005 + Math.random() * 0.001,
|
||||||
|
staggerDelay: -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private initBlobs(): void {
|
||||||
|
this.blobs = [];
|
||||||
|
const { max } = this.getRadiusRange();
|
||||||
|
const minDist = max * 2.5; // minimum distance between blob centers
|
||||||
|
|
||||||
|
for (let i = 0; i < BLOB_COUNT; i++) {
|
||||||
|
let x: number, y: number;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
// Try to find a position that doesn't overlap existing blobs
|
||||||
|
do {
|
||||||
|
x = Math.random() * this.width;
|
||||||
|
y = Math.random() * this.height;
|
||||||
|
attempts++;
|
||||||
|
} while (attempts < 50 && this.tooCloseToExisting(x, y, minDist));
|
||||||
|
|
||||||
|
const blob = this.makeBlob(x, y);
|
||||||
|
blob.targetRadiusScale = 0;
|
||||||
|
blob.staggerDelay = i * STAGGER_INTERVAL + Math.random() * STAGGER_INTERVAL;
|
||||||
|
this.blobs.push(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tooCloseToExisting(x: number, y: number, minDist: number): boolean {
|
||||||
|
for (const blob of this.blobs) {
|
||||||
|
const dx = blob.x - x;
|
||||||
|
const dy = blob.y - y;
|
||||||
|
if (dx * dx + dy * dy < minDist * minDist) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initOffscreenCanvas(): void {
|
||||||
|
const rw = Math.ceil(this.width / RESOLUTION_SCALE);
|
||||||
|
const rh = Math.ceil(this.height / RESOLUTION_SCALE);
|
||||||
|
|
||||||
|
this.offCanvas = document.createElement("canvas");
|
||||||
|
this.offCanvas.width = rw;
|
||||||
|
this.offCanvas.height = rh;
|
||||||
|
this.offCtx = this.offCanvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
|
||||||
|
this.shadowCanvas = document.createElement("canvas");
|
||||||
|
this.shadowCanvas.width = rw;
|
||||||
|
this.shadowCanvas.height = rh;
|
||||||
|
this.shadowCtx = this.shadowCanvas.getContext("2d", {
|
||||||
|
willReadFrequently: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.blobs.length; i++) {
|
||||||
|
const blob = this.blobs[i];
|
||||||
|
if (blob.staggerDelay >= 0) {
|
||||||
|
blob.staggerDelay = -1;
|
||||||
|
}
|
||||||
|
// Stagger the shrink over ~2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
blob.targetRadiusScale = 0;
|
||||||
|
}, Math.random() * 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
return this.blobs.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.blobs = [];
|
||||||
|
this.offCanvas = null;
|
||||||
|
this.offCtx = null;
|
||||||
|
this.shadowCanvas = null;
|
||||||
|
this.shadowCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot active blob data into flat typed arrays for fast inner-loop access */
|
||||||
|
private syncBlobArrays(): void {
|
||||||
|
const blobs = this.blobs;
|
||||||
|
const n = blobs.length;
|
||||||
|
|
||||||
|
// Grow arrays if needed
|
||||||
|
if (this.blobX.length < n) {
|
||||||
|
const cap = n + 32;
|
||||||
|
this.blobX = new Float64Array(cap);
|
||||||
|
this.blobY = new Float64Array(cap);
|
||||||
|
this.blobR = new Float64Array(cap);
|
||||||
|
this.blobCR = new Float64Array(cap);
|
||||||
|
this.blobCG = new Float64Array(cap);
|
||||||
|
this.blobCB = new Float64Array(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const b = blobs[i];
|
||||||
|
const r = b.baseRadius * b.radiusScale;
|
||||||
|
if (r < 1) continue; // skip invisible blobs entirely
|
||||||
|
this.blobX[count] = b.x;
|
||||||
|
this.blobY[count] = b.y;
|
||||||
|
this.blobR[count] = r;
|
||||||
|
this.blobCR[count] = b.color[0];
|
||||||
|
this.blobCG[count] = b.color[1];
|
||||||
|
this.blobCB[count] = b.color[2];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
this.activeBlobCount = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
const dt = deltaTime / (1000 / 60);
|
||||||
|
this.elapsed += deltaTime;
|
||||||
|
|
||||||
|
for (const blob of this.blobs) {
|
||||||
|
// Staggered load-in
|
||||||
|
if (blob.staggerDelay >= 0) {
|
||||||
|
if (this.elapsed >= blob.staggerDelay) {
|
||||||
|
blob.targetRadiusScale = 1;
|
||||||
|
blob.staggerDelay = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blob.radiusScale +=
|
||||||
|
(blob.targetRadiusScale - blob.radiusScale) * RADIUS_LERP_SPEED * dt;
|
||||||
|
|
||||||
|
blob.phase += blob.phaseSpeed * deltaTime;
|
||||||
|
const driftX = Math.sin(blob.phase) * DRIFT_AMPLITUDE;
|
||||||
|
const driftY = Math.cos(blob.phase * 0.7) * DRIFT_AMPLITUDE;
|
||||||
|
|
||||||
|
blob.vx += driftX * dt * 0.01;
|
||||||
|
blob.vy += driftY * dt * 0.01;
|
||||||
|
blob.vx += (Math.random() - 0.5) * 0.008 * dt;
|
||||||
|
blob.vy += (Math.random() - 0.5) * 0.008 * dt;
|
||||||
|
|
||||||
|
const speed = Math.sqrt(blob.vx * blob.vx + blob.vy * blob.vy);
|
||||||
|
if (speed > MAX_SPEED) {
|
||||||
|
blob.vx = (blob.vx / speed) * MAX_SPEED;
|
||||||
|
blob.vy = (blob.vy / speed) * MAX_SPEED;
|
||||||
|
}
|
||||||
|
if (speed < MIN_SPEED) {
|
||||||
|
const angle = Math.atan2(blob.vy, blob.vx);
|
||||||
|
blob.vx = Math.cos(angle) * MIN_SPEED;
|
||||||
|
blob.vy = Math.sin(angle) * MIN_SPEED;
|
||||||
|
}
|
||||||
|
|
||||||
|
blob.x += blob.vx * dt;
|
||||||
|
blob.y += blob.vy * dt;
|
||||||
|
|
||||||
|
const pad = blob.baseRadius * 0.3;
|
||||||
|
if (blob.x < -pad) { blob.x = -pad; blob.vx = Math.abs(blob.vx) * 0.8; }
|
||||||
|
if (blob.x > this.width + pad) { blob.x = this.width + pad; blob.vx = -Math.abs(blob.vx) * 0.8; }
|
||||||
|
if (blob.y < -pad) { blob.y = -pad; blob.vy = Math.abs(blob.vy) * 0.8; }
|
||||||
|
if (blob.y > this.height + pad) { blob.y = this.height + pad; blob.vy = -Math.abs(blob.vy) * 0.8; }
|
||||||
|
|
||||||
|
// Mouse repulsion
|
||||||
|
const dx = blob.x - this.mouseX;
|
||||||
|
const dy = blob.y - this.mouseY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < MOUSE_REPEL_RADIUS && dist > 0) {
|
||||||
|
const force = (1 - dist / MOUSE_REPEL_RADIUS) * MOUSE_REPEL_FORCE * dt;
|
||||||
|
blob.vx += (dx / dist) * force;
|
||||||
|
blob.vy += (dy / dist) * force;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
blob.color[c] += (blob.targetColor[c] - blob.color[c]) * COLOR_LERP_SPEED * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove blobs that have fully shrunk away (but not ones still waiting to stagger in)
|
||||||
|
for (let i = this.blobs.length - 1; i >= 0; i--) {
|
||||||
|
const b = this.blobs[i];
|
||||||
|
if (b.targetRadiusScale === 0 && b.radiusScale < 0.01 && b.staggerDelay < 0) {
|
||||||
|
this.blobs.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Natural spawn/despawn cycle — keeps the scene alive
|
||||||
|
if (!this.exiting && this.elapsed >= this.nextCycleTime) {
|
||||||
|
// Pick a random visible blob to fade out (skip ones still staggering in)
|
||||||
|
const visible = [];
|
||||||
|
for (let i = 0; i < this.blobs.length; i++) {
|
||||||
|
if (this.blobs[i].radiusScale > 0.5 && this.blobs[i].staggerDelay < 0) {
|
||||||
|
visible.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visible.length > 0) {
|
||||||
|
const killIdx = visible[Math.floor(Math.random() * visible.length)];
|
||||||
|
this.blobs[killIdx].targetRadiusScale = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn a fresh one at a random position
|
||||||
|
const blob = this.makeBlob(
|
||||||
|
Math.random() * this.width,
|
||||||
|
Math.random() * this.height
|
||||||
|
);
|
||||||
|
this.blobs.push(blob);
|
||||||
|
|
||||||
|
// Schedule next cycle
|
||||||
|
this.nextCycleTime = this.elapsed + CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune excess blobs (keep the initial set, drop oldest user-spawned ones)
|
||||||
|
const maxBlobs = this.getMaxBlobs();
|
||||||
|
if (this.blobs.length > maxBlobs) {
|
||||||
|
this.blobs.splice(BLOB_COUNT, this.blobs.length - maxBlobs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): void {
|
||||||
|
if (!this.offCtx || !this.offCanvas || !this.shadowCtx || !this.shadowCanvas)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Snapshot blob positions/radii into typed arrays for fast pixel loop
|
||||||
|
this.syncBlobArrays();
|
||||||
|
|
||||||
|
const rw = this.offCanvas.width;
|
||||||
|
const rh = this.offCanvas.height;
|
||||||
|
|
||||||
|
// Render shadow layer
|
||||||
|
const shadowData = this.shadowCtx.createImageData(rw, rh);
|
||||||
|
this.renderField(shadowData, rw, rh, true);
|
||||||
|
this.shadowCtx.putImageData(shadowData, 0, 0);
|
||||||
|
|
||||||
|
// Render main layer
|
||||||
|
const imageData = this.offCtx.createImageData(rw, rh);
|
||||||
|
this.renderField(imageData, rw, rh, false);
|
||||||
|
this.offCtx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = "medium";
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.2;
|
||||||
|
ctx.drawImage(this.shadowCanvas, 0, 4, width, height);
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(this.offCanvas, 0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderField(
|
||||||
|
imageData: ImageData,
|
||||||
|
rw: number,
|
||||||
|
rh: number,
|
||||||
|
isShadow: boolean
|
||||||
|
): void {
|
||||||
|
const data = imageData.data;
|
||||||
|
const threshold = isShadow ? FIELD_THRESHOLD * 0.75 : FIELD_THRESHOLD;
|
||||||
|
const bgR = this.bgRgb[0];
|
||||||
|
const bgG = this.bgRgb[1];
|
||||||
|
const bgB = this.bgRgb[2];
|
||||||
|
const scale = RESOLUTION_SCALE;
|
||||||
|
const n = this.activeBlobCount;
|
||||||
|
const bx = this.blobX;
|
||||||
|
const by = this.blobY;
|
||||||
|
const br = this.blobR;
|
||||||
|
const bcr = this.blobCR;
|
||||||
|
const bcg = this.blobCG;
|
||||||
|
const bcb = this.blobCB;
|
||||||
|
const threshLow = threshold - SMOOTHSTEP_RANGE;
|
||||||
|
|
||||||
|
for (let py = 0; py < rh; py++) {
|
||||||
|
const wy = py * scale;
|
||||||
|
for (let px = 0; px < rw; px++) {
|
||||||
|
const wx = px * scale;
|
||||||
|
|
||||||
|
let fieldSum = 0;
|
||||||
|
let weightedR = 0;
|
||||||
|
let weightedG = 0;
|
||||||
|
let weightedB = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const dx = wx - bx[i];
|
||||||
|
const dy = wy - by[i];
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
const ri = br[i];
|
||||||
|
const rSq = ri * ri;
|
||||||
|
// Raw metaball field
|
||||||
|
const raw = rSq / (distSq + rSq * 0.1);
|
||||||
|
// Cap per-blob contribution so color stays flat inside the blob
|
||||||
|
const contribution = raw > 2 ? 2 : raw;
|
||||||
|
|
||||||
|
fieldSum += contribution;
|
||||||
|
|
||||||
|
if (contribution > 0.01) {
|
||||||
|
weightedR += bcr[i] * contribution;
|
||||||
|
weightedG += bcg[i] * contribution;
|
||||||
|
weightedB += bcb[i] * contribution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = (py * rw + px) << 2;
|
||||||
|
|
||||||
|
if (fieldSum > threshLow) {
|
||||||
|
const alpha = smoothstep(threshLow, threshold, fieldSum);
|
||||||
|
|
||||||
|
if (isShadow) {
|
||||||
|
data[idx] = 0;
|
||||||
|
data[idx + 1] = 0;
|
||||||
|
data[idx + 2] = 0;
|
||||||
|
data[idx + 3] = (alpha * 150) | 0;
|
||||||
|
} else {
|
||||||
|
const invField = 1 / fieldSum;
|
||||||
|
const r = Math.min(255, (weightedR * invField) | 0);
|
||||||
|
const g = Math.min(255, (weightedG * invField) | 0);
|
||||||
|
const b = Math.min(255, (weightedB * invField) | 0);
|
||||||
|
|
||||||
|
data[idx] = bgR + (r - bgR) * alpha;
|
||||||
|
data[idx + 1] = bgG + (g - bgG) * alpha;
|
||||||
|
data[idx + 2] = bgB + (b - bgB) * alpha;
|
||||||
|
data[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isShadow) {
|
||||||
|
// data stays 0 (already zeroed by createImageData)
|
||||||
|
} else {
|
||||||
|
data[idx] = bgR;
|
||||||
|
data[idx + 1] = bgG;
|
||||||
|
data[idx + 2] = bgB;
|
||||||
|
data[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.exiting = false;
|
||||||
|
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||||
|
this.initBlobs();
|
||||||
|
this.initOffscreenCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sampleColorAt(x: number, y: number): [number, number, number] | null {
|
||||||
|
let closest: Blob | null = null;
|
||||||
|
let closestDist = Infinity;
|
||||||
|
|
||||||
|
for (const blob of this.blobs) {
|
||||||
|
const dx = blob.x - x;
|
||||||
|
const dy = blob.y - y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < blob.baseRadius * 1.5 && dist < closestDist) {
|
||||||
|
closestDist = dist;
|
||||||
|
closest = blob;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest ? ([...closest.color] as [number, number, number]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnAt(x: number, y: number): void {
|
||||||
|
const { max } = this.getRadiusRange();
|
||||||
|
const blob = this.makeBlob(x, y, max * (0.8 + Math.random() * 0.4));
|
||||||
|
const nearby = this.sampleColorAt(x, y);
|
||||||
|
if (nearby) {
|
||||||
|
blob.color = nearby;
|
||||||
|
blob.targetColor = [...nearby];
|
||||||
|
}
|
||||||
|
this.blobs.push(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.spawnAt(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], bgColor: string): void {
|
||||||
|
this.palette = palette;
|
||||||
|
this.parseBgColor(bgColor);
|
||||||
|
|
||||||
|
for (let i = 0; i < this.blobs.length; i++) {
|
||||||
|
this.blobs[i].targetColor = [
|
||||||
|
...palette[i % palette.length],
|
||||||
|
] as [number, number, number];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
480
src/components/background/engines/pipes.ts
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
|
||||||
|
// --- Directions ---
|
||||||
|
|
||||||
|
type Dir = 0 | 1 | 2 | 3; // up, right, down, left
|
||||||
|
const DX = [0, 1, 0, -1];
|
||||||
|
const DY = [-1, 0, 1, 0];
|
||||||
|
|
||||||
|
// Box-drawing characters
|
||||||
|
const HORIZONTAL = "\u2501"; // ━
|
||||||
|
const VERTICAL = "\u2503"; // ┃
|
||||||
|
// Corner pieces: [oldDir]-[newDir]
|
||||||
|
// oldDir determines entry side (opposite), newDir determines exit side
|
||||||
|
// ┏ = RIGHT+BOTTOM, ┓ = LEFT+BOTTOM, ┗ = RIGHT+TOP, ┛ = LEFT+TOP
|
||||||
|
const CORNER: Record<string, string> = {
|
||||||
|
"0-1": "\u250F", // ┏ enter BOTTOM, exit RIGHT
|
||||||
|
"0-3": "\u2513", // ┓ enter BOTTOM, exit LEFT
|
||||||
|
"1-0": "\u251B", // ┛ enter LEFT, exit TOP
|
||||||
|
"1-2": "\u2513", // ┓ enter LEFT, exit BOTTOM
|
||||||
|
"2-1": "\u2517", // ┗ enter TOP, exit RIGHT
|
||||||
|
"2-3": "\u251B", // ┛ enter TOP, exit LEFT
|
||||||
|
"3-0": "\u2517", // ┗ enter RIGHT, exit TOP
|
||||||
|
"3-2": "\u250F", // ┏ enter RIGHT, exit BOTTOM
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStraightChar(dir: Dir): string {
|
||||||
|
return dir === 0 || dir === 2 ? VERTICAL : HORIZONTAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCornerChar(fromDir: Dir, toDir: Dir): string {
|
||||||
|
return CORNER[`${fromDir}-${toDir}`] || HORIZONTAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grid Cell ---
|
||||||
|
|
||||||
|
interface PipeCell {
|
||||||
|
char: string;
|
||||||
|
pipeId: number;
|
||||||
|
placedAt: number;
|
||||||
|
color: [number, number, number];
|
||||||
|
baseColor: [number, number, number];
|
||||||
|
opacity: number;
|
||||||
|
elevation: number;
|
||||||
|
targetElevation: number;
|
||||||
|
fadeOut: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Active Pipe ---
|
||||||
|
|
||||||
|
interface ActivePipe {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
dir: Dir;
|
||||||
|
color: [number, number, number];
|
||||||
|
spawnDelay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
|
||||||
|
const CELL_SIZE_DESKTOP = 20;
|
||||||
|
const CELL_SIZE_MOBILE = 14;
|
||||||
|
const MAX_ACTIVE_PIPES = 4;
|
||||||
|
const GROW_INTERVAL = 80;
|
||||||
|
const TURN_CHANCE = 0.3;
|
||||||
|
const TARGET_FPS = 60;
|
||||||
|
const PIPE_LIFETIME = 12_000; // ms before a pipe's segments start fading
|
||||||
|
const FADE_IN_SPEED = 0.06;
|
||||||
|
const FADE_OUT_SPEED = 0.02;
|
||||||
|
|
||||||
|
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||||
|
const ELEVATION_FACTOR = 6;
|
||||||
|
const ELEVATION_LERP_SPEED = 0.05;
|
||||||
|
const COLOR_SHIFT_AMOUNT = 30;
|
||||||
|
const SHADOW_OFFSET_RATIO = 1.1;
|
||||||
|
|
||||||
|
const BURST_PIPE_COUNT = 4;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function range(a: number, b: number): number {
|
||||||
|
return (b - a) * Math.random() + a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Engine ---
|
||||||
|
|
||||||
|
export class PipesEngine implements AnimationEngine {
|
||||||
|
id = "pipes";
|
||||||
|
name = "Pipes";
|
||||||
|
|
||||||
|
private grid: (PipeCell | null)[][] = [];
|
||||||
|
private cols = 0;
|
||||||
|
private rows = 0;
|
||||||
|
private activePipes: ActivePipe[] = [];
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private cellSize = CELL_SIZE_DESKTOP;
|
||||||
|
private fontSize = CELL_SIZE_DESKTOP;
|
||||||
|
private font = `bold ${CELL_SIZE_DESKTOP}px monospace`;
|
||||||
|
private mouseX = -1000;
|
||||||
|
private mouseY = -1000;
|
||||||
|
private elapsed = 0;
|
||||||
|
private growTimer = 0;
|
||||||
|
private exiting = false;
|
||||||
|
private nextPipeId = 0;
|
||||||
|
private offsetX = 0;
|
||||||
|
private offsetY = 0;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
_bgColor: string
|
||||||
|
): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.palette = palette;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.growTimer = 0;
|
||||||
|
this.exiting = false;
|
||||||
|
this.computeGrid();
|
||||||
|
this.spawnInitialPipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeGrid(): void {
|
||||||
|
this.cellSize = this.width <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
|
||||||
|
this.fontSize = this.cellSize;
|
||||||
|
this.font = `bold ${this.fontSize}px monospace`;
|
||||||
|
this.cols = Math.floor(this.width / this.cellSize);
|
||||||
|
this.rows = Math.floor(this.height / this.cellSize);
|
||||||
|
this.offsetX = Math.floor((this.width - this.cols * this.cellSize) / 2);
|
||||||
|
this.offsetY = Math.floor((this.height - this.rows * this.cellSize) / 2);
|
||||||
|
this.grid = Array.from({ length: this.cols }, () =>
|
||||||
|
Array.from({ length: this.rows }, () => null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomColor(): [number, number, number] {
|
||||||
|
// Prefer bright variants (second half of palette) if available
|
||||||
|
const brightStart = Math.floor(this.palette.length / 2);
|
||||||
|
if (brightStart > 0 && this.palette.length > brightStart) {
|
||||||
|
return this.palette[brightStart + Math.floor(Math.random() * (this.palette.length - brightStart))];
|
||||||
|
}
|
||||||
|
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnInitialPipes(): void {
|
||||||
|
this.activePipes = [];
|
||||||
|
for (let i = 0; i < MAX_ACTIVE_PIPES; i++) {
|
||||||
|
this.activePipes.push(this.makeEdgePipe(i * 400));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeEdgePipe(delay: number): ActivePipe {
|
||||||
|
const color = this.randomColor();
|
||||||
|
// Pick a random edge and inward-facing direction
|
||||||
|
const edge = Math.floor(Math.random() * 4) as Dir;
|
||||||
|
let x: number, y: number, dir: Dir;
|
||||||
|
|
||||||
|
switch (edge) {
|
||||||
|
case 0: // top edge, face down
|
||||||
|
x = Math.floor(Math.random() * this.cols);
|
||||||
|
y = 0;
|
||||||
|
dir = 2;
|
||||||
|
break;
|
||||||
|
case 1: // right edge, face left
|
||||||
|
x = this.cols - 1;
|
||||||
|
y = Math.floor(Math.random() * this.rows);
|
||||||
|
dir = 3;
|
||||||
|
break;
|
||||||
|
case 2: // bottom edge, face up
|
||||||
|
x = Math.floor(Math.random() * this.cols);
|
||||||
|
y = this.rows - 1;
|
||||||
|
dir = 0;
|
||||||
|
break;
|
||||||
|
default: // left edge, face right
|
||||||
|
x = 0;
|
||||||
|
y = Math.floor(Math.random() * this.rows);
|
||||||
|
dir = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: this.nextPipeId++, x, y, dir, color: [...color], spawnDelay: delay };
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeSegment(x: number, y: number, char: string, color: [number, number, number], pipeId: number): void {
|
||||||
|
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return;
|
||||||
|
this.grid[x][y] = {
|
||||||
|
char,
|
||||||
|
pipeId,
|
||||||
|
placedAt: this.elapsed,
|
||||||
|
color: [...color],
|
||||||
|
baseColor: [...color],
|
||||||
|
opacity: 0,
|
||||||
|
elevation: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
fadeOut: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOccupied(x: number, y: number): boolean {
|
||||||
|
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return true;
|
||||||
|
return this.grid[x][y] !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickTurn(currentDir: Dir): Dir {
|
||||||
|
// Turn left or right relative to current direction
|
||||||
|
const leftDir = ((currentDir + 3) % 4) as Dir;
|
||||||
|
const rightDir = ((currentDir + 1) % 4) as Dir;
|
||||||
|
return Math.random() < 0.5 ? leftDir : rightDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private growPipe(pipe: ActivePipe): boolean {
|
||||||
|
// Decide direction
|
||||||
|
let newDir = pipe.dir;
|
||||||
|
let turned = false;
|
||||||
|
if (Math.random() < TURN_CHANCE) {
|
||||||
|
newDir = this.pickTurn(pipe.dir);
|
||||||
|
turned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nx = pipe.x + DX[newDir];
|
||||||
|
const ny = pipe.y + DY[newDir];
|
||||||
|
|
||||||
|
// Check if destination is valid
|
||||||
|
if (this.isOccupied(nx, ny)) {
|
||||||
|
// If we tried to turn, try going straight instead
|
||||||
|
if (turned) {
|
||||||
|
const sx = pipe.x + DX[pipe.dir];
|
||||||
|
const sy = pipe.y + DY[pipe.dir];
|
||||||
|
if (!this.isOccupied(sx, sy)) {
|
||||||
|
// Continue straight — place straight piece at destination
|
||||||
|
this.placeSegment(sx, sy, getStraightChar(pipe.dir), pipe.color, pipe.id);
|
||||||
|
pipe.x = sx;
|
||||||
|
pipe.y = sy;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false; // dead end
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turned) {
|
||||||
|
// Replace current head cell with corner piece (turn happens HERE)
|
||||||
|
const cell = this.grid[pipe.x]?.[pipe.y];
|
||||||
|
if (cell) {
|
||||||
|
cell.char = getCornerChar(pipe.dir, newDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place straight piece at destination
|
||||||
|
this.placeSegment(nx, ny, getStraightChar(newDir), pipe.color, pipe.id);
|
||||||
|
pipe.dir = newDir;
|
||||||
|
pipe.x = nx;
|
||||||
|
pipe.y = ny;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interface Methods ---
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
this.exiting = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.cols; i++) {
|
||||||
|
for (let j = 0; j < this.rows; j++) {
|
||||||
|
const cell = this.grid[i][j];
|
||||||
|
if (cell) {
|
||||||
|
setTimeout(() => {
|
||||||
|
cell.fadeOut = true;
|
||||||
|
}, Math.random() * 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
if (!this.exiting) return false;
|
||||||
|
for (let i = 0; i < this.cols; i++) {
|
||||||
|
for (let j = 0; j < this.rows; j++) {
|
||||||
|
const cell = this.grid[i][j];
|
||||||
|
if (cell && cell.opacity > 0.01) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.grid = [];
|
||||||
|
this.activePipes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
const dt = deltaTime / (1000 / TARGET_FPS);
|
||||||
|
this.elapsed += deltaTime;
|
||||||
|
|
||||||
|
// Grow pipes
|
||||||
|
if (!this.exiting) {
|
||||||
|
this.growTimer += deltaTime;
|
||||||
|
while (this.growTimer >= GROW_INTERVAL) {
|
||||||
|
this.growTimer -= GROW_INTERVAL;
|
||||||
|
|
||||||
|
for (let i = this.activePipes.length - 1; i >= 0; i--) {
|
||||||
|
const pipe = this.activePipes[i];
|
||||||
|
|
||||||
|
if (pipe.spawnDelay > 0) {
|
||||||
|
pipe.spawnDelay -= GROW_INTERVAL;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place starting segment if this is the first step
|
||||||
|
if (!this.isOccupied(pipe.x, pipe.y)) {
|
||||||
|
this.placeSegment(pipe.x, pipe.y, getStraightChar(pipe.dir), pipe.color, pipe.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.growPipe(pipe)) {
|
||||||
|
// Pipe is dead, replace it
|
||||||
|
this.activePipes[i] = this.makeEdgePipe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cells: fade in/out, mouse influence
|
||||||
|
const mouseX = this.mouseX;
|
||||||
|
const mouseY = this.mouseY;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.cols; i++) {
|
||||||
|
for (let j = 0; j < this.rows; j++) {
|
||||||
|
const cell = this.grid[i][j];
|
||||||
|
if (!cell) continue;
|
||||||
|
|
||||||
|
// Age-based fade: old segments start dissolving
|
||||||
|
if (!cell.fadeOut && !this.exiting && this.elapsed - cell.placedAt > PIPE_LIFETIME) {
|
||||||
|
cell.fadeOut = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in/out
|
||||||
|
if (cell.fadeOut) {
|
||||||
|
cell.opacity -= FADE_OUT_SPEED * dt;
|
||||||
|
if (cell.opacity <= 0) {
|
||||||
|
cell.opacity = 0;
|
||||||
|
this.grid[i][j] = null; // free the cell for new pipes
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (cell.opacity < 1) {
|
||||||
|
cell.opacity = Math.min(1, cell.opacity + FADE_IN_SPEED * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse influence
|
||||||
|
const cx = this.offsetX + i * this.cellSize + this.cellSize / 2;
|
||||||
|
const cy = this.offsetY + j * this.cellSize + this.cellSize / 2;
|
||||||
|
const dx = cx - mouseX;
|
||||||
|
const dy = cy - mouseY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
|
||||||
|
const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2));
|
||||||
|
cell.targetElevation = ELEVATION_FACTOR * inf * inf;
|
||||||
|
|
||||||
|
const shift = inf * COLOR_SHIFT_AMOUNT * 0.5;
|
||||||
|
cell.color = [
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[0] + shift)),
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[1] + shift)),
|
||||||
|
Math.min(255, Math.max(0, cell.baseColor[2] + shift)),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
cell.targetElevation = 0;
|
||||||
|
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
|
||||||
|
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
|
||||||
|
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.elevation +=
|
||||||
|
(cell.targetElevation - cell.elevation) * ELEVATION_LERP_SPEED * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
_width: number,
|
||||||
|
_height: number
|
||||||
|
): void {
|
||||||
|
ctx.font = this.font;
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
|
||||||
|
for (let i = 0; i < this.cols; i++) {
|
||||||
|
for (let j = 0; j < this.rows; j++) {
|
||||||
|
const cell = this.grid[i][j];
|
||||||
|
if (!cell || cell.opacity <= 0.01) continue;
|
||||||
|
|
||||||
|
const x = this.offsetX + i * this.cellSize;
|
||||||
|
const y = this.offsetY + j * this.cellSize - cell.elevation;
|
||||||
|
const [r, g, b] = cell.color;
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
if (cell.elevation > 0.5) {
|
||||||
|
const shadowAlpha = 0.2 * (cell.elevation / ELEVATION_FACTOR) * cell.opacity;
|
||||||
|
ctx.globalAlpha = shadowAlpha;
|
||||||
|
ctx.fillStyle = "rgb(0,0,0)";
|
||||||
|
ctx.fillText(cell.char, x, y + cell.elevation * SHADOW_OFFSET_RATIO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main
|
||||||
|
ctx.globalAlpha = cell.opacity;
|
||||||
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
ctx.fillText(cell.char, x, y);
|
||||||
|
|
||||||
|
// Highlight
|
||||||
|
if (cell.elevation > 0.5) {
|
||||||
|
const highlightAlpha = 0.1 * (cell.elevation / ELEVATION_FACTOR) * cell.opacity;
|
||||||
|
ctx.globalAlpha = highlightAlpha;
|
||||||
|
ctx.fillStyle = "rgb(255,255,255)";
|
||||||
|
ctx.fillText(cell.char, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.growTimer = 0;
|
||||||
|
this.exiting = false;
|
||||||
|
this.computeGrid();
|
||||||
|
this.spawnInitialPipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||||
|
this.mouseX = x;
|
||||||
|
this.mouseY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
if (this.exiting) return;
|
||||||
|
|
||||||
|
// Convert to grid coords
|
||||||
|
const gx = Math.floor((x - this.offsetX) / this.cellSize);
|
||||||
|
const gy = Math.floor((y - this.offsetY) / this.cellSize);
|
||||||
|
|
||||||
|
// Spawn pipes in all 4 directions from click point
|
||||||
|
for (let d = 0; d < BURST_PIPE_COUNT; d++) {
|
||||||
|
const dir = d as Dir;
|
||||||
|
const color = this.randomColor();
|
||||||
|
this.activePipes.push({
|
||||||
|
id: this.nextPipeId++,
|
||||||
|
x: gx,
|
||||||
|
y: gy,
|
||||||
|
dir,
|
||||||
|
color: [...color],
|
||||||
|
spawnDelay: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
this.mouseX = -1000;
|
||||||
|
this.mouseY = -1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], _bgColor: string): void {
|
||||||
|
this.palette = palette;
|
||||||
|
// Assign by pipeId so all segments of the same pipe get the same color
|
||||||
|
for (let i = 0; i < this.cols; i++) {
|
||||||
|
for (let j = 0; j < this.rows; j++) {
|
||||||
|
const cell = this.grid[i][j];
|
||||||
|
if (cell) {
|
||||||
|
cell.baseColor = palette[cell.pipeId % palette.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
207
src/components/background/engines/shuffle.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
import { GameOfLifeEngine } from "@/components/background/engines/game-of-life";
|
||||||
|
import { LavaLampEngine } from "@/components/background/engines/lava-lamp";
|
||||||
|
import { ConfettiEngine } from "@/components/background/engines/confetti";
|
||||||
|
import { AsciiquariumEngine } from "@/components/background/engines/asciiquarium";
|
||||||
|
import { PipesEngine } from "@/components/background/engines/pipes";
|
||||||
|
|
||||||
|
type ChildId = "game-of-life" | "lava-lamp" | "confetti" | "asciiquarium" | "pipes";
|
||||||
|
|
||||||
|
const CHILD_IDS: ChildId[] = [
|
||||||
|
"game-of-life",
|
||||||
|
"lava-lamp",
|
||||||
|
"confetti",
|
||||||
|
"asciiquarium",
|
||||||
|
"pipes",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLAY_DURATION = 30_000;
|
||||||
|
const STATE_KEY = "shuffle-state";
|
||||||
|
|
||||||
|
interface StoredState {
|
||||||
|
childId: ChildId;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChild(id: ChildId): AnimationEngine {
|
||||||
|
switch (id) {
|
||||||
|
case "game-of-life":
|
||||||
|
return new GameOfLifeEngine();
|
||||||
|
case "lava-lamp":
|
||||||
|
return new LavaLampEngine();
|
||||||
|
case "confetti":
|
||||||
|
return new ConfettiEngine();
|
||||||
|
case "asciiquarium":
|
||||||
|
return new AsciiquariumEngine();
|
||||||
|
case "pipes":
|
||||||
|
return new PipesEngine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDifferent(current: ChildId | null): ChildId {
|
||||||
|
const others = current
|
||||||
|
? CHILD_IDS.filter((id) => id !== current)
|
||||||
|
: CHILD_IDS;
|
||||||
|
return others[Math.floor(Math.random() * others.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(state: StoredState): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): StoredState | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STATE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const state = JSON.parse(raw) as StoredState;
|
||||||
|
if (CHILD_IDS.includes(state.childId)) return state;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShuffleEngine implements AnimationEngine {
|
||||||
|
id = "shuffle";
|
||||||
|
name = "Shuffle";
|
||||||
|
|
||||||
|
private child: AnimationEngine | null = null;
|
||||||
|
private currentChildId: ChildId | null = null;
|
||||||
|
private startedAt = 0;
|
||||||
|
private phase: "playing" | "exiting" = "playing";
|
||||||
|
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private palette: [number, number, number][] = [];
|
||||||
|
private bgColor = "";
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
bgColor: string
|
||||||
|
): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.palette = palette;
|
||||||
|
this.bgColor = bgColor;
|
||||||
|
|
||||||
|
const stored = load();
|
||||||
|
|
||||||
|
if (stored && Date.now() - stored.startedAt < PLAY_DURATION) {
|
||||||
|
// Animation still within its play window — continue it
|
||||||
|
// Covers: Astro nav, sidebar mount, layout switch, quick refresh
|
||||||
|
this.currentChildId = stored.childId;
|
||||||
|
} else {
|
||||||
|
// No recent state (first visit, hard refresh after timer expired) — game-of-life
|
||||||
|
this.currentChildId = "game-of-life";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.startedAt = Date.now();
|
||||||
|
|
||||||
|
this.phase = "playing";
|
||||||
|
this.child = createChild(this.currentChildId);
|
||||||
|
this.child.init(this.width, this.height, this.palette, this.bgColor);
|
||||||
|
save({ childId: this.currentChildId, startedAt: this.startedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
private switchTo(childId: ChildId, startedAt: number): void {
|
||||||
|
if (this.child) this.child.cleanup();
|
||||||
|
this.currentChildId = childId;
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
this.phase = "playing";
|
||||||
|
this.child = createChild(childId);
|
||||||
|
this.child.init(this.width, this.height, this.palette, this.bgColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private advance(): void {
|
||||||
|
// Check if another instance already advanced
|
||||||
|
const stored = load();
|
||||||
|
if (stored && stored.childId !== this.currentChildId) {
|
||||||
|
this.switchTo(stored.childId, stored.startedAt);
|
||||||
|
} else {
|
||||||
|
const next = pickDifferent(this.currentChildId);
|
||||||
|
const now = Date.now();
|
||||||
|
save({ childId: next, startedAt: now });
|
||||||
|
this.switchTo(next, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number): void {
|
||||||
|
if (!this.child) return;
|
||||||
|
|
||||||
|
// Sync: if another instance (sidebar, tab) switched, follow
|
||||||
|
const stored = load();
|
||||||
|
if (stored && stored.childId !== this.currentChildId) {
|
||||||
|
this.switchTo(stored.childId, stored.startedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.child.update(deltaTime);
|
||||||
|
|
||||||
|
const elapsed = Date.now() - this.startedAt;
|
||||||
|
|
||||||
|
if (this.phase === "playing" && elapsed >= PLAY_DURATION) {
|
||||||
|
this.child.beginExit();
|
||||||
|
this.phase = "exiting";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.phase === "exiting" && this.child.isExitComplete()) {
|
||||||
|
this.child.cleanup();
|
||||||
|
this.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): void {
|
||||||
|
if (this.child) this.child.render(ctx, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
if (this.child) this.child.handleResize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, isDown: boolean): void {
|
||||||
|
if (this.child) this.child.handleMouseMove(x, y, isDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void {
|
||||||
|
if (this.child) this.child.handleMouseDown(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp(): void {
|
||||||
|
if (this.child) this.child.handleMouseUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseLeave(): void {
|
||||||
|
if (this.child) this.child.handleMouseLeave();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], bgColor: string): void {
|
||||||
|
this.palette = palette;
|
||||||
|
this.bgColor = bgColor;
|
||||||
|
if (this.child) this.child.updatePalette(palette, bgColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
beginExit(): void {
|
||||||
|
if (this.child) this.child.beginExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
isExitComplete(): boolean {
|
||||||
|
return this.child ? this.child.isExitComplete() : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
if (this.child) {
|
||||||
|
this.child.cleanup();
|
||||||
|
this.child = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
356
src/components/background/index.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { GameOfLifeEngine } from "@/components/background/engines/game-of-life";
|
||||||
|
import { LavaLampEngine } from "@/components/background/engines/lava-lamp";
|
||||||
|
import { ConfettiEngine } from "@/components/background/engines/confetti";
|
||||||
|
import { AsciiquariumEngine } from "@/components/background/engines/asciiquarium";
|
||||||
|
import { PipesEngine } from "@/components/background/engines/pipes";
|
||||||
|
import { ShuffleEngine } from "@/components/background/engines/shuffle";
|
||||||
|
import { getStoredAnimationId } from "@/lib/animations/engine";
|
||||||
|
import type { AnimationEngine } from "@/lib/animations/types";
|
||||||
|
import type { AnimationId } from "@/lib/animations";
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH = 240;
|
||||||
|
|
||||||
|
const FALLBACK_PALETTE: [number, number, number][] = [
|
||||||
|
[204, 36, 29], [152, 151, 26], [215, 153, 33],
|
||||||
|
[69, 133, 136], [177, 98, 134], [104, 157, 106],
|
||||||
|
[251, 73, 52], [184, 187, 38], [250, 189, 47],
|
||||||
|
[131, 165, 152], [211, 134, 155], [142, 192, 124],
|
||||||
|
];
|
||||||
|
|
||||||
|
function createEngine(id: AnimationId): AnimationEngine {
|
||||||
|
switch (id) {
|
||||||
|
case "lava-lamp":
|
||||||
|
return new LavaLampEngine();
|
||||||
|
case "confetti":
|
||||||
|
return new ConfettiEngine();
|
||||||
|
case "asciiquarium":
|
||||||
|
return new AsciiquariumEngine();
|
||||||
|
case "pipes":
|
||||||
|
return new PipesEngine();
|
||||||
|
case "shuffle":
|
||||||
|
return new ShuffleEngine();
|
||||||
|
case "game-of-life":
|
||||||
|
default:
|
||||||
|
return new GameOfLifeEngine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPaletteFromCSS(): [number, number, number][] {
|
||||||
|
try {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const keys = [
|
||||||
|
"--color-red", "--color-green", "--color-yellow",
|
||||||
|
"--color-blue", "--color-purple", "--color-aqua",
|
||||||
|
"--color-red-bright", "--color-green-bright", "--color-yellow-bright",
|
||||||
|
"--color-blue-bright", "--color-purple-bright", "--color-aqua-bright",
|
||||||
|
];
|
||||||
|
const palette: [number, number, number][] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const val = style.getPropertyValue(key).trim();
|
||||||
|
if (val) {
|
||||||
|
const parts = val.split(" ").map(Number);
|
||||||
|
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
||||||
|
palette.push([parts[0], parts[1], parts[2]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return palette.length > 0 ? palette : FALLBACK_PALETTE;
|
||||||
|
} catch {
|
||||||
|
return FALLBACK_PALETTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBgFromCSS(): string {
|
||||||
|
try {
|
||||||
|
const val = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--color-background")
|
||||||
|
.trim();
|
||||||
|
if (val) {
|
||||||
|
const [r, g, b] = val.split(" ");
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return "rgb(0, 0, 0)";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackgroundProps {
|
||||||
|
layout?: "index" | "sidebar" | "content";
|
||||||
|
position?: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
const Background: React.FC<BackgroundProps> = ({
|
||||||
|
layout = "index",
|
||||||
|
position = "left",
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const engineRef = useRef<AnimationEngine | null>(null);
|
||||||
|
const animationFrameRef = useRef<number>();
|
||||||
|
const lastUpdateTimeRef = useRef<number>(0);
|
||||||
|
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
const dimensionsRef = useRef({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
const setupCanvas = (
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
) => {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = width * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
const displayWidth =
|
||||||
|
layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
||||||
|
const displayHeight = window.innerHeight;
|
||||||
|
dimensionsRef.current = { width: displayWidth, height: displayHeight };
|
||||||
|
|
||||||
|
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const palette = readPaletteFromCSS();
|
||||||
|
const bgColor = readBgFromCSS();
|
||||||
|
|
||||||
|
// Initialize engine
|
||||||
|
if (!engineRef.current) {
|
||||||
|
const animId = getStoredAnimationId();
|
||||||
|
engineRef.current = createEngine(animId);
|
||||||
|
engineRef.current.init(displayWidth, displayHeight, palette, bgColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle animation switching
|
||||||
|
const handleAnimationChanged = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (!detail?.id) return;
|
||||||
|
|
||||||
|
if (engineRef.current) {
|
||||||
|
engineRef.current.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
engineRef.current = createEngine(detail.id);
|
||||||
|
engineRef.current.init(w, h, readPaletteFromCSS(), readBgFromCSS());
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("animation-changed", handleAnimationChanged, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle theme changes — only update if palette actually changed
|
||||||
|
let currentPalette = palette;
|
||||||
|
const handleThemeChanged = () => {
|
||||||
|
const newPalette = readPaletteFromCSS();
|
||||||
|
const newBg = readBgFromCSS();
|
||||||
|
const same =
|
||||||
|
newPalette.length === currentPalette.length &&
|
||||||
|
newPalette.every(
|
||||||
|
(c, i) =>
|
||||||
|
c[0] === currentPalette[i][0] &&
|
||||||
|
c[1] === currentPalette[i][1] &&
|
||||||
|
c[2] === currentPalette[i][2]
|
||||||
|
);
|
||||||
|
if (!same && engineRef.current) {
|
||||||
|
currentPalette = newPalette;
|
||||||
|
engineRef.current.updatePalette(newPalette, newBg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("theme-changed", handleThemeChanged, { signal });
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
const handleResize = () => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
|
||||||
|
const newCtx = setupCanvas(canvas, w, h);
|
||||||
|
if (!newCtx) return;
|
||||||
|
|
||||||
|
lastUpdateTimeRef.current = 0;
|
||||||
|
dimensionsRef.current = { width: w, height: h };
|
||||||
|
|
||||||
|
if (engineRef.current) {
|
||||||
|
engineRef.current.handleResize(w, h);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse events
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (!engineRef.current || !canvas) return;
|
||||||
|
|
||||||
|
// Don't spawn when clicking interactive elements
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest("a, button, [role='button'], input, select, textarea, label, [onclick], [tabindex]")) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
if (
|
||||||
|
mouseX < 0 ||
|
||||||
|
mouseX > rect.width ||
|
||||||
|
mouseY < 0 ||
|
||||||
|
mouseY > rect.height
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
engineRef.current.handleMouseDown(mouseX, mouseY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!engineRef.current || !canvas) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
engineRef.current.handleMouseMove(mouseX, mouseY, e.buttons === 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (engineRef.current) {
|
||||||
|
engineRef.current.handleMouseUp();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (engineRef.current) {
|
||||||
|
engineRef.current.handleMouseLeave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousedown", handleMouseDown, { signal });
|
||||||
|
window.addEventListener("mousemove", handleMouseMove, { signal });
|
||||||
|
window.addEventListener("mouseup", handleMouseUp, { signal });
|
||||||
|
|
||||||
|
// Visibility change
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!animationFrameRef.current) {
|
||||||
|
lastUpdateTimeRef.current = performance.now();
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
const animate = (currentTime: number) => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
if (!lastUpdateTimeRef.current) {
|
||||||
|
lastUpdateTimeRef.current = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaTime = currentTime - lastUpdateTimeRef.current;
|
||||||
|
const clampedDeltaTime = Math.min(deltaTime, 100);
|
||||||
|
lastUpdateTimeRef.current = currentTime;
|
||||||
|
|
||||||
|
const engine = engineRef.current;
|
||||||
|
if (engine) {
|
||||||
|
engine.update(clampedDeltaTime);
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
const bg = readBgFromCSS();
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const { width: rw, height: rh } = dimensionsRef.current;
|
||||||
|
engine.render(ctx, rw, rh);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", handleResize, { signal });
|
||||||
|
animate(performance.now());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [layout]);
|
||||||
|
|
||||||
|
const isIndex = layout === "index";
|
||||||
|
const isSidebar = !isIndex;
|
||||||
|
|
||||||
|
const getContainerStyle = (): React.CSSProperties => {
|
||||||
|
if (isIndex) return {};
|
||||||
|
// Fade the inner edge so blobs don't hard-cut at the content boundary
|
||||||
|
return {
|
||||||
|
maskImage:
|
||||||
|
position === "left"
|
||||||
|
? "linear-gradient(to right, black 60%, transparent 100%)"
|
||||||
|
: "linear-gradient(to left, black 60%, transparent 100%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
position === "left"
|
||||||
|
? "linear-gradient(to right, black 60%, transparent 100%)"
|
||||||
|
: "linear-gradient(to left, black 60%, transparent 100%)",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContainerClasses = () => {
|
||||||
|
if (isIndex) {
|
||||||
|
return "fixed inset-0 -z-10";
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10";
|
||||||
|
return position === "left"
|
||||||
|
? `${baseClasses} left-0`
|
||||||
|
: `${baseClasses} right-0`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={getContainerClasses()} style={getContainerStyle()}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full h-full bg-background"
|
||||||
|
style={{ cursor: "default" }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
||||||
|
<div className="crt-scanlines absolute inset-0 pointer-events-none" />
|
||||||
|
<div className="crt-bloom absolute inset-0 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Background;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
|
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
|
||||||
import { AnimateIn } from "@/components/animate-in";
|
import { AnimateIn } from "@/components/animate-in";
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { AnimateIn } from "@/components/animate-in";
|
import { AnimateIn } from "@/components/animate-in";
|
||||||
|
|
||||||
type BlogPost = {
|
type BlogPost = {
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { Links } from "@/components/footer/links";
|
import { Links } from "@/components/footer/links";
|
||||||
|
|
||||||
export default function Footer({ fixed = false }) {
|
export default function Footer({ fixed = false }) {
|
||||||
@@ -8,7 +8,7 @@ interface FooterLink {
|
|||||||
export const Links: FooterLink[] = [
|
export const Links: FooterLink[] = [
|
||||||
{ id: 0, href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green" },
|
{ id: 0, href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green" },
|
||||||
{ id: 1, href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow" },
|
{ id: 1, href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow" },
|
||||||
{ id: 3, href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "Linkedin", color: "text-blue" },
|
{ id: 3, href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "LinkedIn", color: "text-blue" },
|
||||||
{ id: 4, href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple" },
|
{ id: 4, href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple" },
|
||||||
{ id: 5, href: "https://github.com/timmypidashev/web/releases", label: "v2", color: "text-aqua" }
|
{ id: 5, href: "https://github.com/timmypidashev/web/releases", label: "v3", color: "text-aqua" }
|
||||||
];
|
];
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Links } from "@/components/header/links";
|
import { Links } from "@/components/header/links";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header({ transparent = false }: { transparent?: boolean }) {
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(true);
|
||||||
const [lastScrollY, setLastScrollY] = useState(0);
|
const [lastScrollY, setLastScrollY] = useState(0);
|
||||||
@@ -34,7 +34,7 @@ export default function Header() {
|
|||||||
return linkHref !== "/" && path.startsWith(linkHref);
|
return linkHref !== "/" && path.startsWith(linkHref);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isIndexPage = checkIsActive("/");
|
const isIndexPage = transparent || checkIsActive("/");
|
||||||
const headerLinks = Links.map((link) => {
|
const headerLinks = Links.map((link) => {
|
||||||
const isActive = checkIsActive(link.href);
|
const isActive = checkIsActive(link.href);
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export default function Header() {
|
|||||||
className={`
|
className={`
|
||||||
relative inline-block
|
relative inline-block
|
||||||
${link.color}
|
${link.color}
|
||||||
${!isIndexPage ? 'bg-black' : ''}
|
${!isIndexPage ? 'bg-background' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -94,13 +94,13 @@ export default function Header() {
|
|||||||
<div className={`
|
<div className={`
|
||||||
w-full flex flex-row items-center justify-center
|
w-full flex flex-row items-center justify-center
|
||||||
pointer-events-none
|
pointer-events-none
|
||||||
${!isIndexPage ? 'bg-black md:bg-transparent' : ''}
|
${!isIndexPage ? 'bg-background md:bg-transparent' : ''}
|
||||||
`}>
|
`}>
|
||||||
<div className={`
|
<div className={`
|
||||||
w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl
|
w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl
|
||||||
items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2
|
items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2
|
||||||
pointer-events-none [&_a]:pointer-events-auto
|
pointer-events-none [&_a]:pointer-events-auto
|
||||||
${!isIndexPage ? 'bg-black md:px-20' : ''}
|
${!isIndexPage ? 'bg-background md:px-20' : ''}
|
||||||
`}>
|
`}>
|
||||||
{headerLinks}
|
{headerLinks}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import Typewriter from "typewriter-effect";
|
import Typewriter from "typewriter-effect";
|
||||||
|
|
||||||
const html = (strings: TemplateStringsArray, ...values: any[]) => {
|
const html = (strings: TemplateStringsArray, ...values: any[]) => {
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import type { CollectionEntry } from "astro:content";
|
import type { CollectionEntry } from "astro:content";
|
||||||
import { AnimateIn } from "@/components/animate-in";
|
import { AnimateIn } from "@/components/animate-in";
|
||||||
|
|
||||||
98
src/components/theme-switcher/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useRef, useState, useEffect } from "react";
|
||||||
|
import { getStoredThemeId, getNextTheme, applyTheme } from "@/lib/themes/engine";
|
||||||
|
|
||||||
|
const FADE_DURATION = 300;
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
darkbox: "classic",
|
||||||
|
"darkbox-retro": "retro",
|
||||||
|
"darkbox-dim": "dim",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ThemeSwitcher() {
|
||||||
|
const [hovering, setHovering] = useState(false);
|
||||||
|
const [currentLabel, setCurrentLabel] = useState("");
|
||||||
|
|
||||||
|
const maskRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animatingRef = useRef(false);
|
||||||
|
const committedRef = useRef("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
committedRef.current = getStoredThemeId();
|
||||||
|
setCurrentLabel(LABELS[committedRef.current] ?? "");
|
||||||
|
|
||||||
|
const handleSwap = () => {
|
||||||
|
const id = getStoredThemeId();
|
||||||
|
applyTheme(id);
|
||||||
|
committedRef.current = id;
|
||||||
|
setCurrentLabel(LABELS[id] ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("astro:after-swap", handleSwap);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("astro:after-swap", handleSwap);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (animatingRef.current) return;
|
||||||
|
animatingRef.current = true;
|
||||||
|
|
||||||
|
const mask = maskRef.current;
|
||||||
|
if (!mask) return;
|
||||||
|
|
||||||
|
const v = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--color-background")
|
||||||
|
.trim();
|
||||||
|
const [r, g, b] = v.split(" ").map(Number);
|
||||||
|
|
||||||
|
mask.style.backgroundColor = `rgb(${r},${g},${b})`;
|
||||||
|
mask.style.opacity = "1";
|
||||||
|
mask.style.visibility = "visible";
|
||||||
|
mask.style.transition = "none";
|
||||||
|
|
||||||
|
const next = getNextTheme(committedRef.current);
|
||||||
|
applyTheme(next.id);
|
||||||
|
committedRef.current = next.id;
|
||||||
|
setCurrentLabel(LABELS[next.id] ?? "");
|
||||||
|
|
||||||
|
mask.offsetHeight;
|
||||||
|
|
||||||
|
mask.style.transition = `opacity ${FADE_DURATION}ms ease-out`;
|
||||||
|
mask.style.opacity = "0";
|
||||||
|
|
||||||
|
const onEnd = () => {
|
||||||
|
mask.removeEventListener("transitionend", onEnd);
|
||||||
|
mask.style.visibility = "hidden";
|
||||||
|
mask.style.transition = "none";
|
||||||
|
animatingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
mask.addEventListener("transitionend", onEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden md:block"
|
||||||
|
onMouseEnter={() => setHovering(true)}
|
||||||
|
onMouseLeave={() => setHovering(false)}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||||
|
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||||
|
>
|
||||||
|
{currentLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={maskRef}
|
||||||
|
className="fixed inset-0 z-[100] pointer-events-none"
|
||||||
|
style={{ visibility: "hidden", opacity: 0 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
src/src/env.d.ts → src/env.d.ts
vendored
@@ -5,6 +5,10 @@ import { ClientRouter } from "astro:transitions";
|
|||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import Background from "@/components/background";
|
import Background from "@/components/background";
|
||||||
|
import ThemeSwitcher from "@/components/theme-switcher";
|
||||||
|
import AnimationSwitcher from "@/components/animation-switcher";
|
||||||
|
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
||||||
|
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -20,20 +24,16 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<!-- OpenGraph -->
|
|
||||||
<meta property="og:image" content={ogImage} />
|
<meta property="og:image" content={ogImage} />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<!-- Twitter -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:image" content={ogImage} />
|
<meta name="twitter:image" content={ogImage} />
|
||||||
<meta name="twitter:description" content={description} />
|
<meta name="twitter:description" content={description} />
|
||||||
<!-- Basic meta description for search engines -->
|
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<!-- Also used in OpenGraph for social media sharing -->
|
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
|
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
|
||||||
<ClientRouter
|
<ClientRouter
|
||||||
defaultTransition={false}
|
defaultTransition={false}
|
||||||
handleFocus={false}
|
handleFocus={false}
|
||||||
/>
|
/>
|
||||||
@@ -41,7 +41,6 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
::view-transition-new(:root) {
|
::view-transition-new(:root) {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-old(:root) {
|
::view-transition-old(:root) {
|
||||||
animation: 90ms ease-out both fade-out;
|
animation: 90ms ease-out both fade-out;
|
||||||
}
|
}
|
||||||
@@ -50,6 +49,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
to { opacity: 0; }
|
to { opacity: 0; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
||||||
<Header client:load />
|
<Header client:load />
|
||||||
@@ -65,10 +66,9 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
<div class="mt-auto">
|
<div class="mt-auto">
|
||||||
<Footer client:load transition:persist />
|
<Footer client:load transition:persist />
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<ThemeSwitcher client:only="react" transition:persist />
|
||||||
document.addEventListener("astro:after-navigation", () => {
|
<AnimationSwitcher client:only="react" transition:persist />
|
||||||
window.scrollTo(0, 0);
|
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
|
||||||
});
|
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
---
|
---
|
||||||
const { content } = Astro.props;
|
|
||||||
|
|
||||||
import "@/style/globals.css";
|
import "@/style/globals.css";
|
||||||
|
|
||||||
import { ClientRouter } from "astro:transitions";
|
import { ClientRouter } from "astro:transitions";
|
||||||
@@ -8,6 +6,10 @@ import { ClientRouter } from "astro:transitions";
|
|||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import Background from "@/components/background";
|
import Background from "@/components/background";
|
||||||
|
import ThemeSwitcher from "@/components/theme-switcher";
|
||||||
|
import AnimationSwitcher from "@/components/animation-switcher";
|
||||||
|
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
||||||
|
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -23,28 +25,30 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<!-- OpenGraph -->
|
|
||||||
<meta property="og:image" content={ogImage} />
|
<meta property="og:image" content={ogImage} />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<!-- Twitter -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:image" content={ogImage} />
|
<meta name="twitter:image" content={ogImage} />
|
||||||
<meta name="twitter:description" content={description} />
|
<meta name="twitter:description" content={description} />
|
||||||
<!-- Basic meta description for search engines -->
|
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<!-- Also used in OpenGraph for social media sharing -->
|
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
|
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
|
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-foreground">
|
<body class="bg-background text-foreground">
|
||||||
<Header client:load />
|
<Header client:load transparent />
|
||||||
<main transition:animate="fade">
|
<main transition:animate="fade">
|
||||||
<Background layout="index" client:only="react" transition:persist />
|
<Background layout="index" client:only="react" transition:persist />
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<Footer client:load transition:persist fixed=true />
|
<Footer client:load transition:persist fixed=true />
|
||||||
|
<ThemeSwitcher client:only="react" transition:persist />
|
||||||
|
<AnimationSwitcher client:only="react" transition:persist />
|
||||||
|
<script is:inline set:html={THEME_NAV_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -5,6 +5,10 @@ import { ClientRouter } from "astro:transitions";
|
|||||||
import Header from "@/components/header";
|
import Header from "@/components/header";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
import Background from "@/components/background";
|
import Background from "@/components/background";
|
||||||
|
import ThemeSwitcher from "@/components/theme-switcher";
|
||||||
|
import AnimationSwitcher from "@/components/animation-switcher";
|
||||||
|
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
||||||
|
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -20,20 +24,16 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<!-- OpenGraph -->
|
|
||||||
<meta property="og:image" content={ogImage} />
|
<meta property="og:image" content={ogImage} />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<!-- Twitter -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:image" content={ogImage} />
|
<meta name="twitter:image" content={ogImage} />
|
||||||
<meta name="twitter:description" content={description} />
|
<meta name="twitter:description" content={description} />
|
||||||
<!-- Basic meta description for search engines -->
|
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<!-- Also used in OpenGraph for social media sharing -->
|
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
|
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
|
||||||
<ClientRouter
|
<ClientRouter
|
||||||
defaultTransition={false}
|
defaultTransition={false}
|
||||||
handleFocus={false}
|
handleFocus={false}
|
||||||
/>
|
/>
|
||||||
@@ -41,7 +41,6 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
::view-transition-new(:root) {
|
::view-transition-new(:root) {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-old(:root) {
|
::view-transition-old(:root) {
|
||||||
animation: 90ms ease-out both fade-out;
|
animation: 90ms ease-out both fade-out;
|
||||||
}
|
}
|
||||||
@@ -50,6 +49,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
to { opacity: 0; }
|
to { opacity: 0; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
||||||
|
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
||||||
<main class="flex-1 flex flex-col">
|
<main class="flex-1 flex flex-col">
|
||||||
@@ -61,10 +62,9 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
|||||||
<Background layout="content" position="left" client:only="react" transition:persist />
|
<Background layout="content" position="left" client:only="react" transition:persist />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<ThemeSwitcher client:only="react" transition:persist />
|
||||||
document.addEventListener("astro:after-navigation", () => {
|
<AnimationSwitcher client:only="react" transition:persist />
|
||||||
window.scrollTo(0, 0);
|
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
|
||||||
});
|
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
20
src/lib/animations/engine.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "@/lib/animations";
|
||||||
|
import type { AnimationId } from "@/lib/animations";
|
||||||
|
|
||||||
|
export function getStoredAnimationId(): AnimationId {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_ANIMATION_ID;
|
||||||
|
const stored = localStorage.getItem("animation");
|
||||||
|
if (stored && (ANIMATION_IDS as readonly string[]).includes(stored)) {
|
||||||
|
return stored as AnimationId;
|
||||||
|
}
|
||||||
|
return DEFAULT_ANIMATION_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAnimation(id: AnimationId): void {
|
||||||
|
localStorage.setItem("animation", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextAnimation(currentId: AnimationId): AnimationId {
|
||||||
|
const idx = ANIMATION_IDS.indexOf(currentId);
|
||||||
|
return ANIMATION_IDS[(idx + 1) % ANIMATION_IDS.length];
|
||||||
|
}
|
||||||
12
src/lib/animations/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const ANIMATION_IDS = ["shuffle", "game-of-life", "lava-lamp", "confetti", "asciiquarium", "pipes"] as const;
|
||||||
|
export type AnimationId = (typeof ANIMATION_IDS)[number];
|
||||||
|
export const DEFAULT_ANIMATION_ID: AnimationId = "shuffle";
|
||||||
|
|
||||||
|
export const ANIMATION_LABELS: Record<AnimationId, string> = {
|
||||||
|
"shuffle": "shuffle",
|
||||||
|
"game-of-life": "life",
|
||||||
|
"lava-lamp": "lava",
|
||||||
|
"confetti": "confetti",
|
||||||
|
"asciiquarium": "aquarium",
|
||||||
|
"pipes": "pipes",
|
||||||
|
};
|
||||||
7
src/lib/animations/loader.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "@/lib/animations";
|
||||||
|
|
||||||
|
const VALID_IDS = JSON.stringify(ANIMATION_IDS);
|
||||||
|
|
||||||
|
export const ANIMATION_LOADER_SCRIPT = `(function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";})();`;
|
||||||
|
|
||||||
|
export const ANIMATION_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";});`;
|
||||||
37
src/lib/animations/types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface AnimationEngine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
init(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
palette: [number, number, number][],
|
||||||
|
bgColor: string
|
||||||
|
): void;
|
||||||
|
|
||||||
|
update(deltaTime: number): void;
|
||||||
|
|
||||||
|
render(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): void;
|
||||||
|
|
||||||
|
handleResize(width: number, height: number): void;
|
||||||
|
|
||||||
|
handleMouseMove(x: number, y: number, isDown: boolean): void;
|
||||||
|
|
||||||
|
handleMouseDown(x: number, y: number): void;
|
||||||
|
|
||||||
|
handleMouseUp(): void;
|
||||||
|
|
||||||
|
handleMouseLeave(): void;
|
||||||
|
|
||||||
|
updatePalette(palette: [number, number, number][], bgColor: string): void;
|
||||||
|
|
||||||
|
beginExit(): void;
|
||||||
|
|
||||||
|
isExitComplete(): boolean;
|
||||||
|
|
||||||
|
cleanup(): void;
|
||||||
|
}
|
||||||
61
src/lib/themes/engine.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { THEMES, DEFAULT_THEME_ID } from "@/lib/themes";
|
||||||
|
import { CSS_PROPS } from "@/lib/themes/props";
|
||||||
|
import type { Theme } from "@/lib/themes/types";
|
||||||
|
|
||||||
|
export function getStoredThemeId(): string {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
||||||
|
return localStorage.getItem("theme") || DEFAULT_THEME_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTheme(id: string): void {
|
||||||
|
localStorage.setItem("theme", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextTheme(currentId: string): Theme {
|
||||||
|
const list = Object.values(THEMES);
|
||||||
|
const idx = list.findIndex((t) => t.id === currentId);
|
||||||
|
return list[(idx + 1) % list.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets CSS vars and notifies canvas, but does NOT persist to localStorage. */
|
||||||
|
export function previewTheme(id: string): void {
|
||||||
|
const theme = THEMES[id];
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [key, prop] of CSS_PROPS) {
|
||||||
|
root.style.setProperty(prop, theme.colors[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(id: string): void {
|
||||||
|
const theme = THEMES[id];
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
// Set CSS vars on :root for immediate visual update
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [key, prop] of CSS_PROPS) {
|
||||||
|
root.style.setProperty(prop, theme.colors[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update <style id="theme-vars"> so Astro view transitions don't revert
|
||||||
|
let el = document.getElementById("theme-vars") as HTMLStyleElement | null;
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("style");
|
||||||
|
el.id = "theme-vars";
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
let css = ":root{";
|
||||||
|
for (const [key, prop] of CSS_PROPS) {
|
||||||
|
css += `${prop}:${theme.colors[key]};`;
|
||||||
|
}
|
||||||
|
css += "}";
|
||||||
|
el.textContent = css;
|
||||||
|
|
||||||
|
saveTheme(id);
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
||||||
|
}
|
||||||
58
src/lib/themes/index.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Theme } from "./types";
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_ID = "darkbox-retro";
|
||||||
|
|
||||||
|
function theme(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
type: "dark" | "light",
|
||||||
|
colors: Theme["colors"],
|
||||||
|
palette: [number, number, number][]
|
||||||
|
): Theme {
|
||||||
|
return { id, name, type, colors, canvasPalette: palette };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three darkbox variants from darkbox.nvim
|
||||||
|
// Classic (vivid) → Retro (muted) → Dim (deep)
|
||||||
|
// Each variant's "bright" is the next level up's base.
|
||||||
|
|
||||||
|
export const THEMES: Record<string, Theme> = {
|
||||||
|
darkbox: theme("darkbox", "Darkbox Classic", "dark", {
|
||||||
|
background: "0 0 0",
|
||||||
|
foreground: "235 219 178",
|
||||||
|
red: "251 73 52", redBright: "255 110 85",
|
||||||
|
orange: "254 128 25", orangeBright: "255 165 65",
|
||||||
|
green: "184 187 38", greenBright: "210 215 70",
|
||||||
|
yellow: "250 189 47", yellowBright: "255 215 85",
|
||||||
|
blue: "131 165 152", blueBright: "165 195 180",
|
||||||
|
purple: "211 134 155", purpleBright: "235 165 180",
|
||||||
|
aqua: "142 192 124", aquaBright: "175 220 160",
|
||||||
|
surface: "60 56 54",
|
||||||
|
}, [[251,73,52],[184,187,38],[250,189,47],[131,165,152],[211,134,155],[142,192,124]]),
|
||||||
|
|
||||||
|
"darkbox-retro": theme("darkbox-retro", "Darkbox Retro", "dark", {
|
||||||
|
background: "0 0 0",
|
||||||
|
foreground: "189 174 147",
|
||||||
|
red: "204 36 29", redBright: "251 73 52",
|
||||||
|
orange: "214 93 14", orangeBright: "254 128 25",
|
||||||
|
green: "152 151 26", greenBright: "184 187 38",
|
||||||
|
yellow: "215 153 33", yellowBright: "250 189 47",
|
||||||
|
blue: "69 133 136", blueBright: "131 165 152",
|
||||||
|
purple: "177 98 134", purpleBright: "211 134 155",
|
||||||
|
aqua: "104 157 106", aquaBright: "142 192 124",
|
||||||
|
surface: "60 56 54",
|
||||||
|
}, [[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106]]),
|
||||||
|
|
||||||
|
"darkbox-dim": theme("darkbox-dim", "Darkbox Dim", "dark", {
|
||||||
|
background: "0 0 0",
|
||||||
|
foreground: "168 153 132",
|
||||||
|
red: "157 0 6", redBright: "204 36 29",
|
||||||
|
orange: "175 58 3", orangeBright: "214 93 14",
|
||||||
|
green: "121 116 14", greenBright: "152 151 26",
|
||||||
|
yellow: "181 118 20", yellowBright: "215 153 33",
|
||||||
|
blue: "7 102 120", blueBright: "69 133 136",
|
||||||
|
purple: "143 63 113", purpleBright: "177 98 134",
|
||||||
|
aqua: "66 123 88", aquaBright: "104 157 106",
|
||||||
|
surface: "60 56 54",
|
||||||
|
}, [[157,0,6],[121,116,14],[181,118,20],[7,102,120],[143,63,113],[66,123,88]]),
|
||||||
|
};
|
||||||
25
src/lib/themes/loader.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Generates the inline <script> content for theme loading.
|
||||||
|
* Called at build time in Astro frontmatter.
|
||||||
|
* The script reads "theme" from localStorage, looks up colors, injects a <style> tag.
|
||||||
|
*/
|
||||||
|
import { THEMES } from "@/lib/themes";
|
||||||
|
import { CSS_PROPS } from "@/lib/themes/props";
|
||||||
|
|
||||||
|
// Pre-build a { prop: value } map for each theme at build time
|
||||||
|
const themeVars: Record<string, Record<string, string>> = {};
|
||||||
|
for (const [id, theme] of Object.entries(THEMES)) {
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
for (const [key, prop] of CSS_PROPS) {
|
||||||
|
vars[prop] = theme.colors[key];
|
||||||
|
}
|
||||||
|
themeVars[id] = vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets inline styles on <html> — highest specificity, beats any stylesheet
|
||||||
|
const APPLY = `var v=t[id];if(!v)return;var s=document.documentElement.style;for(var k in v)s.setProperty(k,v[k])`;
|
||||||
|
const LOOKUP = `var id=localStorage.getItem("theme");if(!id)return;var t=${JSON.stringify(themeVars)};`;
|
||||||
|
|
||||||
|
export const THEME_LOADER_SCRIPT = `(function(){${LOOKUP}${APPLY}})();`;
|
||||||
|
|
||||||
|
export const THEME_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){${LOOKUP}${APPLY}});`;
|
||||||
21
src/lib/themes/props.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ThemeColors } from "@/lib/themes/types";
|
||||||
|
|
||||||
|
export const CSS_PROPS: [keyof ThemeColors, string][] = [
|
||||||
|
["background", "--color-background"],
|
||||||
|
["foreground", "--color-foreground"],
|
||||||
|
["red", "--color-red"],
|
||||||
|
["redBright", "--color-red-bright"],
|
||||||
|
["orange", "--color-orange"],
|
||||||
|
["orangeBright", "--color-orange-bright"],
|
||||||
|
["green", "--color-green"],
|
||||||
|
["greenBright", "--color-green-bright"],
|
||||||
|
["yellow", "--color-yellow"],
|
||||||
|
["yellowBright", "--color-yellow-bright"],
|
||||||
|
["blue", "--color-blue"],
|
||||||
|
["blueBright", "--color-blue-bright"],
|
||||||
|
["purple", "--color-purple"],
|
||||||
|
["purpleBright", "--color-purple-bright"],
|
||||||
|
["aqua", "--color-aqua"],
|
||||||
|
["aquaBright", "--color-aqua-bright"],
|
||||||
|
["surface", "--color-surface"],
|
||||||
|
];
|
||||||
27
src/lib/themes/types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface ThemeColors {
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
red: string;
|
||||||
|
redBright: string;
|
||||||
|
orange: string;
|
||||||
|
orangeBright: string;
|
||||||
|
green: string;
|
||||||
|
greenBright: string;
|
||||||
|
yellow: string;
|
||||||
|
yellowBright: string;
|
||||||
|
blue: string;
|
||||||
|
blueBright: string;
|
||||||
|
purple: string;
|
||||||
|
purpleBright: string;
|
||||||
|
aqua: string;
|
||||||
|
aquaBright: string;
|
||||||
|
surface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "dark" | "light";
|
||||||
|
colors: ThemeColors;
|
||||||
|
canvasPalette: [number, number, number][];
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import GlitchText from "@/components/404/glitched-text";
|
|||||||
const title = "404 Not Found";
|
const title = "404 Not Found";
|
||||||
---
|
---
|
||||||
|
|
||||||
<IndexLayout content={{ title: "404 | Timothy Pidashev" }}>
|
<IndexLayout title="404 | Timothy Pidashev" description="Page not found">
|
||||||
<main class="min-h-screen flex flex-col items-center justify-center p-4 text-center">
|
<main class="min-h-screen flex flex-col items-center justify-center p-4 text-center">
|
||||||
<GlitchText client:only />
|
<GlitchText client:only />
|
||||||
<p class="text-xl text-orange mb-8">Whoops! This page doesn't exist :(</p>
|
<p class="text-xl text-orange mb-8">Whoops! This page doesn't exist :(</p>
|
||||||