Skip to content

Commit 0ee1474

Browse files
devsart95claude
andcommitted
feat(starfield): estrellas tipo universo con brillo, twinkle y spikes
- Sin líneas de conexión — estrellas independientes puras - 3 tipos: tiny (círculo simple), medium (glow suave), bright (glow + spikes) - ~400-520 estrellas según tamaño de pantalla - Movimiento muy lento y diferenciado por tipo - Twinkle animado con fase y velocidad únicas por estrella - Colores: blanco, azul-blanco, cálido, cian, lavanda - Spikes en cruz para el 65% de las estrellas bright - Gradiente radial en core de medium/bright - Light mode: alpha reducido al 25% Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a64c5b5 commit 0ee1474

1 file changed

Lines changed: 109 additions & 86 deletions

File tree

src/hooks/useStarfield.ts

Lines changed: 109 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
11
import { type RefObject, useEffect } from 'react'
22

3-
interface Particle {
3+
type StarType = 'tiny' | 'medium' | 'bright'
4+
5+
interface Star {
46
x: number; y: number
57
vx: number; vy: number
68
r: number
7-
baseAlpha: number
9+
glowR: number
810
alpha: number
9-
pulse: number // phase for opacity pulse
10-
pulseSpd: number
11-
node: boolean // larger "node" particle
11+
baseAlpha: number
12+
phase: number
13+
phaseSpd: number
14+
cr: number; cg: number; cb: number // color components
15+
type: StarType
16+
spikes: boolean
1217
}
1318

14-
// Dark theme colors
15-
const D_ACCENT: [number,number,number] = [56, 189, 248]
16-
const D_ACCENT2: [number,number,number] = [129, 140, 248]
17-
const D_WHITE: [number,number,number] = [255, 255, 255]
18-
// Light theme colors (visible on light bg)
19-
const L_ACCENT: [number,number,number] = [2, 132, 199]
20-
const L_ACCENT2: [number,number,number] = [79, 70, 229]
21-
const L_DARK: [number,number,number] = [15, 23, 42]
19+
// Star color palette — white, blue-white, warm-white, cyan-white, lavender
20+
const COLORS: [number, number, number][] = [
21+
[255, 255, 255],
22+
[210, 230, 255],
23+
[180, 210, 255],
24+
[255, 248, 210],
25+
[160, 225, 255],
26+
[220, 205, 255],
27+
[200, 240, 255],
28+
]
2229

2330
export function useStarfield(canvasRef: RefObject<HTMLCanvasElement | null>) {
2431
useEffect(() => {
@@ -30,12 +37,10 @@ export function useStarfield(canvasRef: RefObject<HTMLCanvasElement | null>) {
3037
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
3138

3239
let W = 0, H = 0
33-
let particles: Particle[] = []
40+
let stars: Star[] = []
3441
let t = 0, rafId = 0, paused = false
3542

36-
// How many particles based on screen area
37-
const count = () => Math.min(Math.floor((window.innerWidth * window.innerHeight) / 5500), 280)
38-
const CONNECT_DIST = 180 // px — max distance to draw a line
43+
const count = () => Math.min(Math.floor((window.innerWidth * window.innerHeight) / 2800), 520)
3944
const isLight = () => document.documentElement.dataset.theme === 'light'
4045

4146
function resize() {
@@ -48,101 +53,119 @@ export function useStarfield(canvasRef: RefObject<HTMLCanvasElement | null>) {
4853
ctx.scale(dpr, dpr)
4954
}
5055

51-
function mkParticle(): Particle {
52-
const isNode = Math.random() < 0.12 // ~12% are larger nodes
56+
function mkStar(): Star {
57+
const roll = Math.random()
58+
const type: StarType = roll < 0.12 ? 'bright' : roll < 0.38 ? 'medium' : 'tiny'
59+
const [cr, cg, cb] = COLORS[Math.floor(Math.random() * COLORS.length)]
60+
61+
const r = type === 'bright' ? rnd(1.6, 2.8) : type === 'medium' ? rnd(0.8, 1.5) : rnd(0.25, 0.75)
62+
const speed = type === 'bright' ? 0.04 : type === 'medium' ? 0.07 : 0.12
63+
5364
return {
5465
x: rnd(0, W / dpr),
5566
y: rnd(0, H / dpr),
56-
vx: rnd(-0.5, 0.5),
57-
vy: rnd(-0.4, 0.4),
58-
r: isNode ? rnd(2.2, 3.8) : rnd(0.8, 1.8),
59-
baseAlpha: isNode ? rnd(0.6, 0.9) : rnd(0.25, 0.65),
67+
vx: rnd(-speed, speed),
68+
vy: rnd(-speed * 0.8, speed * 0.8),
69+
r,
70+
glowR: type === 'bright' ? rnd(10, 20) : rnd(3, 7),
71+
baseAlpha: type === 'bright' ? rnd(0.75, 1.0) : type === 'medium' ? rnd(0.4, 0.75) : rnd(0.15, 0.55),
6072
alpha: 0,
61-
pulse: rnd(0, PI2),
62-
pulseSpd: rnd(0.006, 0.018),
63-
node: isNode,
73+
phase: rnd(0, PI2),
74+
phaseSpd: type === 'bright' ? rnd(0.004, 0.012) : rnd(0.01, 0.03),
75+
cr, cg, cb,
76+
type,
77+
spikes: type === 'bright' && Math.random() < 0.65,
6478
}
6579
}
6680

6781
function init() {
68-
particles = Array.from({ length: count() }, mkParticle)
82+
stars = Array.from({ length: count() }, mkStar)
6983
}
7084

71-
// Pick a color for a particle — theme-aware
72-
function pickColor(p: Particle): [number, number, number] {
73-
const h = p.x / (W / dpr) // positional tint
74-
if (isLight()) {
75-
if (h < 0.33) return L_ACCENT
76-
if (h < 0.66) return L_DARK
77-
return L_ACCENT2
85+
function drawSpikes(x: number, y: number, r: number, cr: number, cg: number, cb: number, a: number) {
86+
const len = r * 14
87+
const lw = r * 0.7
88+
for (let i = 0; i < 4; i++) {
89+
const angle = (i * Math.PI) / 2
90+
const ex = x + Math.cos(angle) * len
91+
const ey = y + Math.sin(angle) * len
92+
const grd = ctx.createLinearGradient(x, y, ex, ey)
93+
grd.addColorStop(0, `rgba(${cr},${cg},${cb},${a * 0.75})`)
94+
grd.addColorStop(0.4, `rgba(${cr},${cg},${cb},${a * 0.2})`)
95+
grd.addColorStop(1, `rgba(${cr},${cg},${cb},0)`)
96+
ctx.beginPath()
97+
ctx.moveTo(x, y)
98+
ctx.lineTo(ex, ey)
99+
ctx.strokeStyle = grd
100+
ctx.lineWidth = lw
101+
ctx.stroke()
78102
}
79-
if (h < 0.33) return D_ACCENT
80-
if (h < 0.66) return D_WHITE
81-
return D_ACCENT2
82103
}
83104

84105
function draw() {
85106
const W2 = W / dpr, H2 = H / dpr
86107
ctx.clearRect(0, 0, W2, H2)
87108
t++
88109

89-
// update positions
90-
particles.forEach(p => {
91-
p.x += p.vx
92-
p.y += p.vy
93-
94-
// wrap around edges with a soft margin
95-
if (p.x < -20) p.x = W2 + 20
96-
if (p.x > W2 + 20) p.x = -20
97-
if (p.y < -20) p.y = H2 + 20
98-
if (p.y > H2 + 20) p.y = -20
99-
100-
p.alpha = p.baseAlpha * (0.55 + 0.45 * Math.sin(t * p.pulseSpd + p.pulse))
101-
})
102-
103-
// draw connections
104-
for (let i = 0; i < particles.length; i++) {
105-
const a = particles[i]
106-
for (let j = i + 1; j < particles.length; j++) {
107-
const b = particles[j]
108-
const dx = a.x - b.x
109-
const dy = a.y - b.y
110-
const dist = Math.sqrt(dx * dx + dy * dy)
111-
if (dist > CONNECT_DIST) continue
112-
113-
const lineAlpha = (1 - dist / CONNECT_DIST) * 0.18 * Math.min(a.alpha, b.alpha) * 2
114-
const [r, g, bv] = pickColor(a)
115-
ctx.beginPath()
116-
ctx.moveTo(a.x, a.y)
117-
ctx.lineTo(b.x, b.y)
118-
ctx.strokeStyle = `rgba(${r},${g},${bv},${lineAlpha})`
119-
ctx.lineWidth = 0.6
120-
ctx.stroke()
121-
}
122-
}
110+
const lightMode = isLight()
123111

124-
// draw particles
125-
particles.forEach(p => {
126-
const [r, g, b] = pickColor(p)
127-
const a = p.alpha
112+
for (const s of stars) {
113+
// drift
114+
s.x += s.vx
115+
s.y += s.vy
116+
if (s.x < -15) s.x = W2 + 15
117+
if (s.x > W2 + 15) s.x = -15
118+
if (s.y < -15) s.y = H2 + 15
119+
if (s.y > H2 + 15) s.y = -15
128120

129-
// glow halo for nodes
130-
if (p.node) {
131-
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 6)
132-
grd.addColorStop(0, `rgba(${r},${g},${b},${a * 0.25})`)
133-
grd.addColorStop(1, `rgba(${r},${g},${b},0)`)
121+
// twinkle
122+
const tw = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(t * s.phaseSpd + s.phase))
123+
// light mode: reduce visibility significantly
124+
const alphaScale = lightMode ? 0.25 : 1
125+
s.alpha = s.baseAlpha * tw * alphaScale
126+
127+
const { cr, cg, cb } = s
128+
const a = s.alpha
129+
130+
if (s.type === 'tiny') {
131+
// simple dot only — no gradient cost
134132
ctx.beginPath()
135-
ctx.arc(p.x, p.y, p.r * 6, 0, PI2)
136-
ctx.fillStyle = grd
133+
ctx.arc(s.x, s.y, s.r, 0, PI2)
134+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${a})`
137135
ctx.fill()
136+
continue
138137
}
139138

140-
// core dot
139+
// outer glow
140+
const grd = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.glowR)
141+
if (s.type === 'bright') {
142+
grd.addColorStop(0, `rgba(${cr},${cg},${cb},${a * 0.35})`)
143+
grd.addColorStop(0.3, `rgba(${cr},${cg},${cb},${a * 0.12})`)
144+
grd.addColorStop(1, `rgba(${cr},${cg},${cb},0)`)
145+
} else {
146+
grd.addColorStop(0, `rgba(${cr},${cg},${cb},${a * 0.2})`)
147+
grd.addColorStop(1, `rgba(${cr},${cg},${cb},0)`)
148+
}
141149
ctx.beginPath()
142-
ctx.arc(p.x, p.y, p.r, 0, PI2)
143-
ctx.fillStyle = `rgba(${r},${g},${b},${a})`
150+
ctx.arc(s.x, s.y, s.glowR, 0, PI2)
151+
ctx.fillStyle = grd
144152
ctx.fill()
145-
})
153+
154+
// spikes for bright stars
155+
if (s.spikes) {
156+
drawSpikes(s.x, s.y, s.r, cr, cg, cb, a)
157+
}
158+
159+
// bright core with radial gradient
160+
const core = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r * 2)
161+
core.addColorStop(0, `rgba(${cr},${cg},${cb},${a})`)
162+
core.addColorStop(0.4, `rgba(${cr},${cg},${cb},${a * 0.8})`)
163+
core.addColorStop(1, `rgba(${cr},${cg},${cb},0)`)
164+
ctx.beginPath()
165+
ctx.arc(s.x, s.y, s.r * 2, 0, PI2)
166+
ctx.fillStyle = core
167+
ctx.fill()
168+
}
146169
}
147170

148171
function loop() {

0 commit comments

Comments
 (0)