feat(gioser): shake on click, mouseleave rebound, element particles, taskbar

Renderer (gioser-canvas-web):
- Spring shake (SpringDamper1, 7.5 Hz / ζ=0.13) aplicado como rotación Z
  en el MVP. impulse_click() inyecta velocidad alternada → vibración fuerte
  con ~5 ciclos decayendo en ~0.8s.
- release_tilt() pone target del tilt en (0,0) → la chacana cae al frente
  con el rebote natural del spring sub-crítico.
- world_scale_for_aspect(): en portrait (aspect<1) escala baja proporcional
  para que el aro exterior no se corte por los lados. Base 1.05, piso 0.45.
- click_radius_css_px() expone radio del aro en CSS-pixels desde el centro
  del canvas; la app lo usa para hit-test del impulso.
- set_client_size() separa CSS-pixels de device-pixels (DPR).
- tilt_degrees() ahora retorna (pitch, yaw, roll) — el brand replica los 3.
- 4 nuevos uniforms u_aire/fuego/tierra/agua_color para el shader de
  partículas.

Shader (gioser-shaders/FS_CHACANA):
- Función element_particles(tip, outward, color, kind) → 4 partículas por
  cardinal con personalidad: AIRE drift+sway, FUEGO rise+flicker (siempre
  hacia +Y), TIERRA cae, AGUA ondula descendiendo. Gauss + envelope
  sinusoidal en la vida. ~16 partículas total, costo modesto.

App (gioser-web):
- pointerdown en canvas → si distancia al centro < click_radius_css_px →
  impulse_click(). Touch y mouse vienen unificados por PointerEvent.
- mouseleave en canvas → release_tilt(). Sin set_target, el spring se
  quedaría en la última posición — ahora vuelve al frente con rebote.
- position_tips ahora clampea raw_x/raw_y a [margin, viewport - taskbar -
  margin] en CSS pixels. Los botones NUNCA salen del canvas ni cubren la
  taskbar incluso en aspect extremos o tilt máximo.
- AppState + TaskbarState (RefCell): trackea drawers abiertos + activo.
  open_tab/switch_tab/close_tab/home aplican mutación + sync().
- sync() rebuild de taskbar-list innerHTML por cada cambio de estado,
  más swap de body classes + drawer .open classes.
- Click delegation en taskbar-list — un listener para todas las cajitas.
- Botón home con data-home en la barra (svg de casa) cierra todo y
  limpia el taskbar.
- Escape también cierra el drawer activo.
- update_tilt_css ahora setea --tilt-z también — brand title roll
  visible en el shake.

CSS:
- .drawer bottom: 52px (reserva taskbar).
- .taskbar full ancho fixed bottom, glass + gold border, scrollable horiz
  para muchas cajitas.
- .taskbar-item con --task-color por elemento (aire/fuego/tierra/agua),
  .active glow del color + inset border bottom.
- .taskbar-home con svg de casa dorado, hover glow.
- Responsive: taskbar 46px en mobile + ajustes.
- .brand transform agrega rotateZ(--tilt-z) para que el título vibre con
  la chacana en click impulses.

