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>
5e0fcae4b438491c340d858d9a907103d366593e · por sergio · 2026-05-14T01:46:05Z (hace 5 meses) · padre fce630c8d · ver el árbol en este commit
Delta semántico · 43
crates/apps/gioser-web/src/lib.rs
crates/modules/gioser/gioser-canvas-web/src/lib.rs
crates/modules/gioser/gioser-shaders/src/lib.rs
diff --git a/crates/apps/gioser-web/Cargo.toml b/crates/apps/gioser-web/Cargo.toml
index e666b52..9235d8e 100644
--- a/crates/apps/gioser-web/Cargo.toml
+++ b/crates/apps/gioser-web/Cargo.toml
@@ -22,6 +22,7 @@ features = [
"Window",
"Document",
"Element",
+ "Node",
"HtmlElement",
"HtmlCanvasElement",
"CssStyleDeclaration",
@@ -30,6 +31,7 @@ features = [
"Event",
"EventTarget",
"MouseEvent",
+ "PointerEvent",
"KeyboardEvent",
"NodeList",
"Performance",
diff --git a/crates/apps/gioser-web/index.html b/crates/apps/gioser-web/index.html
index 7d5173a..884361d 100644
--- a/crates/apps/gioser-web/index.html
+++ b/crates/apps/gioser-web/index.html
@@ -105,6 +105,20 @@
<section class="drawer-content" id="drawer-agua-content"></section>
</aside>
+ <!-- TASKBAR estilo Windows: home a la izquierda + cajitas dinámicas
+ de las vistas MD abiertas. Sincronizada por WASM. -->
+ <nav class="taskbar" aria-label="Vistas abiertas">
+ <button class="taskbar-home" data-home aria-label="Volver al home" type="button">
+ <svg viewBox="0 0 24 24" class="taskbar-home-glyph" aria-hidden="true">
+ <path d="M3 12 L12 3 L21 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
+ <path d="M5 11 V20 H10 V14 H14 V20 H19 V11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
+ <circle cx="12" cy="17" r="0.8" fill="currentColor"/>
+ </svg>
+ </button>
+ <span class="taskbar-divider" aria-hidden="true"></span>
+ <ul class="taskbar-list" id="taskbar-list" role="presentation"></ul>
+ </nav>
+
<script type="module">
import init from "./pkg/gioser_web.js";
init().catch(err => {
diff --git a/crates/apps/gioser-web/src/lib.rs b/crates/apps/gioser-web/src/lib.rs
index c66dcca..a9a516b 100644
--- a/crates/apps/gioser-web/src/lib.rs
+++ b/crates/apps/gioser-web/src/lib.rs
@@ -1,14 +1,16 @@
//! Entrypoint WASM de la landing GioSer.
//!
//! Responsabilidades:
-//! - Montar canvas WebGL2 + listeners de mouse/resize/RAF loop.
-//! - Reposicionar los 4 tips DOM (botones cardinales) sobre las posiciones
-//! proyectadas del aro de la chacana cada frame.
-//! - Inclinar el título "GioSer" central inyectando CSS vars de tilt.
-//! - Manejar click sobre cada tip → animar drawer expandiéndose desde la
-//! posición del botón hasta fullscreen, cargar el .md asociado vía
-//! `pluma-reader-web` y renderearlo themed por elemento.
-//! - Cerrar drawer con close button, Escape o backdrop click.
+//! - Montar canvas WebGL2 + listeners de mouse/pointer/resize/keyboard/RAF.
+//! - Reposicionar los 4 tips DOM cada frame siguiendo el aro de la chacana,
+//! con clamp para que nunca salgan del viewport ni cubran la taskbar.
+//! - Inclinar el título "GioSer" central inyectando CSS vars de tilt+roll.
+//! - Manejar **click/tap dentro del aro** → vibración (impulso al shake spring).
+//! - Manejar **mouseleave del canvas** → tilt vuelve al frente con rebote.
+//! - Drawers MD por elemento que crecen desde la posición del botón
+//! clickeado hasta fullscreen (excepto la taskbar).
+//! - Taskbar estilo Windows: home a la izquierda + cajitas dinámicas por
+//! cada vista MD abierta, click cambia el activo.
use std::cell::RefCell;
use std::rc::Rc;
@@ -18,12 +20,163 @@ use pluma_reader_web::Reader;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{
- Document, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent, Window,
+ Document, Element, Event, HtmlCanvasElement, HtmlElement, KeyboardEvent, MouseEvent,
+ PointerEvent, Window,
};
-/// Factor radial sobre `arm_extent` donde se anclan los botones DOM.
-/// Queda entre la punta de la chacana y el aro grueso.
+/// Botones se anclan entre la punta de la chacana y el aro grueso.
const BUTTON_RADIUS_FACTOR: f32 = 1.32;
+/// Altura reservada para la taskbar abajo. Sincronizar con CSS `.taskbar { height }`.
+const TASKBAR_HEIGHT_PX: f32 = 52.0;
+/// Padding de seguridad alrededor de los botones para que nunca toquen los bordes.
+const BUTTON_HALF_W_PX: f32 = 90.0;
+const BUTTON_HALF_H_PX: f32 = 64.0;
+const VIEWPORT_MARGIN_PX: f32 = 14.0;
+const ELEMENTS: [&str; 4] = ["aire", "fuego", "tierra", "agua"];
+
+#[derive(Default)]
+struct TaskbarState {
+ /// Elementos abiertos (en orden de apertura), aparecen como cajitas.
+ open: Vec<String>,
+ /// Cuál está visible. `None` = home (sin drawer activo).
+ active: Option<String>,
+}
+
+struct AppState {
+ document: Document,
+ state: RefCell<TaskbarState>,
+}
+
+impl AppState {
+ fn open_tab(&self, element: &str, origin_x: f64, origin_y: f64, md_url: &str) {
+ self.set_drawer_origin(element, origin_x, origin_y);
+ {
+ let mut s = self.state.borrow_mut();
+ if !s.open.iter().any(|e| e == element) {
+ s.open.push(element.to_string());
+ }
+ s.active = Some(element.to_string());
+ }
+ self.sync();
+ self.load_md_if_empty(element, md_url);
+ }
+
+ fn switch_tab(&self, element: &str, origin_x: f64, origin_y: f64) {
+ self.set_drawer_origin(element, origin_x, origin_y);
+ let mut s = self.state.borrow_mut();
+ if s.open.iter().any(|e| e == element) {
+ s.active = Some(element.to_string());
+ }
+ drop(s);
+ self.sync();
+ }
+
+ fn close_tab(&self, element: &str) {
+ let mut s = self.state.borrow_mut();
+ s.open.retain(|e| e != element);
+ if s.active.as_deref() == Some(element) {
+ s.active = s.open.last().cloned();
+ }
+ drop(s);
+ self.sync();
+ }
+
+ fn home(&self) {
+ let mut s = self.state.borrow_mut();
+ s.open.clear();
+ s.active = None;
+ drop(s);
+ self.sync();
+ }
+
+ fn active(&self) -> Option<String> {
+ self.state.borrow().active.clone()
+ }
+
+ fn set_drawer_origin(&self, element: &str, x: f64, y: f64) {
+ let id = format!("drawer-{}", element);
+ if let Some(el) = self.document.get_element_by_id(&id) {
+ if let Ok(el) = el.dyn_into::<HtmlElement>() {
+ let _ = el.style().set_property("--origin-x", &format!("{:.1}px", x));
+ let _ = el.style().set_property("--origin-y", &format!("{:.1}px", y));
+ }
+ }
+ }
+
+ fn sync(&self) {
+ let s = self.state.borrow();
+ let body = match self.document.body() {
+ Some(b) => b,
+ None => return,
+ };
+ if s.active.is_some() {
+ let _ = body.class_list().add_1("drawer-active");
+ } else {
+ let _ = body.class_list().remove_1("drawer-active");
+ }
+ for &e in &ELEMENTS {
+ let cls = format!("drawer-active-{}", e);
+ if s.active.as_deref() == Some(e) {
+ let _ = body.class_list().add_1(&cls);
+ } else {
+ let _ = body.class_list().remove_1(&cls);
+ }
+ }
+ for &e in &ELEMENTS {
+ let id = format!("drawer-{}", e);
+ if let Some(el) = self.document.get_element_by_id(&id) {
+ if let Ok(el) = el.dyn_into::<HtmlElement>() {
+ if s.active.as_deref() == Some(e) {
+ let _ = el.class_list().add_1("open");
+ el.set_attribute("aria-hidden", "false").ok();
+ } else {
+ let _ = el.class_list().remove_1("open");
+ el.set_attribute("aria-hidden", "true").ok();
+ }
+ }
+ }
+ }
+ if let Some(list) = self.document.get_element_by_id("taskbar-list") {
+ let mut html = String::new();
+ for e in &s.open {
+ let label = e.to_uppercase();
+ let active = if s.active.as_deref() == Some(e.as_str()) {
+ " active"
+ } else {
+ ""
+ };
+ html.push_str(&format!(
+ "<li><button class=\"taskbar-item{active}\" data-task=\"{e}\" type=\"button\">\
+ <span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label}</button></li>"
+ ));
+ }
+ list.set_inner_html(&html);
+ }
+ }
+
+ fn load_md_if_empty(&self, element: &str, md_url: &str) {
+ let content_id = format!("drawer-{}-content", element);
+ let Some(content_el) = self.document.get_element_by_id(&content_id) else {
+ return;
+ };
+ let Ok(content): Result<HtmlElement, _> = content_el.dyn_into() else {
+ return;
+ };
+ let inner = content.inner_html();
+ // Si ya tiene contenido renderizado (pluma-doc) y no es loader/error, no re-fetch.
+ if inner.contains("pluma-doc") {
+ return;
+ }
+ let reader = Reader::new(content);
+ let element_owned = element.to_string();
+ let url_owned = md_url.to_string();
+ wasm_bindgen_futures::spawn_local(async move {
+ if let Err(e) = reader.open_url(&url_owned, &element_owned).await {
+ web_sys::console::warn_1(&e);
+ }
+ });
+ }
+}
#[wasm_bindgen(start)]
pub fn boot() -> Result<(), JsValue> {
@@ -40,13 +193,28 @@ pub fn boot() -> Result<(), JsValue> {
fit_canvas(&canvas, &window);
let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
- renderer
- .borrow_mut()
- .resize(canvas.width(), canvas.height());
+ {
+ let mut r = renderer.borrow_mut();
+ r.resize(canvas.width(), canvas.height());
+ r.set_client_size(
+ canvas.client_width() as f32,
+ canvas.client_height() as f32,
+ );
+ }
+
+ let app = Rc::new(AppState {
+ document: document.clone(),
+ state: RefCell::default(),
+ });
install_resize(&window, &canvas, &renderer)?;
install_mouse(&document, &canvas, &renderer)?;
- install_drawer_handlers(&document)?;
+ install_canvas_pointer(&canvas, &renderer)?;
+ install_canvas_leave(&canvas, &renderer)?;
+ install_tip_clicks(&document, &app)?;
+ install_drawer_close_buttons(&document, &app)?;
+ install_taskbar(&document, &app)?;
+ install_keyboard(&document, &app)?;
install_raf(&window, &document, &canvas, &renderer);
Ok(())
@@ -62,7 +230,12 @@ fn install_resize(
let r = renderer.clone();
let cb = Closure::<dyn FnMut()>::new(move || {
fit_canvas(&canvas, &win2);
- r.borrow_mut().resize(canvas.width(), canvas.height());
+ let mut rr = r.borrow_mut();
+ rr.resize(canvas.width(), canvas.height());
+ rr.set_client_size(
+ canvas.client_width() as f32,
+ canvas.client_height() as f32,
+ );
});
window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
cb.forget();
@@ -88,114 +261,151 @@ fn install_mouse(
Ok(())
}
-fn install_drawer_handlers(document: &Document) -> Result<(), JsValue> {
- let doc = document.clone();
+/// Pointer down dentro del aro → impulso de vibración (click/tap shake).
+fn install_canvas_pointer(
+ canvas: &HtmlCanvasElement,
+ renderer: &Rc<RefCell<Renderer>>,
+) -> Result<(), JsValue> {
+ let canvas2 = canvas.clone();
+ let r = renderer.clone();
+ let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+ let rect = canvas2.get_bounding_client_rect();
+ let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0);
+ let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0);
+ let dist2 = dx * dx + dy * dy;
+ let ring = {
+ let rb = r.borrow();
+ rb.click_radius_css_px() as f64
+ };
+ if dist2 <= ring * ring {
+ r.borrow_mut().impulse_click();
+ }
+ });
+ canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ Ok(())
+}
+
+/// Mouse sale del canvas → tilt vuelve al frente con rebote del spring.
+fn install_canvas_leave(
+ canvas: &HtmlCanvasElement,
+ renderer: &Rc<RefCell<Renderer>>,
+) -> Result<(), JsValue> {
+ let r = renderer.clone();
+ let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
+ r.borrow_mut().release_tilt();
+ });
+ canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ Ok(())
+}
- // Clicks en los 4 tips → open drawer.
- let tips = document.query_selector_all(".tip[data-md]")?;
- for i in 0..tips.length() {
- let Some(node) = tips.item(i) else { continue };
- let Ok(el) = node.dyn_into::<HtmlElement>() else { continue };
+fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
+ let tips_nodes = document.query_selector_all(".tip[data-md]")?;
+ for i in 0..tips_nodes.length() {
+ let Some(node) = tips_nodes.item(i) else {
+ continue;
+ };
+ let Ok(el) = node.dyn_into::<HtmlElement>() else {
+ continue;
+ };
let id = el.id();
let element = id.strip_prefix("tip-").unwrap_or("").to_string();
let md_url = el.get_attribute("data-md").unwrap_or_default();
- let d = doc.clone();
+ let app2 = app.clone();
let el_for_rect = el.clone();
let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
e.prevent_default();
- open_drawer(&d, &element, &el_for_rect, &md_url);
+ let rect = el_for_rect.get_bounding_client_rect();
+ let cx = rect.left() + rect.width() / 2.0;
+ let cy = rect.top() + rect.height() / 2.0;
+ app2.open_tab(&element, cx, cy, &md_url);
});
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
cb.forget();
}
+ Ok(())
+}
- // Cualquier elemento marcado con `data-close-drawer` cierra.
+fn install_drawer_close_buttons(
+ document: &Document,
+ app: &Rc<AppState>,
+) -> Result<(), JsValue> {
let closes = document.query_selector_all("[data-close-drawer]")?;
for i in 0..closes.length() {
let Some(node) = closes.item(i) else { continue };
- let Ok(el) = node.dyn_into::<HtmlElement>() else { continue };
- let d = doc.clone();
+ let Ok(el) = node.dyn_into::<HtmlElement>() else {
+ continue;
+ };
+ let el_ref: &Element = el.as_ref();
+ let element_attr = el_ref
+ .closest(".drawer")
+ .ok()
+ .flatten()
+ .and_then(|d| d.get_attribute("data-element"))
+ .unwrap_or_default();
+ let app2 = app.clone();
let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
e.stop_propagation();
- close_drawers(&d);
+ app2.close_tab(&element_attr);
});
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
cb.forget();
}
-
- // Escape cierra.
- let d = doc.clone();
- let kcb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
- if e.key() == "Escape" {
- close_drawers(&d);
- }
- });
- document.add_event_listener_with_callback("keydown", kcb.as_ref().unchecked_ref())?;
- kcb.forget();
-
Ok(())
}
-fn open_drawer(doc: &Document, element: &str, button: &HtmlElement, md_url: &str) {
- let rect = button.get_bounding_client_rect();
- let cx = rect.left() + rect.width() / 2.0;
- let cy = rect.top() + rect.height() / 2.0;
- let drawer_id = format!("drawer-{}", element);
- let Some(drawer_el) = doc.get_element_by_id(&drawer_id) else {
- return;
- };
- let drawer: HtmlElement = drawer_el.unchecked_into();
- let _ = drawer
- .style()
- .set_property("--origin-x", &format!("{:.1}px", cx));
- let _ = drawer
- .style()
- .set_property("--origin-y", &format!("{:.1}px", cy));
- let _ = drawer.class_list().add_1("open");
- drawer.set_attribute("aria-hidden", "false").ok();
-
- if let Some(body) = doc.body() {
- let _ = body.class_list().add_1("drawer-active");
- let _ = body
- .class_list()
- .add_1(&format!("drawer-active-{}", element));
+fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
+ // Botón home
+ let homes = document.query_selector_all("[data-home]")?;
+ for i in 0..homes.length() {
+ let Some(node) = homes.item(i) else { continue };
+ let Ok(el) = node.dyn_into::<HtmlElement>() else {
+ continue;
+ };
+ let app2 = app.clone();
+ let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
+ app2.home();
+ });
+ el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+ cb.forget();
}
-
- // Carga del .md en background.
- let content_id = format!("drawer-{}-content", element);
- if let Some(content_el) = doc.get_element_by_id(&content_id) {
- let content: HtmlElement = content_el.unchecked_into();
- let reader = Reader::new(content);
- let element_owned = element.to_string();
- let md_url_owned = md_url.to_string();
- wasm_bindgen_futures::spawn_local(async move {
- if let Err(e) = reader.open_url(&md_url_owned, &element_owned).await {
- web_sys::console::warn_1(&e);
+ // Delegación: 1 listener en la lista, dispatch por data-task del closest .taskbar-item.
+ if let Some(list) = document.get_element_by_id("taskbar-list") {
+ let app2 = app.clone();
+ let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
+ let Some(target) = e.target() else { return };
+ let Ok(target_el): Result<Element, _> = target.dyn_into() else {
+ return;
+ };
+ let Ok(Some(item)) = target_el.closest(".taskbar-item") else {
+ return;
+ };
+ if let Some(task) = item.get_attribute("data-task") {
+ let rect = item.get_bounding_client_rect();
+ let cx = rect.left() + rect.width() / 2.0;
+ let cy = rect.top() + rect.height() / 2.0;
+ app2.switch_tab(&task, cx, cy);
}
});
+ list.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
+ cb.forget();
}
+ Ok(())
}
-fn close_drawers(doc: &Document) {
- let Ok(drawers) = doc.query_selector_all(".drawer.open") else {
- return;
- };
- for i in 0..drawers.length() {
- let Some(node) = drawers.item(i) else { continue };
- let Ok(el) = node.dyn_into::<HtmlElement>() else {
- continue;
- };
- let _ = el.class_list().remove_1("open");
- let _ = el.set_attribute("aria-hidden", "true");
- }
- if let Some(body) = doc.body() {
- let _ = body.class_list().remove_1("drawer-active");
- for e in ["aire", "fuego", "tierra", "agua"] {
- let _ = body
- .class_list()
- .remove_1(&format!("drawer-active-{}", e));
+fn install_keyboard(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
+ let app2 = app.clone();
+ let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
+ if e.key() == "Escape" {
+ if let Some(active) = app2.active() {
+ app2.close_tab(&active);
+ }
}
- }
+ });
+ document.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ Ok(())
}
fn install_raf(
@@ -211,9 +421,6 @@ fn install_raf(
let document = document.clone();
let window2 = window.clone();
*g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| {
- let r = renderer.borrow_mut();
- // r es Mut, ojo: el render necesita mut, lo hacemos antes de paint.
- drop(r);
renderer.borrow_mut().render(time_ms);
let r = renderer.borrow();
position_tips(&document, &canvas, &r);
@@ -250,10 +457,17 @@ fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Ren
let clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
let cw = canvas.client_width().max(1) as f32;
let ch = canvas.client_height().max(1) as f32;
+ // Bounds en CSS pixels donde los botones pueden moverse libremente.
+ let min_x = VIEWPORT_MARGIN_PX + BUTTON_HALF_W_PX;
+ let max_x = (cw - VIEWPORT_MARGIN_PX - BUTTON_HALF_W_PX).max(min_x);
+ let min_y = VIEWPORT_MARGIN_PX + BUTTON_HALF_H_PX;
+ let max_y = (ch - TASKBAR_HEIGHT_PX - VIEWPORT_MARGIN_PX - BUTTON_HALF_H_PX).max(min_y);
for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
let (nx, ny) = clips[i];
- let px = (nx + 1.0) * 0.5 * cw;
- let py = (1.0 - (ny + 1.0) * 0.5) * ch;
+ let raw_x = (nx + 1.0) * 0.5 * cw;
+ let raw_y = (1.0 - (ny + 1.0) * 0.5) * ch;
+ let px = raw_x.clamp(min_x, max_x);
+ let py = raw_y.clamp(min_y, max_y);
let sel = format!("tip-{}", id);
if let Some(el) = document.get_element_by_id(&sel) {
if let Ok(el) = el.dyn_into::<HtmlElement>() {
@@ -267,16 +481,18 @@ fn position_tips(document: &Document, canvas: &HtmlCanvasElement, renderer: &Ren
}
fn update_tilt_css(document: &Document, renderer: &Renderer) {
- let (pitch, yaw) = renderer.tilt_degrees();
+ let (pitch, yaw, roll) = renderer.tilt_degrees();
if let Some(brand) = document.get_element_by_id("brand") {
if let Ok(brand) = brand.dyn_into::<HtmlElement>() {
- // CSS rotateX usa el mismo signo que nuestra pitch (mouse up tilts top toward viewer).
let _ = brand
.style()
.set_property("--tilt-x", &format!("{:.2}deg", pitch));
let _ = brand
.style()
.set_property("--tilt-y", &format!("{:.2}deg", yaw));
+ let _ = brand
+ .style()
+ .set_property("--tilt-z", &format!("{:.2}deg", roll));
}
}
}
diff --git a/crates/apps/gioser-web/styles.css b/crates/apps/gioser-web/styles.css
index fd71309..84b4ca3 100644
--- a/crates/apps/gioser-web/styles.css
+++ b/crates/apps/gioser-web/styles.css
@@ -47,12 +47,14 @@ html, body {
pointer-events: none;
user-select: none;
transform-style: preserve-3d;
- /* perspective hace que la rotación del título dé sensación 3D igual que la chacana */
+ /* perspective hace que la rotación del título dé sensación 3D igual que la chacana.
+ rotateZ refleja el shake de click — el título tiembla con la cruz. */
transform:
translate(-50%, -50%)
perspective(900px)
rotateX(var(--tilt-x, 0deg))
- rotateY(var(--tilt-y, 0deg));
+ rotateY(var(--tilt-y, 0deg))
+ rotateZ(var(--tilt-z, 0deg));
transition: transform 30ms linear;
}
.brand-title {
@@ -188,7 +190,10 @@ body.drawer-active .brand {
/* === Drawers (visor MD full-screen) === */
.drawer {
position: fixed;
- inset: 0;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 52px; /* reservamos la altura de la taskbar */
z-index: 100;
pointer-events: none;
opacity: 0;
@@ -487,6 +492,114 @@ body.drawer-active .brand {
}
/* === Responsive === */
+/* === Taskbar estilo Windows === */
+.taskbar {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 52px;
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0 0.7rem;
+ background:
+ linear-gradient(to top, rgba(6, 5, 13, 0.92), rgba(8, 6, 22, 0.78));
+ backdrop-filter: blur(24px) saturate(140%);
+ -webkit-backdrop-filter: blur(24px) saturate(140%);
+ border-top: 1px solid rgba(216, 168, 93, 0.22);
+ box-shadow: 0 -10px 36px rgba(0, 0, 0, 0.45);
+}
+
+.taskbar-home {
+ width: 40px;
+ height: 40px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: 1px solid rgba(216, 168, 93, 0.32);
+ color: var(--gold);
+ border-radius: 9px;
+ cursor: pointer;
+ padding: 0;
+ transition: all 220ms var(--ease-emerge);
+}
+.taskbar-home:hover {
+ border-color: var(--gold);
+ background: rgba(216, 168, 93, 0.12);
+ box-shadow: 0 0 16px rgba(216, 168, 93, 0.35);
+ transform: translateY(-1px);
+}
+.taskbar-home-glyph {
+ width: 22px;
+ height: 22px;
+ filter: drop-shadow(0 0 6px currentColor);
+}
+
+.taskbar-divider {
+ display: inline-block;
+ width: 1px;
+ height: 26px;
+ background: rgba(255, 255, 255, 0.12);
+ margin: 0 0.2rem;
+}
+
+.taskbar-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ gap: 0.4rem;
+ overflow-x: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+.taskbar-list::-webkit-scrollbar { display: none; }
+
+.taskbar-item {
+ height: 38px;
+ padding: 0 1.0rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.10);
+ border-radius: 9px;
+ color: var(--task-color, var(--fg));
+ font-family: 'Cinzel', serif;
+ font-size: 0.72rem;
+ letter-spacing: 0.36em;
+ text-indent: 0.36em;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 260ms var(--ease-emerge);
+}
+.taskbar-item:hover {
+ background: rgba(255, 255, 255, 0.09);
+ border-color: var(--task-color, currentColor);
+ transform: translateY(-1px);
+}
+.taskbar-item.active {
+ background: rgba(255, 255, 255, 0.11);
+ border-color: var(--task-color);
+ box-shadow:
+ 0 0 18px var(--task-color),
+ inset 0 -2px 0 0 var(--task-color);
+}
+.taskbar-item-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--task-color, var(--gold));
+ box-shadow: 0 0 8px currentColor;
+}
+.taskbar-item[data-task="aire"] { --task-color: var(--aire); }
+.taskbar-item[data-task="fuego"] { --task-color: var(--fuego); }
+.taskbar-item[data-task="tierra"] { --task-color: var(--tierra); }
+.taskbar-item[data-task="agua"] { --task-color: var(--agua); }
+
@media (max-width: 720px) {
.tip { min-width: 110px; padding: 0.7rem 0.9rem; }
.tip-glyph { width: 36px; height: 36px; }
@@ -495,4 +608,8 @@ body.drawer-active .brand {
.brand-title { font-size: clamp(2rem, 10vw, 3rem); }
.drawer-head { padding: 5vh 5vw 1vh; }
.drawer-content { padding: 0 5vw 5vh; }
+ .taskbar { height: 46px; padding: 0 0.4rem; gap: 0.3rem; }
+ .taskbar-home { width: 36px; height: 36px; }
+ .taskbar-item { height: 34px; padding: 0 0.7rem; font-size: 0.65rem; }
+ .drawer { bottom: 46px; }
}
diff --git a/crates/modules/gioser/gioser-canvas-web/src/lib.rs b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
index 53e77e3..6da50b0 100644
--- a/crates/modules/gioser/gioser-canvas-web/src/lib.rs
+++ b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
@@ -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);
}
diff --git a/crates/modules/gioser/gioser-shaders/src/lib.rs b/crates/modules/gioser/gioser-shaders/src/lib.rs
index 6c684a6..0ccb486 100644
--- a/crates/modules/gioser/gioser-shaders/src/lib.rs
+++ b/crates/modules/gioser/gioser-shaders/src/lib.rs
@@ -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);
}