diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/index.html b/index.html index 32b0675..a135adb 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,6 @@
- + diff --git a/package-lock.json b/package-lock.json index 9514fc9..d8ccc4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "particle-surfer", "version": "0.0.0", "dependencies": { + "@types/three": "^0.178.1", "pixi.js": "^8.11.0", + "prettier": "^3.6.2", "stats.js": "^0.17.0", "three": "^0.179.1", "uber-noise": "^0.2.2" @@ -18,6 +20,12 @@ "vite": "^7.0.4" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", @@ -746,6 +754,12 @@ "win32" ] }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/css-font-loading-module": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", @@ -765,6 +779,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.178.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.178.1.tgz", + "integrity": "sha512-WSabew1mgWgRx2RfLfKY+9h4wyg6U94JfLbZEGU245j/WY2kXqU0MUfghS+3AYMV5ET1VlILAgpy77cB6a3Itw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.22.tgz", + "integrity": "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==", + "license": "MIT" + }, "node_modules/@webgpu/types": { "version": "0.1.64", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.64.tgz", @@ -855,6 +896,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -891,6 +938,12 @@ "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", "license": "MIT" }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -987,6 +1040,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", diff --git a/package.json b/package.json index 8117055..92493ed 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "vite": "^7.0.4" }, "dependencies": { + "@types/three": "^0.178.1", "pixi.js": "^8.11.0", + "prettier": "^3.6.2", "stats.js": "^0.17.0", "three": "^0.179.1", "uber-noise": "^0.2.2" diff --git a/src/colors.ts b/src/colors.ts new file mode 100644 index 0000000..561a2be --- /dev/null +++ b/src/colors.ts @@ -0,0 +1,207 @@ +// colorGrid.ts + +export type RGB01 = { r: number; g: number; b: number }; // each in [0,1] +type HexColor = string; + +export interface ColorGridResult { + grid: RGB01[][]; // [row][col], y then x + getColor: (xSpeed: number, ySpeed: number, out: RGB01) => void; +} + +/** Parse hex like "#abc" or "#aabbcc" into RGB01 (0..1) */ +function hexToRgb01(hex: string): RGB01 { + hex = hex.replace(/^#/, ""); + if (hex.length === 3) { + hex = hex + .split("") + .map((c) => c + c) + .join(""); + } + const int = parseInt(hex, 16); + const r = ((int >> 16) & 0xff) / 255; + const g = ((int >> 8) & 0xff) / 255; + const b = (int & 0xff) / 255; + return { r, g, b }; +} + +/** Convert RGB01 to hex string "#rrggbb" */ +export function rgb01ToHex({ r, g, b }: RGB01): HexColor { + const clamp8 = (v: number) => + Math.max(0, Math.min(255, Math.round(v * 255))) // convert to 0-255 + .toString(16) + .padStart(2, "0"); + return `#${clamp8(r)}${clamp8(g)}${clamp8(b)}`; +} + +/** Linear interpolation */ +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** Lerp between two RGB01 colors */ +function lerpColor(c1: RGB01, c2: RGB01, t: number): RGB01 { + return { + r: lerp(c1.r, c2.r, t), + g: lerp(c1.g, c2.g, t), + b: lerp(c1.b, c2.b, t), + }; +} + +/** + * Sample a multi-stop gradient. stops are hex colors, t in [0,1]. + * Returns RGB01. + */ +function sampleGradient(stops: HexColor[], t: number): RGB01 { + if (t <= 0) return hexToRgb01(stops[0]); + if (t >= 1) return hexToRgb01(stops[stops.length - 1]); + const scaled = t * (stops.length - 1); + const idx = Math.floor(scaled); + const frac = scaled - idx; + const c1 = hexToRgb01(stops[idx]); + const c2 = hexToRgb01(stops[idx + 1]); + return lerpColor(c1, c2, frac); +} + +/** Simple average blend of two RGB01 colors */ +function blendRGB(a: RGB01, b: RGB01): RGB01 { + return { + r: (a.r + b.r) * 0.5, + g: (a.g + b.g) * 0.5, + b: (a.b + b.b) * 0.5, + }; +} + +/** + * Build the precomputed color grid and sampler. + * xSpeed and ySpeed are expected in [-1,1]. + */ +export function createColorGrid(params: { + gradientXStops: HexColor[]; // for x speed (-1 → 1) + gradientYStops: HexColor[]; // for y speed (-1 → 1) + detailX?: number; // columns + detailY?: number; // rows +}): ColorGridResult { + const detailX = params.detailX ?? 20; + const detailY = params.detailY ?? 20; + + const gradX: RGB01[] = new Array(detailX); + const gradY: RGB01[] = new Array(detailY); + for (let i = 0; i < detailX; i++) { + const tx = detailX === 1 ? 0.5 : i / (detailX - 1); + gradX[i] = sampleGradient(params.gradientXStops, tx); + } + for (let j = 0; j < detailY; j++) { + const ty = detailY === 1 ? 0.5 : j / (detailY - 1); + gradY[j] = sampleGradient(params.gradientYStops, ty); + } + + // build blended grid [row][col] (y then x) + const grid: RGB01[][] = new Array(detailY); + for (let row = 0; row < detailY; row++) { + grid[row] = new Array(detailX); + for (let col = 0; col < detailX; col++) { + grid[row][col] = blendRGB(gradX[col], gradY[row]); + } + } + + /** + * Writes interpolated color into `out` (mutated), each channel in [0,1]. + */ + function getColor(xSpeed: number, ySpeed: number, out: RGB01): void { + const clamp = (v: number) => Math.max(-1, Math.min(1, v)); + xSpeed = clamp(xSpeed); + ySpeed = clamp(ySpeed); + + const fx = ((xSpeed + 1) / 2) * (detailX - 1); + const fy = ((ySpeed + 1) / 2) * (detailY - 1); + + const x0 = Math.floor(fx); + const x1 = Math.min(detailX - 1, x0 + 1); + const y0 = Math.floor(fy); + const y1 = Math.min(detailY - 1, y0 + 1); + const wx = fx - x0; + const wy = fy - y0; + + const c00 = grid[y0][x0]; + const c10 = grid[y0][x1]; + const c01 = grid[y1][x0]; + const c11 = grid[y1][x1]; + + // bilinear interpolation + const mix = (a: RGB01, b: RGB01, t: number): RGB01 => ({ + r: lerp(a.r, b.r, t), + g: lerp(a.g, b.g, t), + b: lerp(a.b, b.b, t), + }); + + const c0 = mix(c00, c10, wx); + const c1 = mix(c01, c11, wx); + const c = mix(c0, c1, wy); + + out.r = c.r; + out.g = c.g; + out.b = c.b; + } + + return { grid, getColor }; +} + +/** + * Render a RGB01 grid to an offscreen canvas and trigger PNG download. + */ +export function saveGridAsPNG( + grid: RGB01[][], + cellSize: number = 20, + filename: string = "color-grid.png", +): void { + const rows = grid.length; + const cols = grid[0]?.length ?? 0; + const canvas = document.createElement("canvas"); + canvas.width = cols * cellSize; + canvas.height = rows * cellSize; + const ctx = canvas.getContext("2d"); + if (!ctx) { + console.error("2D context unavailable"); + return; + } + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const { r, g, b } = grid[y][x]; + // convert to 0-255 + const ir = Math.round(Math.max(0, Math.min(1, r)) * 255); + const ig = Math.round(Math.max(0, Math.min(1, g)) * 255); + const ib = Math.round(Math.max(0, Math.min(1, b)) * 255); + ctx.fillStyle = `rgb(${ir},${ig},${ib})`; + ctx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize); + } + } + + // optional subtle grid lines + ctx.strokeStyle = "rgba(0,0,0,0.1)"; + for (let x = 0; x <= cols; x++) { + ctx.beginPath(); + ctx.moveTo(x * cellSize + 0.5, 0); + ctx.lineTo(x * cellSize + 0.5, rows * cellSize); + ctx.stroke(); + } + for (let y = 0; y <= rows; y++) { + ctx.beginPath(); + ctx.moveTo(0, y * cellSize + 0.5); + ctx.lineTo(cols * cellSize, y * cellSize + 0.5); + ctx.stroke(); + } + + canvas.toBlob((blob) => { + if (!blob) { + console.error("Failed to make PNG blob"); + return; + } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + }, "image/png"); +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 111ee6c..0000000 --- a/src/index.js +++ /dev/null @@ -1,287 +0,0 @@ -import * as THREE from 'https://cdnjs.cloudflare.com/ajax/libs/three.js/110/three.module.js'; - -//import { LPSphere, LPPlane } from 'https://1florki.github.io/low_poly_generator/lpshapes.js' - -import { Noise } from 'https://1florki.github.io/jsutils2/noise.js' -import Stats from "stats.js"; - -let stats = new Stats(); -stats.showPanel( 0 ); // 0: fps, 1: ms, 2: mb, 3+: custom -document.body.appendChild( stats.dom ); - -class Particle { - constructor(opt) { - opt = opt || {}; - - this.pos = new THREE.Vector3(0, 0, 0); - this.speed = new THREE.Vector3(0, 0, 0); - this.acc = new THREE.Vector3(0, 0, 0); - this.time = opt.time || Math.random() * 1.8 + 0.3; - if(opt.size) this.reset(opt.size); - } - reset(size) { - this.setPos((Math.random() - 0.5) * 2 * size.x, (Math.random() - 0.5) * 2 * size.y, 0); - this.acc.set(0, 0, 0); - this.speed.set(0, 0, 0); - this.time = Math.random() * 1.8 + 0.3 - } - setPos(x, y, z) { - this.pos.set(x, y, z); - } - update(dt, maxSpeed) { - this.speed.add(this.acc); - this.speed.clampLength(0, maxSpeed); - this.pos.add(this.speed); - this.time -= dt; - } - isDead(size) { - return (Math.abs(this.pos.x) > size.x || Math.abs(this.pos.y) > size.y || this.time < 0) - } -} - -class Particles { - constructor(opt) { - opt = opt || {}; - - this.num = opt.num || 10000; - - this.particleSize = opt.particleSize || 1; - this.size = opt.size || new THREE.Vector3(1, 1, 1); - - - this.vertexColors = opt.vertexColors || true; - this.color = opt.color || new THREE.Color(0xffffff); - this.maxSpeed = opt.maxSpeed || 0.02; - - this.minTime = opt.minTime || 0.01; - this.maxTime = opt.maxTime || 1; - this.newNoise(opt.seed || 0); - - this.createParticles(); - } - newNoise(seed) { - this.noise = new Noise({min: -0.01, max: 0.01, scale: 0.2, octaves: 2, persistence: 0.5, seed: seed}) - } - applyNoiseForce(p) { - p.acc.x = this.noise.getValue(p.pos.x, p.pos.y + 23, p.pos.z) + 0.002; - p.acc.y = this.noise.getValue(p.pos.x + 100, p.pos.y, p.pos.z); - } - - createParticles() { - this.geo = new THREE.BufferGeometry(); - - this.positionData = new Float32Array(this.num * 3); - - this.geo.setAttribute('position', new THREE.BufferAttribute(this.positionData, 3)); - - if(this.vertexColors) { - this.colorData = new Float32Array(this.num * 3); - this.geo.setAttribute('color', new THREE.BufferAttribute(this.colorData, 3)); - } - - this.parts = []; - for(let i = 0; i < this.num; i++) { - this.parts.push(new Particle({size: this.size})); - } - - var mat = new THREE.PointsMaterial({vertexColors: true, size: this.particleSize, sizeAttenuation: false, color: this.color}); - this.mesh = new THREE.Points(this.geo, mat); - } - update(dt, num) { - for(let i = 0; i < this.num; i++) { - let p = this.parts[i]; - - this.applyNoiseForce(p); - p.update(dt, this.maxSpeed); - if(p.isDead(this.size)) p.reset(this.size) - - this.positionData[i * 3] = p.pos.x; - this.positionData[i * 3 + 1] = p.pos.y; - this.positionData[i * 3 + 2] = p.pos.z; - - this.colorData[i * 3] = num * ((p.acc.x + 0.01) * 80 + 0.2); - this.colorData[i * 3 + 1] = (1 - num) * ((p.acc.x + 0.01) * 80 + 0.2); - this.colorData[i * 3 + 2] = (p.acc.y + 0.01) * 50 + 0.2; - - } - this.geo.attributes.position.needsUpdate = true; - - if(this.vertexColors) this.geo.attributes.color.needsUpdate = true; - - this.noise.shiftBy(0.0, 0.0, 0.00); - } - -} - - -var renderer, scene, light, camera, keys = {}, mesh, camNode, clock, particles = [], player, playerPart, active = 0, size, stop = false; - -const levels = [[89842, 74789], [41742, 37680], [78288, 60840], [15693, 83395], [54971, 5891], [29338, 42504], [83166, 59559], [14271, 26324], [87125, 3695], [43298, 94833], [12641, 84336], [81706, 92840], [81342, 18215], [68226, 9387], [50415, 61135], [40356, 68917], [21870, 34087], [77604, 4641], [35813, 32668]]; - -let level = 0; - -function setupScene() { - renderer = new THREE.WebGLRenderer({antialising: true, alpha: true, preserveDrawingBuffer: true, alpha: true}); - - renderer.setSize(window.innerWidth, window.innerHeight); - renderer.setPixelRatio(window.devicePixelRatio); - - // Enable alpha blending for trail effect - renderer.autoClearColor = false; // Don't auto-clear the canvas - - document.body.appendChild(renderer.domElement); - - window.addEventListener('resize', onResize, false); - - scene = new THREE.Scene({background: new THREE.Color(0xff0000)}); - - let ambi = new THREE.AmbientLight(0xffffff, 0.5); // soft white light - scene.add(ambi); - - let light = new THREE.DirectionalLight(0xffffff, 0.5); - scene.add(light); - let light2 = new THREE.DirectionalLight(0xffffff, 0.5); - light2.position.set(0, - 1, 0.5) - scene.add(light2); - - // add black transparent rect - let rect = new THREE.Mesh(new THREE.PlaneGeometry(100, 100), new THREE.MeshBasicMaterial({color: 0x000000, transparent: true, opacity: 0.15})); - rect.position.set(0, 0, 0); - scene.add(rect); - - document.addEventListener("keydown", (event) => { - keys[event.key] = true - if(event.key == " ") { - switchActive(); - } - if(event.key == "t") { - stop = !stop; - } - if(event.key == "l") { - nextLevel(); - } - if(event.key == "r") { - let s1 = Math.floor(Math.random() * 100000); - let s2 = Math.floor(Math.random() * 100000); - particles[0].newNoise(s1); - particles[1].newNoise(s2); - console.log("random level [" + s1 + ", " + s2 + "]"); - resetPlayer(); - } - }, false); - - document.addEventListener("mouseup", (event) => { - switchActive(); - }); - - let fogColor = new THREE.Color(0x000000); - scene.background = null; // Remove solid background for trail effect - // scene.fog = new THREE.Fog(fogColor, 4.5, 4.7); - - size = new THREE.Vector3(10, 2, .01); - - camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.2, 40); - camera.position.z = 4.5 - - clock = new THREE.Clock(); - - let part1 = new Particles({size: size, seed: levels[level][0]}); - let part2 = new Particles({size: size, seed: levels[level][1]}); - - particles.push(part1); - particles.push(part2); - scene.add(part1.mesh); - scene.add(part2.mesh); - switchActive(); - - player = new THREE.Mesh(new THREE.SphereGeometry( 0.07, 8, 8 ), new THREE.MeshBasicMaterial( {color: 0xffffff} )) - playerPart = new Particle(); - playerPart.pos.set(-size.x * 5 / 6, 0, 0); - playerPart.time = 1000000; - scene.add(player); - - let borderColor = 0x444444//0xcf1020 - let goalColor = 0xffffff//0xcf1020 - let border = new THREE.Mesh(new THREE.BoxGeometry( size.x * 2, 0.1, 0.15 ), new THREE.MeshStandardMaterial( {color: borderColor} )); - border.position.y = size.y; - scene.add(border); - let border2 = border.clone(); - border2.position.y = -size.y; - scene.add(border2) - - let border3 = new THREE.Mesh(new THREE.BoxGeometry( 0.1, size.y * 2 + 0.1, 0.15 ), new THREE.MeshStandardMaterial( {color: borderColor} )); - border3.position.x = -size.x; - scene.add(border3) - - let goal = new THREE.Mesh(new THREE.BoxGeometry( 0.1, size.y * 2 + 0.1, 0.15 ), new THREE.MeshStandardMaterial( {color: goalColor} )); - goal.position.x = size.x; - scene.add(goal) -} -function nextLevel() { - level++; - if(level >= levels.length) level = 0; - - particles[0].newNoise(levels[level][0]); - particles[1].newNoise(levels[level][1]); - console.log("level: " + level + " [" + levels[level][0] + ", " + levels[level][1] + "]"); -} -function switchActive() { - active++; - if(active >= particles.length) active = 0; - for(const element of particles) { - element.mesh.material.size = 1; - element.mesh.position.z = -0.1; - } - particles[active].mesh.material.size = 1.7; - particles[active].mesh.position.z = 0; -} -function resetPlayer() { - playerPart.reset(size); - playerPart.pos.set(-size.x * 5 / 6, 0, 0); - playerPart.time = 1000000; -} -function animate(now) { - requestAnimationFrame(animate); - - stats.begin(); - - // animation loop here - - let dt = clock.getDelta(); - //console.log(dt); - for(let i = 0; i < particles.length; i++) { - particles[i].update(dt, i); - } - if(!stop) { - - particles[active].applyNoiseForce(playerPart); - playerPart.update(dt, particles[active].maxSpeed); - player.position.copy(playerPart.pos); - if(playerPart.isDead(size)) { - if(playerPart.pos.x >= size.x) { - nextLevel(); - } - resetPlayer(); - } - camera.position.x = (playerPart.pos.x + camera.position.x * 49) / 50; - } - //console.log(playerPart.pos); - - // renderer.setClearAlpha(0.0); - // Create trail effect using WebGL - // renderer.setClearColor(0x000000, 0.1); - // renderer.clear(); - renderer.render(scene, camera); - - stats.end(); -} - -function onResize() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - - renderer.setSize(window.innerWidth, window.innerHeight); -} - -setupScene(); -animate(); diff --git a/src/main.ts b/src/main.ts index 0f89fb9..ece9193 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,64 +1,267 @@ -/* eslint-disable prefer-const */ -/* eslint-disable prettier/prettier */ -import { Application, Graphics, RenderTexture, Sprite } from "pixi.js"; -import { UberNoise } from "uber-noise"; -import ParticleSystem from "./particles"; -import 'pixi.js/math-extras'; +import * as THREE from "three"; + +// @ts-ignore import Stats from "stats.js"; +import { ParticleSystem } from "./particle-system"; +import { Particle } from "./particle"; -(async () => { - const app = new Application(); - await app.init({ background: "#000000", resizeTo: window }); +let stats: Stats = new Stats(); +stats.showPanel(0); +document.body.appendChild(stats.dom); - const noise = new UberNoise({ scale: 0.01}) - document.getElementById("pixi-container")!.appendChild(app.canvas); +const visibleArea = 4; +const trailHalfLife = 0.15; // seconds, adjust to taste +const tau = trailHalfLife / Math.log(2); - const renderTexture = RenderTexture.create({ width: 1000, height: 1000 }); +const innerRingRadius = 5; +const outerRingRadius = 7; - const sprite = new Sprite(renderTexture); - app.stage.addChild(sprite); +let renderer: THREE.WebGLRenderer, + scene: THREE.Scene, + camera: THREE.OrthographicCamera, + keys: { [key: string]: boolean } = {}, + clock: THREE.Clock, + particles: ParticleSystem[] = [], + player: THREE.Mesh, + playerPart: Particle, + active: number = 0, + size: THREE.Vector2, + stop: boolean = false, + helpCamera: THREE.PerspectiveCamera, + debugOn: boolean = false, + overlay: THREE.Mesh, + overlayScene: THREE.Scene, + overlayCamera: THREE.OrthographicCamera; - const particles = new ParticleSystem(); - let stats = new Stats(); - stats.showPanel( 1 ); // 0: fps, 1: ms, 2: mb, 3+: custom - document.body.appendChild( stats.dom ); +const levels: [number, number][] = [ + [89842, 74789], + [41742, 37680], + [78288, 60840], + [15693, 83395], + [54971, 5891], + [29338, 42504], + [83166, 59559], + [14271, 26324], + [87125, 3695], + [43298, 94833], + [12641, 84336], + [81706, 92840], + [81342, 18215], + [68226, 9387], + [50415, 61135], + [40356, 68917], + [21870, 34087], + [77604, 4641], + [35813, 32668], +]; - // app.stage.addChild(particles.container); - for(let i = 0; i < 5000; i++) { - particles.spawnParticle(); - } +let level: number = 0; - // add mostly transparent black to the render texture +function setupScene(): void { + renderer = new THREE.WebGLRenderer({ + antialising: true, + preserveDrawingBuffer: true, + } as any); - const black = new Graphics(); - black.rect(0, 0, 1000, 1000).fill({ color: 0x000000, alpha: 0.1 }); - particles.container.addChild(black); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.autoClearColor = false; - let noiseData: number[][][] = []; - for(let x = 0; x < 1000; x++) { - noiseData.push([]); - for(let y = 0; y < 1000; y++) { - noiseData[x].push([noise.get(x, y), noise.get(x + 5000, y - 1234)]); - } - } + document.body.appendChild(renderer.domElement); + + window.addEventListener("resize", onResize, false); + + scene = new THREE.Scene(); - console.log(noiseData); + // add black transparent rect + overlay = new THREE.Mesh( + new THREE.PlaneGeometry(100, 100), + new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + depthTest: false, + depthWrite: false, + }), + ); + overlay.position.set(0, 0, -10); + overlayScene = new THREE.Scene(); + overlayCamera = new THREE.OrthographicCamera(-1, 1, 1, -1); + overlayScene.add(overlay); + overlayCamera.position.z = 10; - app.ticker.add(() => { - stats.begin(); - particles.update(app.ticker.deltaTime, noiseData); + document.addEventListener( + "keydown", + (event: KeyboardEvent) => { + keys[event.key] = true; + if (event.key == " ") { + switchActive(); + } + if (event.key == "t") { + stop = !stop; + } + if (event.key == "l") { + nextLevel(); + } + if (event.key == "r") { + let s1 = Math.floor(Math.random() * 100000); + let s2 = Math.floor(Math.random() * 100000); + particles[0].newNoise(s1); + particles[1].newNoise(s2); + console.log("random level [" + s1 + ", " + s2 + "]"); + resetPlayer(); + } + if (event.key == "d") { + debugOn = !debugOn; + } + }, + false, + ); - app.renderer.render({ container: particles.container, target: renderTexture, clear: false }); - stats.end(); - + document.addEventListener("mouseup", () => { + switchActive(); }); - window.addEventListener("keydown", (e) => { - + size = new THREE.Vector2(3, 2); + + camera = new THREE.OrthographicCamera( + -visibleArea, + visibleArea, + visibleArea, + -visibleArea, + ); + camera.position.z = 10; + onResize(); + + clock = new THREE.Clock(); + + let part1: ParticleSystem = new ParticleSystem({ + size: size, + innerRingRadius: innerRingRadius, + outerRingRadius: outerRingRadius, + + seed: levels[level][0], + + colorGridOptions: { + gradientXStops: ["#ff0000", "#000000", "#000000", "#0000ff"], + gradientYStops: ["#ff0000", "#000000", "#000000", "#0000ff"], + detailX: 30, + detailY: 30, + }, }); + let part2: ParticleSystem = new ParticleSystem({ + size: size, + innerRingRadius: innerRingRadius, + outerRingRadius: outerRingRadius, + + seed: levels[level][1], - window.addEventListener("keyup", (e) => { - + colorGridOptions: { + gradientXStops: ["#00ff00", "#000000", "#000000", "#ffff00"], + gradientYStops: ["#00ff00", "#000000", "#000000", "#00ffff"], + detailX: 30, + detailY: 30, + }, }); -})(); + + particles.push(part1); + particles.push(part2); + + if (part1.mesh) scene.add(part1.mesh); + if (part2.mesh) scene.add(part2.mesh); + + switchActive(); + + player = new THREE.Mesh( + new THREE.SphereGeometry(0.07, 8, 8), + new THREE.MeshBasicMaterial({ color: 0xffffff }), + ); + playerPart = new Particle(); + playerPart.pos.set(0, 0); + playerPart.time = 1000000; + scene.add(player); +} +function nextLevel(): void { + level++; + if (level >= levels.length) level = 0; + + particles[0].newNoise(levels[level][0]); + particles[1].newNoise(levels[level][1]); + console.log( + "level: " + level + " [" + levels[level][0] + ", " + levels[level][1] + "]", + ); +} +function switchActive(): void { + active++; + if (active >= particles.length) active = 0; + for (const element of particles) { + if (!element.mesh) continue; + (element.mesh.material as THREE.PointsMaterial).size = 1; + element.mesh.visible = false; + } + + let activeMesh = particles[active].mesh; + if (!activeMesh) return; + + (activeMesh.material as THREE.PointsMaterial).size = 1; + activeMesh.position.z = 0; + activeMesh.visible = true; +} +function resetPlayer(): void { + playerPart.reset(camera); + playerPart.pos.set(0, 0); + playerPart.time = 1000000; + camera.position.x = 0; + camera.position.y = 0; + renderer.clear(); +} +function animate(): void { + requestAnimationFrame(animate); + + stats.begin(); + + let dt: number = clock.getDelta(); + + if (particles[0].mesh?.visible) particles[0].update(dt, camera); + if (particles[1].mesh?.visible) particles[1].update(dt, camera); + + if (!stop) { + particles[active].applyNoiseForce(playerPart, dt); + + playerPart.update(dt, particles[active].maxSpeed); + player.position.set(playerPart.pos.x, playerPart.pos.y, 0); + + camera.position.x = (playerPart.pos.x + camera.position.x * 49) / 50; + camera.position.y = (playerPart.pos.y + camera.position.y * 49) / 50; + } + + const alpha = 1 - Math.exp(-dt / tau); + (overlay.material as THREE.MeshBasicMaterial).opacity = alpha; + + renderer.render(overlayScene, overlayCamera); + renderer.render(scene, camera); + + stats.end(); +} + +function onResize(): void { + let aspect = window.innerWidth / window.innerHeight; + if (aspect > 1) { + camera.top = visibleArea / aspect; + camera.bottom = -visibleArea / aspect; + + camera.right = visibleArea; + camera.left = -visibleArea; + } else { + camera.top = visibleArea; + camera.bottom = -visibleArea; + + camera.right = visibleArea * aspect; + camera.left = -visibleArea * aspect; + } + camera.updateProjectionMatrix(); + + renderer.setSize(window.innerWidth, window.innerHeight); +} + +setupScene(); +animate(); diff --git a/src/new.ts b/src/new.ts deleted file mode 100644 index 20aa0f9..0000000 --- a/src/new.ts +++ /dev/null @@ -1,285 +0,0 @@ -import * as THREE from 'three'; - -import { UberNoise } from "uber-noise"; -// @ts-ignore -import Stats from "stats.js"; - -let stats = new Stats(); -stats.showPanel( 0 ); // 0: fps, 1: ms, 2: mb, 3+: custom -document.body.appendChild( stats.dom ); - -class Particle { - constructor(opt) { - opt = opt || {}; - - this.pos = new THREE.Vector3(0, 0, 0); - this.speed = new THREE.Vector3(0, 0, 0); - this.acc = new THREE.Vector3(0, 0, 0); - this.time = opt.time || Math.random() * 1.8 + 0.3; - if(opt.size) this.reset(opt.size); - } - reset(size) { - this.setPos((Math.random() - 0.5) * 2 * size.x, (Math.random() - 0.5) * 2 * size.y, 0); - this.acc.set(0, 0, 0); - this.speed.set(0, 0, 0); - this.time = Math.random() * 1.8 + 0.3 - } - setPos(x, y, z) { - this.pos.set(x, y, z); - } - update(dt, maxSpeed) { - this.speed.add(this.acc); - this.speed.clampLength(0, maxSpeed); - this.pos.add(this.speed); - this.time -= dt; - } - isDead(size) { - return (Math.abs(this.pos.x) > size.x || Math.abs(this.pos.y) > size.y || this.time < 0) - } -} - -class Particles { - constructor(opt) { - opt = opt || {}; - - this.num = opt.num || 10000; - - this.particleSize = opt.particleSize || 1; - this.size = opt.size || new THREE.Vector3(1, 1, 1); - - - this.vertexColors = opt.vertexColors || true; - this.color = opt.color || new THREE.Color(0xffffff); - this.maxSpeed = opt.maxSpeed || 0.02; - - this.minTime = opt.minTime || 0.01; - this.maxTime = opt.maxTime || 1; - this.newNoise(opt.seed || 0); - - this.createParticles(); - } - newNoise(seed) { - this.noise = new UberNoise({min: -0.01, max: 0.01, scale: 0.2, octaves: 2, persistence: 0.5, seed: seed}) - } - applyNoiseForce(p) { - p.acc.x = this.noise.get(p.pos.x, p.pos.y + 23, p.pos.z) + 0.002; - p.acc.y = this.noise.get(p.pos.x + 100, p.pos.y, p.pos.z); - } - - createParticles() { - this.geo = new THREE.BufferGeometry(); - - this.positionData = new Float32Array(this.num * 3); - - this.geo.setAttribute('position', new THREE.BufferAttribute(this.positionData, 3)); - - if(this.vertexColors) { - this.colorData = new Float32Array(this.num * 3); - this.geo.setAttribute('color', new THREE.BufferAttribute(this.colorData, 3)); - } - - this.parts = []; - for(let i = 0; i < this.num; i++) { - this.parts.push(new Particle({size: this.size})); - } - - var mat = new THREE.PointsMaterial({vertexColors: true, size: this.particleSize, sizeAttenuation: false, color: this.color}); - this.mesh = new THREE.Points(this.geo, mat); - } - update(dt: number, num: number) { - for(let i = 0; i < this.num; i++) { - let p = this.parts[i]; - - this.applyNoiseForce(p); - p.update(dt, this.maxSpeed); - if(p.isDead(this.size)) p.reset(this.size) - - this.positionData[i * 3] = p.pos.x; - this.positionData[i * 3 + 1] = p.pos.y; - this.positionData[i * 3 + 2] = p.pos.z; - - this.colorData[i * 3] = num * ((p.acc.x + 0.01) * 80 + 0.2); - this.colorData[i * 3 + 1] = (1 - num) * ((p.acc.x + 0.01) * 80 + 0.2); - this.colorData[i * 3 + 2] = (p.acc.y + 0.01) * 50 + 0.2; - - } - this.geo.attributes.position.needsUpdate = true; - - if(this.vertexColors) this.geo.attributes.color.needsUpdate = true; - - } - -} - - -var renderer, scene, light, camera, keys = {}, mesh, camNode, clock, particles = [], player, playerPart, active = 0, size, stop = false; - -const levels = [[89842, 74789], [41742, 37680], [78288, 60840], [15693, 83395], [54971, 5891], [29338, 42504], [83166, 59559], [14271, 26324], [87125, 3695], [43298, 94833], [12641, 84336], [81706, 92840], [81342, 18215], [68226, 9387], [50415, 61135], [40356, 68917], [21870, 34087], [77604, 4641], [35813, 32668]]; - -let level = 0; - -function setupScene() { - renderer = new THREE.WebGLRenderer({antialising: true, alpha: true, preserveDrawingBuffer: true, alpha: true}); - - renderer.setSize(window.innerWidth, window.innerHeight); - renderer.setPixelRatio(window.devicePixelRatio); - - // Enable alpha blending for trail effect - renderer.autoClearColor = false; // Don't auto-clear the canvas - - document.body.appendChild(renderer.domElement); - - window.addEventListener('resize', onResize, false); - - scene = new THREE.Scene({background: new THREE.Color(0xff0000)}); - - let ambi = new THREE.AmbientLight(0xffffff, 0.5); // soft white light - scene.add(ambi); - - let light = new THREE.DirectionalLight(0xffffff, 0.5); - scene.add(light); - let light2 = new THREE.DirectionalLight(0xffffff, 0.5); - light2.position.set(0, - 1, 0.5) - scene.add(light2); - - // add black transparent rect - let rect = new THREE.Mesh(new THREE.PlaneGeometry(100, 100), new THREE.MeshBasicMaterial({color: 0x000000, transparent: true, opacity: 0.15})); - rect.position.set(0, 0, 0); - scene.add(rect); - - document.addEventListener("keydown", (event) => { - keys[event.key] = true - if(event.key == " ") { - switchActive(); - } - if(event.key == "t") { - stop = !stop; - } - if(event.key == "l") { - nextLevel(); - } - if(event.key == "r") { - let s1 = Math.floor(Math.random() * 100000); - let s2 = Math.floor(Math.random() * 100000); - particles[0].newNoise(s1); - particles[1].newNoise(s2); - console.log("random level [" + s1 + ", " + s2 + "]"); - resetPlayer(); - } - }, false); - - document.addEventListener("mouseup", (event) => { - switchActive(); - }); - - let fogColor = new THREE.Color(0x000000); - scene.background = null; // Remove solid background for trail effect - // scene.fog = new THREE.Fog(fogColor, 4.5, 4.7); - - size = new THREE.Vector3(10, 2, .01); - - camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.2, 40); - camera.position.z = 4.5 - - clock = new THREE.Clock(); - - let part1 = new Particles({size: size, seed: levels[level][0]}); - let part2 = new Particles({size: size, seed: levels[level][1]}); - - particles.push(part1); - particles.push(part2); - scene.add(part1.mesh); - scene.add(part2.mesh); - switchActive(); - - player = new THREE.Mesh(new THREE.SphereGeometry( 0.07, 8, 8 ), new THREE.MeshBasicMaterial( {color: 0xffffff} )) - playerPart = new Particle(); - playerPart.pos.set(-size.x * 5 / 6, 0, 0); - playerPart.time = 1000000; - scene.add(player); - - let borderColor = 0x444444//0xcf1020 - let goalColor = 0xffffff//0xcf1020 - let border = new THREE.Mesh(new THREE.BoxGeometry( size.x * 2, 0.1, 0.15 ), new THREE.MeshStandardMaterial( {color: borderColor} )); - border.position.y = size.y; - scene.add(border); - let border2 = border.clone(); - border2.position.y = -size.y; - scene.add(border2) - - let border3 = new THREE.Mesh(new THREE.BoxGeometry( 0.1, size.y * 2 + 0.1, 0.15 ), new THREE.MeshStandardMaterial( {color: borderColor} )); - border3.position.x = -size.x; - scene.add(border3) - - let goal = new THREE.Mesh(new THREE.BoxGeometry( 0.1, size.y * 2 + 0.1, 0.15 ), new THREE.MeshStandardMaterial( {color: goalColor} )); - goal.position.x = size.x; - scene.add(goal) -} -function nextLevel() { - level++; - if(level >= levels.length) level = 0; - - particles[0].newNoise(levels[level][0]); - particles[1].newNoise(levels[level][1]); - console.log("level: " + level + " [" + levels[level][0] + ", " + levels[level][1] + "]"); -} -function switchActive() { - active++; - if(active >= particles.length) active = 0; - for(const element of particles) { - element.mesh.material.size = 1; - element.mesh.position.z = -0.1; - } - particles[active].mesh.material.size = 1.7; - particles[active].mesh.position.z = 0; -} -function resetPlayer() { - playerPart.reset(size); - playerPart.pos.set(-size.x * 5 / 6, 0, 0); - playerPart.time = 1000000; -} -function animate(now) { - requestAnimationFrame(animate); - - stats.begin(); - - // animation loop here - - let dt = clock.getDelta(); - //console.log(dt); - for(let i = 0; i < particles.length; i++) { - particles[i].update(dt, i); - } - if(!stop) { - - particles[active].applyNoiseForce(playerPart); - playerPart.update(dt, particles[active].maxSpeed); - player.position.copy(playerPart.pos); - if(playerPart.isDead(size)) { - if(playerPart.pos.x >= size.x) { - nextLevel(); - } - resetPlayer(); - } - camera.position.x = (playerPart.pos.x + camera.position.x * 49) / 50; - } - //console.log(playerPart.pos); - - // renderer.setClearAlpha(0.0); - // Create trail effect using WebGL - // renderer.setClearColor(0x000000, 0.1); - // renderer.clear(); - renderer.render(scene, camera); - - stats.end(); -} - -function onResize() { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - - renderer.setSize(window.innerWidth, window.innerHeight); -} - -setupScene(); -animate(0); diff --git a/src/particle-system.ts b/src/particle-system.ts new file mode 100644 index 0000000..2fc05ca --- /dev/null +++ b/src/particle-system.ts @@ -0,0 +1,202 @@ +import * as THREE from "three"; +import { UberNoise } from "uber-noise"; +import { Particle } from "./particle"; +import { createColorGrid, type ColorGridResult } from "./colors"; + +interface ParticlesOptions { + num?: number; + particleSize?: number; + size?: THREE.Vector2; + vertexColors?: boolean; + color?: THREE.Color; + maxSpeed?: number; + minTime?: number; + maxTime?: number; + seed?: number; + innerRingRadius?: number; + outerRingRadius?: number; + colorGridOptions?: { + gradientXStops: string[]; + gradientYStops: string[]; + detailX?: number; + detailY?: number; + }; +} + +export class ParticleSystem { + num: number; + particleSize: number; + size: THREE.Vector2; + vertexColors: boolean; + color: THREE.Color; + maxSpeed: number; + minTime: number; + maxTime: number; + noise: UberNoise | undefined; + geo: THREE.BufferGeometry | undefined; + positionData: Float32Array | undefined; + colorData: Float32Array | undefined; + parts: Particle[] = []; + mesh: THREE.Points | undefined; + + noiseDetail = 1000; + noiseData: number[][][] = []; + + colorGrid: ColorGridResult | undefined; + + innerRingRadius: number; + outerRingRadius: number; + + constructor(opt?: ParticlesOptions) { + opt = opt || {}; + + this.num = opt.num ?? 15000; + + this.particleSize = opt.particleSize ?? 1; + this.size = opt.size ?? new THREE.Vector2(1, 1); + + this.vertexColors = opt.vertexColors ?? true; + this.color = opt.color ?? new THREE.Color(0xffffff); + this.maxSpeed = opt.maxSpeed ?? 0.02; + + this.minTime = opt.minTime ?? 0.01; + this.maxTime = opt.maxTime ?? 1; + this.newNoise(opt.seed ?? 0); + + this.innerRingRadius = opt.innerRingRadius ?? 0; + this.outerRingRadius = opt.outerRingRadius ?? 0; + + this.createParticles(); + + this.colorGrid = createColorGrid({ + gradientXStops: opt.colorGridOptions?.gradientXStops ?? [ + "#0000ff", + "#ff0000", + ], // blue→red + gradientYStops: opt.colorGridOptions?.gradientYStops ?? [ + "#00ff00", + "#ffff00", + ], // green→yellow + detailX: opt.colorGridOptions?.detailX ?? 30, + detailY: opt.colorGridOptions?.detailY ?? 30, + }); + + // saveGridAsPNG(this.colorGrid.grid); + } + newNoise(seed: number) { + this.noise = new UberNoise({ + min: -0.01, + max: 0.01, + scale: 0.4, + warp: 0.02, + lacunarity: 0.5, + seed: seed, + }); + this.noiseData = []; + for (let x = 0; x < this.noiseDetail; x++) { + this.noiseData.push([]); + for (let y = 0; y < this.noiseDetail; y++) { + // turn x and y into -size.x and size.x + let x2 = x / this.noiseDetail; + let y2 = y / this.noiseDetail; + this.noiseData[x].push([ + this.noise.get(x2, y2), + this.noise.get(x2 + 100, y2 + 200), + ]); + } + } + } + applyNoiseForce(p: Particle, dt: number) { + if (!this.noise) return; + + // let x = Math.floor( + // ((p.pos.x + this.size.x) * this.noiseDetail) / (this.size.x * 2), + // ); + // let y = Math.floor( + // ((p.pos.y + this.size.x) * this.noiseDetail) / (this.size.x * 2), + // ); + + // if (x < 0 || x >= this.noiseDetail || y < 0 || y >= this.noiseDetail) + // return; + + // p.acc.x = this.noiseData[x][y][0] * dt * 20; + // p.acc.y = this.noiseData[x][y][1] * dt * 20; + p.acc.x = this.noise.get(p.pos.x, p.pos.y) * dt * 20; + p.acc.y = this.noise.get(p.pos.x + 123, p.pos.y - 543) * dt * 20; + } + + createParticles() { + this.geo = new THREE.BufferGeometry(); + + this.positionData = new Float32Array(this.num * 3); + + this.geo.setAttribute( + "position", + new THREE.BufferAttribute(this.positionData, 3), + ); + + if (this.vertexColors) { + this.colorData = new Float32Array(this.num * 3); + this.geo.setAttribute( + "color", + new THREE.BufferAttribute(this.colorData, 3), + ); + } else { + // @ts-ignore + this.colorData = undefined; + } + + this.parts = []; + for (let i = 0; i < this.num; i++) { + this.parts.push(new Particle()); + } + + const mat = new THREE.PointsMaterial({ + vertexColors: true, + size: this.particleSize, + sizeAttenuation: false, + color: this.color, + transparent: true, + opacity: 0.5, + }); + this.mesh = new THREE.Points(this.geo, mat); + this.mesh.frustumCulled = false; + } + update(dt: number, camera: THREE.OrthographicCamera) { + let color = new THREE.Color(0, 0, 0); + + for (let i = 0; i < this.num; i++) { + let p = this.parts[i]; + + if (p.isDead(camera)) { + p.reset(camera); + } + + this.applyNoiseForce(p, dt); + p.update(dt, this.maxSpeed); + + if (!this.positionData) return; + this.positionData[i * 3] = p.pos.x; + this.positionData[i * 3 + 1] = p.pos.y; + this.positionData[i * 3 + 2] = 0; + + if (this.colorData && this.colorGrid) { + this.colorGrid.getColor( + p.normalizedSpeed.x, + p.normalizedSpeed.y, + color, + ); + this.colorData[i * 3] = color.r; + this.colorData[i * 3 + 1] = color.g; + this.colorData[i * 3 + 2] = color.b; + } + } + if (!this.geo) return; + + (this.geo.attributes.position as THREE.BufferAttribute).needsUpdate = true; + + if (this.vertexColors && this.geo.attributes.color && this.colorData) { + (this.geo.attributes.color as THREE.BufferAttribute).needsUpdate = true; + } + } +} diff --git a/src/particle.ts b/src/particle.ts new file mode 100644 index 0000000..c16391f --- /dev/null +++ b/src/particle.ts @@ -0,0 +1,71 @@ +import * as THREE from "three"; + +interface ParticleOptions { + time?: number; +} + +const DEADZONE = 0.2; + +export class Particle { + pos: THREE.Vector2; + speed: THREE.Vector2; + acc: THREE.Vector2; + time: number; + + normalizedSpeed: THREE.Vector2; + + constructor(opt?: ParticleOptions) { + opt = opt || {}; + + this.pos = new THREE.Vector2(0, 0); + this.speed = new THREE.Vector2(0, 0); + this.acc = new THREE.Vector2(0, 0); + this.time = opt.time ?? Math.random() * 1.8 + 0.3; + + this.normalizedSpeed = new THREE.Vector2(0, 0); + } + reset(camera: THREE.OrthographicCamera) { + const halfWidth = (camera.right - camera.left) / 2 / camera.zoom; + const halfHeight = (camera.top - camera.bottom) / 2 / camera.zoom; + + const minX = camera.position.x - halfWidth - DEADZONE; + const maxX = camera.position.x + halfWidth + DEADZONE; + const minY = camera.position.y - halfHeight - DEADZONE; + const maxY = camera.position.y + halfHeight + DEADZONE; + + this.pos.set( + Math.random() * (maxX - minX) + minX, + Math.random() * (maxY - minY) + minY, + ); + this.acc.set(0, 0); + this.speed.set(0, 0); + this.time = Math.random() * 1.8 + 0.3; + } + update(dt: number, maxSpeed: number) { + this.speed.add(this.acc); + this.speed.clampLength(0, maxSpeed); + this.pos.x += this.speed.x * dt * 50; + this.pos.y += this.speed.y * dt * 50; + this.time -= dt; + + this.normalizedSpeed.set(this.speed.x / maxSpeed, this.speed.y / maxSpeed); + } + isDead(camera: THREE.OrthographicCamera): boolean { + // check if within camera bounds + + const halfWidth = (camera.right - camera.left) / 2 / camera.zoom; + const halfHeight = (camera.top - camera.bottom) / 2 / camera.zoom; + + const minX = camera.position.x - halfWidth - DEADZONE; + const maxX = camera.position.x + halfWidth + DEADZONE; + const minY = camera.position.y - halfHeight - DEADZONE; + const maxY = camera.position.y + halfHeight + DEADZONE; + + if (this.time < 0) return true; + + if (this.pos.x < minX || this.pos.x > maxX) return true; + if (this.pos.y < minY || this.pos.y > maxY) return true; + + return false; + } +} diff --git a/src/particles.ts b/src/particles.ts deleted file mode 100644 index 6b3286b..0000000 --- a/src/particles.ts +++ /dev/null @@ -1,155 +0,0 @@ -import * as PIXI from "pixi.js"; -import type UberNoise from "uber-noise"; - -export type ParticleOptions = { - x: number; - y: number; - size: number; - color: PIXI.ColorSource; - alpha: number; - speedX: number; - speedY: number; - maxAge: number; -}; - -class Particle extends PIXI.Graphics { - speedX: number = 0; - speedY: number = 0; - age: number = 0; - maxAge: number = 100; - initialSize: number = 1; - - maxSpeed: number = 2; - - speed = new PIXI.Point(0, 0); - acc = new PIXI.Point(0, 0); - - constructor() { - super(); - this.reset(new PIXI.Point(Math.random() * 1000, Math.random() * 1000)); - - this.rect(0, 0, 1, 1); - } - - update(dt: number, noise: number[][][]) { - let x = Math.max(0, Math.min(999, Math.floor(this.x))); - let y = Math.max(0, Math.min(999, Math.floor(this.y))); - - this.acc.x = noise[x][y][0]; - this.acc.y = noise[x][y][1]; - - this.speed.x += this.acc.x * dt * 0.1; - this.speed.y += this.acc.y * dt * 0.1; - - let l = this.speed.magnitude(); - if(l > this.maxSpeed) { - this.speed.normalize().multiplyScalar(this.maxSpeed, this.speed); - } - - if(this.x > 1000) { - this.x = 0; - } else if(this.x < 0) { - this.x = 1000; - } - - if(this.y > 1000) { - this.y = 0; - } else if(this.y < 0) { - this.y = 1000; - } - - let lastX = this.x; - let lastY = this.y; - - this.x += this.speed.x * dt * 0.1; - this.y += this.speed.y * dt * 0.1; - this.age += dt; - - this.clear(); - this.beginPath(); - - this.moveTo(lastX, lastY); - this.lineTo(this.x, this.y); - - // get angle between lastX, lastY and this.x, this.y - let angle = Math.atan2(this.y - lastY, this.x - lastX); - // get distance between lastX, lastY and this.x, this.y - // let distance = Math.sqrt((this.x - lastX) ** 2 + (this.y - lastY) ** 2); - - // this.scale.set(1, distance); - // this.rotation = angle; - - // color based on angle (to hue) - let hue = angle / (2 * Math.PI); - - this.stroke({ - color: `hsl(${(hue * 100).toFixed(1)}, 100%, 50%)`, - width: 5, - }); - } - - reset(position: PIXI.Point) { - this.x = position.x; - this.y = position.y; - this.acc.set(0, 0); - this.speed.set(0, 0); - this.age = 0; - this.maxAge = Math.random() * 100 + 10; - } - -} - -export default class ParticleSystem { - private readonly maxParticles: number; - public container: PIXI.Container; - private readonly particles: Particle[]; - private readonly particlePool: Particle[]; - - - constructor(maxParticles: number = 10000) { - this.maxParticles = maxParticles; - this.container = new PIXI.Container(); - this.container.zIndex = 10; - this.particles = []; - this.particlePool = []; - } - - - spawnParticle(): Particle | null { - let particle: Particle; - if (this.particlePool.length > 0) { - particle = this.particlePool.pop()!; - } else if (this.particles.length < this.maxParticles) { - particle = new Particle(); - particle.reset(new PIXI.Point(Math.random() * 1000, Math.random() * 1000)); - - this.container.addChild(particle); - } else { - return null; - } - - this.particles.push(particle); - - return particle; - } - - removeParticle(particle: Particle): void { - const index = this.particles.indexOf(particle); - if (index !== -1) { - particle.visible = false; - - this.particles.splice(index, 1); - this.particlePool.push(particle); - } - } - - update(deltaTime: number, noise: number[][][]): void { - for (let i = this.particles.length - 1; i >= 0; i--) { - this.particles[i].update(deltaTime, noise); - - if(this.particles[i].age > this.particles[i].maxAge) { - this.particles[i].reset(new PIXI.Point(Math.random() * 1000, Math.random() * 1000)); - } - } - } -}