Workspace verde + 18 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-14 01:46:05 +00:00
parent fce630c8d0
commit 5e0fcae4b4
6 changed files with 683 additions and 156 deletions
@@ -1,18 +1,14 @@
//! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla.
//!
//! Es agnóstico del DOM: el caller monta el `<canvas>`, le pasa eventos
//! de mouse y llama `render(time_ms)` desde un `requestAnimationFrame`.
//!
//! ```ignore
//! let mut r = Renderer::new(&canvas)?;
//! r.resize(w, h);
//! r.set_mouse_px(dx, dy);
//! r.render(time_ms);
//! ```
//! El loop externo (típicamente `requestAnimationFrame`) llama `render(time_ms)`.
//! Los eventos input se propagan vía métodos: `set_mouse_px`, `release_tilt`,
//! `impulse_click`. El cliente puede consultar dimensiones derivadas
//! (`click_radius_css_px`, `tilt_degrees`, `cardinal_positions_ndc`) para
//! sincronizar DOM (botones, título, taskbar).
use gioser_geom::ChacanaSpec;
use gioser_palette::{cosmos, Rgb};
use gioser_physics::SpringDamper2;
use gioser_physics::{SpringDamper1, SpringDamper2};
use gioser_shaders::{
chacana_quad, FS_CHACANA, FS_COSMOS, FULLSCREEN_QUAD, VS_CHACANA, VS_FULLSCREEN,
};
@@ -26,22 +22,23 @@ use web_sys::{
const RAD: f32 = core::f32::consts::PI / 180.0;
const DEG: f32 = 180.0 / core::f32::consts::PI;
/// Inclinación máxima en cada eje. 28° = movimiento bien legible pero
/// no caricaturesco; la chacana se siente "pesada y noble".
/// Inclinación máxima en cada eje.
const MAX_TILT_DEG: f32 = 28.0;
/// Escala mundo→viewport: con arm_extent=0.65 + aro a 1.45×, la chacana
/// + aro entran cómodos con margen para botones DOM más allá del aro.
const WORLD_SCALE: f32 = 1.05;
/// `cot(45°/2)` — factor de proyección. Lo necesitamos también para calcular
/// el radio del círculo en pixels (hit-test del click).
const COT_HALF_FOV: f32 = 2.414_213_5;
/// Distancia del aro principal respecto al centro de la chacana — sincronizar
/// con `FS_CHACANA::ringR_main` del shader.
const RING_FACTOR: f32 = 1.45;
/// Identidad de cada cardinal (id, color de acento, label visible).
/// Orden `[N, E, S, W]` coincide con `ChacanaSpec::tips()`.
/// Identidad de cada cardinal (id, color de acento, label). Orden `[N, E, S, W]`.
pub mod tips {
use gioser_palette::{elements, Rgb};
pub const ORDER: [(&str, Rgb, &str); 4] = [
("aire", elements::AIRE, "AIRE"), // N
("fuego", elements::FUEGO, "FUEGO"), // E
("tierra", elements::TIERRA, "TIERRA"), // S
("agua", elements::AGUA, "AGUA"), // W
("aire", elements::AIRE, "AIRE"),
("fuego", elements::FUEGO, "FUEGO"),
("tierra", elements::TIERRA, "TIERRA"),
("agua", elements::AGUA, "AGUA"),
];
}
@@ -53,10 +50,19 @@ pub struct Renderer {
chacana_vao: WebGlVertexArrayObject,
chacana_quad_count: i32,
chacana: ChacanaSpec,
/// Spring del tilt 3D que sigue al mouse. Sub-crítico orgánico.
tilt: SpringDamper2,
/// Spring de "vibración" tras click: rotación Z bien underdamped que
/// decae naturalmente. Independiente del tilt.
shake: SpringDamper1,
/// Contador para alternar sentido del shake en clicks sucesivos.
click_count: u32,
sun_pulse: f32,
last_time_ms: f64,
/// Dimensiones device-pixel del canvas (lo que GL viewport usa).
viewport: (u32, u32),
/// Dimensiones CSS-pixel del canvas (lo que ven los eventos DOM).
client_size: (f32, f32),
/// Mouse en clip-space, x ∈ [-aspect, aspect], y ∈ [-1, 1].
mouse: (f32, f32),
}
@@ -137,6 +143,21 @@ fn upload_quad(
Ok((vao, (verts.len() / 2) as i32))
}
/// Devuelve el factor de escala mundo→viewport en función del aspect.
/// Para portrait (aspect < 1), achicamos proporcionalmente para que la
/// circunferencia exterior no se corte por los lados.
fn world_scale_for_aspect(aspect: f32) -> f32 {
let base = 1.05;
if aspect >= 1.0 {
base
} else {
// En portrait, el extent visible horizontal se reduce con `aspect`.
// Bajamos la escala para mantener el aro entero dentro del viewport,
// con piso 0.45 para que no quede ridículamente pequeña.
(base * aspect.max(0.45)).min(base)
}
}
impl Renderer {
pub fn new(canvas: &HtmlCanvasElement) -> Result<Self, JsValue> {
let gl = canvas
@@ -176,6 +197,10 @@ impl Renderer {
"u_rim_color",
"u_sun_color",
"u_dark_color",
"u_aire_color",
"u_fuego_color",
"u_tierra_color",
"u_agua_color",
"u_sun_pulse",
],
)
@@ -187,6 +212,9 @@ impl Renderer {
upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
let tilt = SpringDamper2::new(1.7, 0.65);
// Shake: alta frecuencia, muy underdamped → vibración fuerte que
// muere en ~0.8 s con varios ciclos visibles.
let shake = SpringDamper1::new(7.5, 0.13);
Ok(Self {
gl,
@@ -197,9 +225,15 @@ impl Renderer {
chacana_quad_count,
chacana,
tilt,
shake,
click_count: 0,
sun_pulse: 0.0,
last_time_ms: 0.0,
viewport: (canvas.width().max(1), canvas.height().max(1)),
client_size: (
canvas.client_width().max(1) as f32,
canvas.client_height().max(1) as f32,
),
mouse: (0.0, 0.0),
})
}
@@ -210,6 +244,12 @@ impl Renderer {
.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
}
/// Tamaño en CSS pixels (independiente del DPR). Lo usa el hit-test del
/// click para que coincida con coordenadas DOM.
pub fn set_client_size(&mut self, w: f32, h: f32) {
self.client_size = (w.max(1.0), h.max(1.0));
}
pub fn set_mouse_px(&mut self, x: f32, y: f32) {
let (w, h) = self.viewport;
if h == 0 {
@@ -225,14 +265,41 @@ impl Renderer {
self.tilt.set_target(target);
}
/// Mouse fuera del canvas — la chacana vuelve al frente con rebote
/// natural del spring sub-crítico.
pub fn release_tilt(&mut self) {
self.tilt.set_target([0.0, 0.0]);
// mouse parallax (fondo) también vuelve al centro
self.mouse = (0.0, 0.0);
}
/// Inyecta un impulso al spring shake — la chacana vibra fuerte y decae.
/// Llamar en respuesta a un click/tap dentro del aro.
pub fn impulse_click(&mut self) {
self.click_count = self.click_count.wrapping_add(1);
let dir = if self.click_count % 2 == 0 { 1.0 } else { -1.0 };
// Magnitud del impulso en rad/s. Con ω≈47, esto produce un pico
// de ~5-7° en la rotación Z, decayendo en ~0.8 s.
self.shake.velocity[0] += 6.5 * dir;
}
/// Radio del aro exterior, en CSS pixels desde el centro del canvas.
/// El cliente lo usa para decidir si un click cae dentro del círculo.
pub fn click_radius_css_px(&self) -> f32 {
let (w, _h) = self.viewport;
let aspect = w as f32 / self.viewport.1.max(1) as f32;
let scale = world_scale_for_aspect(aspect);
let ring_ndc = self.chacana.arm_extent() * RING_FACTOR * scale * COT_HALF_FOV / 2.6;
ring_ndc * self.client_size.1 / 2.0
}
/// Posición proyectada NDC de cada tip cardinal `[N, E, S, W]`.
pub fn tips_ndc(&self) -> [(f32, f32); 4] {
self.points_ndc(&self.chacana.tips())
}
/// Posición NDC de un punto en cualquier radio cardinal (factor sobre
/// `arm_extent`). Útil para anclar los botones DOM más allá de la chacana
/// pero dentro del aro.
/// Posiciones NDC para anclar botones en los 4 cardinales a un radio
/// específico (factor sobre `arm_extent`).
pub fn cardinal_positions_ndc(&self, radius_factor: f32) -> [(f32, f32); 4] {
let r = self.chacana.arm_extent() * radius_factor;
self.points_ndc(&[(0.0, r), (r, 0.0), (0.0, -r), (-r, 0.0)])
@@ -257,22 +324,26 @@ impl Renderer {
self.mouse
}
/// Devuelve `(pitch_deg, yaw_deg)` actuales del spring de tilt.
/// El caller los inyecta como CSS vars en el contenedor del título para
/// que el HTML se tumbe junto con la chacana renderizada en GL.
pub fn tilt_degrees(&self) -> (f32, f32) {
(self.tilt.position[0] * DEG, self.tilt.position[1] * DEG)
/// `(pitch_deg, yaw_deg, roll_deg)` actuales. Roll viene del shake spring.
pub fn tilt_degrees(&self) -> (f32, f32, f32) {
(
self.tilt.position[0] * DEG,
self.tilt.position[1] * DEG,
self.shake.position[0] * DEG,
)
}
fn build_mvp(&self) -> Mat4 {
let (w, h) = self.viewport;
let aspect = w as f32 / h as f32;
let scale_val = world_scale_for_aspect(aspect);
let proj = Mat4::perspective_rh(45.0_f32.to_radians(), aspect, 0.1, 20.0);
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 2.6), Vec3::ZERO, Vec3::Y);
let pitch = Mat4::from_rotation_x(self.tilt.position[0]);
let yaw = Mat4::from_rotation_y(self.tilt.position[1]);
let scale = Mat4::from_scale(Vec3::splat(WORLD_SCALE));
proj * view * yaw * pitch * scale
let roll = Mat4::from_rotation_z(self.shake.position[0]);
let scale = Mat4::from_scale(Vec3::splat(scale_val));
proj * view * yaw * pitch * roll * scale
}
pub fn render(&mut self, time_ms: f64) {
@@ -282,11 +353,17 @@ impl Renderer {
((time_ms - self.last_time_ms) as f32 / 1000.0).clamp(0.0, 1.0 / 15.0)
};
self.last_time_ms = time_ms;
let sub = 4;
// Subdividir físico — el shake corre a alta frecuencia y necesita
// dt < 1/freq para mantenerse estable (1/7.5 ≈ 133 ms; 8 sub-pasos a
// 60fps dejan 2 ms por sub-paso).
let sub = 8;
let sub_dt = dt / sub as f32;
for _ in 0..sub {
self.tilt.step(sub_dt);
self.shake.step(sub_dt);
}
let t = time_ms as f32 * 0.001;
self.sun_pulse = 0.5 + 0.5 * (t * 1.4).sin();
@@ -316,7 +393,7 @@ impl Renderer {
gl.bind_vertex_array(Some(&self.cosmos_vao));
gl.draw_arrays(GL::TRIANGLES, 0, 6);
// Chacana (blend aditivo para que dorado y sol sumen luz al cosmos)
// Chacana (blend aditivo)
gl.blend_func(GL::SRC_ALPHA, GL::ONE);
gl.use_program(Some(&self.chacana_prog.program));
let mvp = self.build_mvp();
@@ -339,6 +416,26 @@ impl Renderer {
upload_rgb(gl, self.chacana_prog.u("u_rim_color"), cosmos::CHACANA_RIM);
upload_rgb(gl, self.chacana_prog.u("u_sun_color"), cosmos::SUN_CORE);
upload_rgb(gl, self.chacana_prog.u("u_dark_color"), cosmos::CHACANA_DARK);
upload_rgb(
gl,
self.chacana_prog.u("u_aire_color"),
gioser_palette::elements::AIRE,
);
upload_rgb(
gl,
self.chacana_prog.u("u_fuego_color"),
gioser_palette::elements::FUEGO,
);
upload_rgb(
gl,
self.chacana_prog.u("u_tierra_color"),
gioser_palette::elements::TIERRA,
);
upload_rgb(
gl,
self.chacana_prog.u("u_agua_color"),
gioser_palette::elements::AGUA,
);
if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
gl.uniform1f(Some(u), self.sun_pulse);
}
@@ -186,10 +186,21 @@ uniform vec3 u_line_color;
uniform vec3 u_rim_color;
uniform vec3 u_sun_color;
uniform vec3 u_dark_color;
uniform vec3 u_aire_color;
uniform vec3 u_fuego_color;
uniform vec3 u_tierra_color;
uniform vec3 u_agua_color;
uniform float u_sun_pulse;
const float PI = 3.14159265;
float hash21c(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float hash11c(float n) {
return fract(sin(n * 78.233) * 43758.5453);
}
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
@@ -214,6 +225,65 @@ float sdChacana(vec2 p, float s, float c) {
return d;
}
// Emisor de partículas por tip cardinal. Cada elemento tiene su propio
// patrón de velocidad para sentirse vivo:
// AIRE → drift hacia afuera con sway lateral (viento)
// FUEGO → asciende erráticamente con flicker amplio
// TIERRA→ cae con gravedad y rebote sutil
// AGUA → ondula descendiendo (gotas que se deslizan)
//
// `element_kind`: 0=AIRE, 1=FUEGO, 2=TIERRA, 3=AGUA.
// `outward`: dirección unitaria desde el centro hacia el tip.
vec3 element_particles(vec2 p, vec2 tip, vec2 outward, vec3 color, int kind, float seed_base) {
vec3 accum = vec3(0.0);
vec2 perp = vec2(-outward.y, outward.x);
// 4 partículas por tip — suficiente densidad sin saturar el costo del frag.
for (int k = 0; k < 4; k++) {
float seed = seed_base + float(k) * 1.31;
float life = 1.5 + hash11c(seed * 11.0) * 0.7;
float t_seeded = u_time + seed * 9.3;
float phase = mod(t_seeded, life);
float ph = phase / life; // 0..1
// Random offsets por época (cuando el ciclo reinicia).
float epoch = floor(t_seeded / life);
vec2 jitter = vec2(
hash21c(vec2(seed, epoch)) - 0.5,
hash21c(vec2(epoch, seed)) - 0.5
);
// Velocidad por elemento — distinto carácter visual.
vec2 vel;
float sway = sin(u_time * 4.0 + seed * 7.3);
if (kind == 0) {
// AIRE: drift hacia afuera + sway perpendicular notable.
vel = outward * 0.14 + perp * sway * 0.10;
} else if (kind == 1) {
// FUEGO: rise erratic — siempre con componente +Y (hacia arriba en el mundo),
// independiente del tip → flamas suben.
float erratic = sin(u_time * 6.0 + seed * 11.0) * 0.06;
vel = outward * 0.10 + vec2(erratic, 0.18 + 0.04 * sway);
} else if (kind == 2) {
// TIERRA: cae — outward más componente -Y.
vel = outward * 0.05 + vec2(0.03 * sway, -0.16);
} else {
// AGUA: drift outward con descenso y ondulación.
float wave = sin(u_time * 3.2 + seed * 8.7) * 0.07;
vel = outward * 0.12 + vec2(wave, -0.08);
}
vec2 pos = tip + vel * phase + jitter * 0.04;
// Brillo gauss + envelope sinusoidal en la vida.
float bright = sin(ph * PI);
float dist = length(p - pos);
float size = 0.014 + 0.006 * (kind == 1 ? sway : 0.0); // fuego pulsa
float glow = exp(-(dist * dist) / (2.0 * size * size));
accum += color * glow * bright;
}
return accum;
}
// 3 puntos pequeños en cada uno de los 4 cardinales sobre el aro grueso.
float cardinal_dots(vec2 p, float ringR, float dotSize) {
float r = length(p);
@@ -282,6 +352,15 @@ void main() {
// 4 grupos de 3 puntos cardinales sobre el aro principal.
float dots = cardinal_dots(p, ringR_main, 0.008) * 1.10;
// === PARTÍCULAS POR ELEMENTO ===
// Cada tip emite partículas con la personalidad del elemento.
float L = u_arm_extent;
vec3 particles = vec3(0.0);
particles += element_particles(p, vec2(0.0, L), vec2(0.0, 1.0), u_aire_color, 0, 0.31);
particles += element_particles(p, vec2( L, 0.0), vec2( 1.0, 0.0), u_fuego_color, 1, 1.73);
particles += element_particles(p, vec2(0.0, -L), vec2(0.0, -1.0), u_tierra_color, 2, 3.11);
particles += element_particles(p, vec2(-L, 0.0), vec2(-1.0, 0.0), u_agua_color, 3, 5.97);
// === COMPOSICIÓN ===
vec3 col = vec3(0.0);
// Sol detrás (clip a interior).
@@ -295,9 +374,11 @@ void main() {
col += u_line_color * ring_main * 1.45;
col += u_rim_color * ring_inner * 1.05;
col += u_line_color * dots * 1.85;
col += particles * 1.25;
float alpha = clamp(
halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12,
halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12
+ (particles.r + particles.g + particles.b) * 0.5,
0.0, 1.0);
fragColor = vec4(col, alpha);
}