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
+2
View File
@@ -22,6 +22,7 @@ features = [
"Window", "Window",
"Document", "Document",
"Element", "Element",
"Node",
"HtmlElement", "HtmlElement",
"HtmlCanvasElement", "HtmlCanvasElement",
"CssStyleDeclaration", "CssStyleDeclaration",
@@ -30,6 +31,7 @@ features = [
"Event", "Event",
"EventTarget", "EventTarget",
"MouseEvent", "MouseEvent",
"PointerEvent",
"KeyboardEvent", "KeyboardEvent",
"NodeList", "NodeList",
"Performance", "Performance",
+14
View File
@@ -105,6 +105,20 @@
<section class="drawer-content" id="drawer-agua-content"></section> <section class="drawer-content" id="drawer-agua-content"></section>
</aside> </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"> <script type="module">
import init from "./pkg/gioser_web.js"; import init from "./pkg/gioser_web.js";
init().catch(err => { init().catch(err => {
+332 -116
View File
@@ -1,14 +1,16 @@
//! Entrypoint WASM de la landing GioSer. //! Entrypoint WASM de la landing GioSer.
//! //!
//! Responsabilidades: //! Responsabilidades:
//! - Montar canvas WebGL2 + listeners de mouse/resize/RAF loop. //! - Montar canvas WebGL2 + listeners de mouse/pointer/resize/keyboard/RAF.
//! - Reposicionar los 4 tips DOM (botones cardinales) sobre las posiciones //! - Reposicionar los 4 tips DOM cada frame siguiendo el aro de la chacana,
//! proyectadas del aro de la chacana cada frame. //! con clamp para que nunca salgan del viewport ni cubran la taskbar.
//! - Inclinar el título "GioSer" central inyectando CSS vars de tilt. //! - Inclinar el título "GioSer" central inyectando CSS vars de tilt+roll.
//! - Manejar click sobre cada tip → animar drawer expandiéndose desde la //! - Manejar **click/tap dentro del aro** → vibración (impulso al shake spring).
//! posición del botón hasta fullscreen, cargar el .md asociado vía //! - Manejar **mouseleave del canvas** → tilt vuelve al frente con rebote.
//! `pluma-reader-web` y renderearlo themed por elemento. //! - Drawers MD por elemento que crecen desde la posición del botón
//! - Cerrar drawer con close button, Escape o backdrop click. //! 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::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -18,12 +20,163 @@ use pluma_reader_web::Reader;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use web_sys::{ 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. /// Botones se anclan entre la punta de la chacana y el aro grueso.
/// Queda entre la punta de la chacana y el aro grueso.
const BUTTON_RADIUS_FACTOR: f32 = 1.32; 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)] #[wasm_bindgen(start)]
pub fn boot() -> Result<(), JsValue> { pub fn boot() -> Result<(), JsValue> {
@@ -40,13 +193,28 @@ pub fn boot() -> Result<(), JsValue> {
fit_canvas(&canvas, &window); fit_canvas(&canvas, &window);
let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?)); let renderer = Rc::new(RefCell::new(Renderer::new(&canvas)?));
renderer {
.borrow_mut() let mut r = renderer.borrow_mut();
.resize(canvas.width(), canvas.height()); 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_resize(&window, &canvas, &renderer)?;
install_mouse(&document, &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); install_raf(&window, &document, &canvas, &renderer);
Ok(()) Ok(())
@@ -62,7 +230,12 @@ fn install_resize(
let r = renderer.clone(); let r = renderer.clone();
let cb = Closure::<dyn FnMut()>::new(move || { let cb = Closure::<dyn FnMut()>::new(move || {
fit_canvas(&canvas, &win2); 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())?; window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
cb.forget(); cb.forget();
@@ -88,114 +261,151 @@ fn install_mouse(
Ok(()) Ok(())
} }
fn install_drawer_handlers(document: &Document) -> Result<(), JsValue> { /// Pointer down dentro del aro → impulso de vibración (click/tap shake).
let doc = document.clone(); fn install_canvas_pointer(
canvas: &HtmlCanvasElement,
// Clicks en los 4 tips → open drawer. renderer: &Rc<RefCell<Renderer>>,
let tips = document.query_selector_all(".tip[data-md]")?; ) -> Result<(), JsValue> {
for i in 0..tips.length() { let canvas2 = canvas.clone();
let Some(node) = tips.item(i) else { continue }; let r = renderer.clone();
let Ok(el) = node.dyn_into::<HtmlElement>() else { continue }; let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
let id = el.id(); let rect = canvas2.get_bounding_client_rect();
let element = id.strip_prefix("tip-").unwrap_or("").to_string(); let dx = e.client_x() as f64 - (rect.left() + rect.width() / 2.0);
let md_url = el.get_attribute("data-md").unwrap_or_default(); let dy = e.client_y() as f64 - (rect.top() + rect.height() / 2.0);
let d = doc.clone(); let dist2 = dx * dx + dy * dy;
let el_for_rect = el.clone(); let ring = {
let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| { let rb = r.borrow();
e.prevent_default(); rb.click_radius_css_px() as f64
open_drawer(&d, &element, &el_for_rect, &md_url); };
if dist2 <= ring * ring {
r.borrow_mut().impulse_click();
}
}); });
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?; canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
cb.forget(); cb.forget();
}
// Cualquier elemento marcado con `data-close-drawer` cierra.
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 cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
e.stop_propagation();
close_drawers(&d);
});
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(()) Ok(())
} }
fn open_drawer(doc: &Document, element: &str, button: &HtmlElement, md_url: &str) { /// Mouse sale del canvas → tilt vuelve al frente con rebote del spring.
let rect = button.get_bounding_client_rect(); fn install_canvas_leave(
let cx = rect.left() + rect.width() / 2.0; canvas: &HtmlCanvasElement,
let cy = rect.top() + rect.height() / 2.0; renderer: &Rc<RefCell<Renderer>>,
let drawer_id = format!("drawer-{}", element); ) -> Result<(), JsValue> {
let Some(drawer_el) = doc.get_element_by_id(&drawer_id) else { let r = renderer.clone();
return; let cb = Closure::<dyn FnMut(Event)>::new(move |_e: Event| {
}; r.borrow_mut().release_tilt();
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));
}
// 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);
}
}); });
} canvas.add_event_listener_with_callback("mouseleave", cb.as_ref().unchecked_ref())?;
cb.forget();
Ok(())
} }
fn close_drawers(doc: &Document) { fn install_tip_clicks(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
let Ok(drawers) = doc.query_selector_all(".drawer.open") else { let tips_nodes = document.query_selector_all(".tip[data-md]")?;
return; for i in 0..tips_nodes.length() {
let Some(node) = tips_nodes.item(i) else {
continue;
}; };
for i in 0..drawers.length() {
let Some(node) = drawers.item(i) else { continue };
let Ok(el) = node.dyn_into::<HtmlElement>() else { let Ok(el) = node.dyn_into::<HtmlElement>() else {
continue; continue;
}; };
let _ = el.class_list().remove_1("open"); let id = el.id();
let _ = el.set_attribute("aria-hidden", "true"); let element = id.strip_prefix("tip-").unwrap_or("").to_string();
let md_url = el.get_attribute("data-md").unwrap_or_default();
let app2 = app.clone();
let el_for_rect = el.clone();
let cb = Closure::<dyn FnMut(Event)>::new(move |e: Event| {
e.prevent_default();
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();
} }
if let Some(body) = doc.body() { Ok(())
let _ = body.class_list().remove_1("drawer-active"); }
for e in ["aire", "fuego", "tierra", "agua"] {
let _ = body fn install_drawer_close_buttons(
.class_list() document: &Document,
.remove_1(&format!("drawer-active-{}", e)); 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 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();
app2.close_tab(&element_attr);
});
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
cb.forget();
}
Ok(())
}
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();
}
// 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 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( fn install_raf(
@@ -211,9 +421,6 @@ fn install_raf(
let document = document.clone(); let document = document.clone();
let window2 = window.clone(); let window2 = window.clone();
*g.borrow_mut() = Some(Closure::<dyn FnMut(f64)>::new(move |time_ms: f64| { *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); renderer.borrow_mut().render(time_ms);
let r = renderer.borrow(); let r = renderer.borrow();
position_tips(&document, &canvas, &r); 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 clips = renderer.cardinal_positions_ndc(BUTTON_RADIUS_FACTOR);
let cw = canvas.client_width().max(1) as f32; let cw = canvas.client_width().max(1) as f32;
let ch = canvas.client_height().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() { for (i, (id, _color, _label)) in tips::ORDER.iter().enumerate() {
let (nx, ny) = clips[i]; let (nx, ny) = clips[i];
let px = (nx + 1.0) * 0.5 * cw; let raw_x = (nx + 1.0) * 0.5 * cw;
let py = (1.0 - (ny + 1.0) * 0.5) * ch; 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); let sel = format!("tip-{}", id);
if let Some(el) = document.get_element_by_id(&sel) { if let Some(el) = document.get_element_by_id(&sel) {
if let Ok(el) = el.dyn_into::<HtmlElement>() { 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) { 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 Some(brand) = document.get_element_by_id("brand") {
if let Ok(brand) = brand.dyn_into::<HtmlElement>() { 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 let _ = brand
.style() .style()
.set_property("--tilt-x", &format!("{:.2}deg", pitch)); .set_property("--tilt-x", &format!("{:.2}deg", pitch));
let _ = brand let _ = brand
.style() .style()
.set_property("--tilt-y", &format!("{:.2}deg", yaw)); .set_property("--tilt-y", &format!("{:.2}deg", yaw));
let _ = brand
.style()
.set_property("--tilt-z", &format!("{:.2}deg", roll));
} }
} }
} }
+120 -3
View File
@@ -47,12 +47,14 @@ html, body {
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
transform-style: preserve-3d; 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: transform:
translate(-50%, -50%) translate(-50%, -50%)
perspective(900px) perspective(900px)
rotateX(var(--tilt-x, 0deg)) rotateX(var(--tilt-x, 0deg))
rotateY(var(--tilt-y, 0deg)); rotateY(var(--tilt-y, 0deg))
rotateZ(var(--tilt-z, 0deg));
transition: transform 30ms linear; transition: transform 30ms linear;
} }
.brand-title { .brand-title {
@@ -188,7 +190,10 @@ body.drawer-active .brand {
/* === Drawers (visor MD full-screen) === */ /* === Drawers (visor MD full-screen) === */
.drawer { .drawer {
position: fixed; position: fixed;
inset: 0; top: 0;
left: 0;
right: 0;
bottom: 52px; /* reservamos la altura de la taskbar */
z-index: 100; z-index: 100;
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
@@ -487,6 +492,114 @@ body.drawer-active .brand {
} }
/* === Responsive === */ /* === 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) { @media (max-width: 720px) {
.tip { min-width: 110px; padding: 0.7rem 0.9rem; } .tip { min-width: 110px; padding: 0.7rem 0.9rem; }
.tip-glyph { width: 36px; height: 36px; } .tip-glyph { width: 36px; height: 36px; }
@@ -495,4 +608,8 @@ body.drawer-active .brand {
.brand-title { font-size: clamp(2rem, 10vw, 3rem); } .brand-title { font-size: clamp(2rem, 10vw, 3rem); }
.drawer-head { padding: 5vh 5vw 1vh; } .drawer-head { padding: 5vh 5vw 1vh; }
.drawer-content { padding: 0 5vw 5vh; } .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; }
} }
@@ -1,18 +1,14 @@
//! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla. //! 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 //! El loop externo (típicamente `requestAnimationFrame`) llama `render(time_ms)`.
//! de mouse y llama `render(time_ms)` desde un `requestAnimationFrame`. //! Los eventos input se propagan vía métodos: `set_mouse_px`, `release_tilt`,
//! //! `impulse_click`. El cliente puede consultar dimensiones derivadas
//! ```ignore //! (`click_radius_css_px`, `tilt_degrees`, `cardinal_positions_ndc`) para
//! let mut r = Renderer::new(&canvas)?; //! sincronizar DOM (botones, título, taskbar).
//! r.resize(w, h);
//! r.set_mouse_px(dx, dy);
//! r.render(time_ms);
//! ```
use gioser_geom::ChacanaSpec; use gioser_geom::ChacanaSpec;
use gioser_palette::{cosmos, Rgb}; use gioser_palette::{cosmos, Rgb};
use gioser_physics::SpringDamper2; use gioser_physics::{SpringDamper1, SpringDamper2};
use gioser_shaders::{ use gioser_shaders::{
chacana_quad, FS_CHACANA, FS_COSMOS, FULLSCREEN_QUAD, VS_CHACANA, VS_FULLSCREEN, 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 RAD: f32 = core::f32::consts::PI / 180.0;
const DEG: f32 = 180.0 / core::f32::consts::PI; const DEG: f32 = 180.0 / core::f32::consts::PI;
/// Inclinación máxima en cada eje. 28° = movimiento bien legible pero /// Inclinación máxima en cada eje.
/// no caricaturesco; la chacana se siente "pesada y noble".
const MAX_TILT_DEG: f32 = 28.0; const MAX_TILT_DEG: f32 = 28.0;
/// Escala mundo→viewport: con arm_extent=0.65 + aro a 1.45×, la chacana /// `cot(45°/2)` — factor de proyección. Lo necesitamos también para calcular
/// + aro entran cómodos con margen para botones DOM más allá del aro. /// el radio del círculo en pixels (hit-test del click).
const WORLD_SCALE: f32 = 1.05; 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). /// Identidad de cada cardinal (id, color de acento, label). Orden `[N, E, S, W]`.
/// Orden `[N, E, S, W]` coincide con `ChacanaSpec::tips()`.
pub mod tips { pub mod tips {
use gioser_palette::{elements, Rgb}; use gioser_palette::{elements, Rgb};
pub const ORDER: [(&str, Rgb, &str); 4] = [ pub const ORDER: [(&str, Rgb, &str); 4] = [
("aire", elements::AIRE, "AIRE"), // N ("aire", elements::AIRE, "AIRE"),
("fuego", elements::FUEGO, "FUEGO"), // E ("fuego", elements::FUEGO, "FUEGO"),
("tierra", elements::TIERRA, "TIERRA"), // S ("tierra", elements::TIERRA, "TIERRA"),
("agua", elements::AGUA, "AGUA"), // W ("agua", elements::AGUA, "AGUA"),
]; ];
} }
@@ -53,10 +50,19 @@ pub struct Renderer {
chacana_vao: WebGlVertexArrayObject, chacana_vao: WebGlVertexArrayObject,
chacana_quad_count: i32, chacana_quad_count: i32,
chacana: ChacanaSpec, chacana: ChacanaSpec,
/// Spring del tilt 3D que sigue al mouse. Sub-crítico orgánico.
tilt: SpringDamper2, 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, sun_pulse: f32,
last_time_ms: f64, last_time_ms: f64,
/// Dimensiones device-pixel del canvas (lo que GL viewport usa).
viewport: (u32, u32), 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 en clip-space, x ∈ [-aspect, aspect], y ∈ [-1, 1].
mouse: (f32, f32), mouse: (f32, f32),
} }
@@ -137,6 +143,21 @@ fn upload_quad(
Ok((vao, (verts.len() / 2) as i32)) 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 { impl Renderer {
pub fn new(canvas: &HtmlCanvasElement) -> Result<Self, JsValue> { pub fn new(canvas: &HtmlCanvasElement) -> Result<Self, JsValue> {
let gl = canvas let gl = canvas
@@ -176,6 +197,10 @@ impl Renderer {
"u_rim_color", "u_rim_color",
"u_sun_color", "u_sun_color",
"u_dark_color", "u_dark_color",
"u_aire_color",
"u_fuego_color",
"u_tierra_color",
"u_agua_color",
"u_sun_pulse", "u_sun_pulse",
], ],
) )
@@ -187,6 +212,9 @@ impl Renderer {
upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?; upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
let tilt = SpringDamper2::new(1.7, 0.65); 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 { Ok(Self {
gl, gl,
@@ -197,9 +225,15 @@ impl Renderer {
chacana_quad_count, chacana_quad_count,
chacana, chacana,
tilt, tilt,
shake,
click_count: 0,
sun_pulse: 0.0, sun_pulse: 0.0,
last_time_ms: 0.0, last_time_ms: 0.0,
viewport: (canvas.width().max(1), canvas.height().max(1)), 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), mouse: (0.0, 0.0),
}) })
} }
@@ -210,6 +244,12 @@ impl Renderer {
.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32); .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) { pub fn set_mouse_px(&mut self, x: f32, y: f32) {
let (w, h) = self.viewport; let (w, h) = self.viewport;
if h == 0 { if h == 0 {
@@ -225,14 +265,41 @@ impl Renderer {
self.tilt.set_target(target); 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]`. /// Posición proyectada NDC de cada tip cardinal `[N, E, S, W]`.
pub fn tips_ndc(&self) -> [(f32, f32); 4] { pub fn tips_ndc(&self) -> [(f32, f32); 4] {
self.points_ndc(&self.chacana.tips()) self.points_ndc(&self.chacana.tips())
} }
/// Posición NDC de un punto en cualquier radio cardinal (factor sobre /// Posiciones NDC para anclar botones en los 4 cardinales a un radio
/// `arm_extent`). Útil para anclar los botones DOM más allá de la chacana /// específico (factor sobre `arm_extent`).
/// pero dentro del aro.
pub fn cardinal_positions_ndc(&self, radius_factor: f32) -> [(f32, f32); 4] { pub fn cardinal_positions_ndc(&self, radius_factor: f32) -> [(f32, f32); 4] {
let r = self.chacana.arm_extent() * radius_factor; let r = self.chacana.arm_extent() * radius_factor;
self.points_ndc(&[(0.0, r), (r, 0.0), (0.0, -r), (-r, 0.0)]) self.points_ndc(&[(0.0, r), (r, 0.0), (0.0, -r), (-r, 0.0)])
@@ -257,22 +324,26 @@ impl Renderer {
self.mouse self.mouse
} }
/// Devuelve `(pitch_deg, yaw_deg)` actuales del spring de tilt. /// `(pitch_deg, yaw_deg, roll_deg)` actuales. Roll viene del shake spring.
/// El caller los inyecta como CSS vars en el contenedor del título para pub fn tilt_degrees(&self) -> (f32, f32, f32) {
/// 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[0] * DEG, self.tilt.position[1] * DEG) self.tilt.position[1] * DEG,
self.shake.position[0] * DEG,
)
} }
fn build_mvp(&self) -> Mat4 { fn build_mvp(&self) -> Mat4 {
let (w, h) = self.viewport; let (w, h) = self.viewport;
let aspect = w as f32 / h as f32; 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 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 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 pitch = Mat4::from_rotation_x(self.tilt.position[0]);
let yaw = Mat4::from_rotation_y(self.tilt.position[1]); let yaw = Mat4::from_rotation_y(self.tilt.position[1]);
let scale = Mat4::from_scale(Vec3::splat(WORLD_SCALE)); let roll = Mat4::from_rotation_z(self.shake.position[0]);
proj * view * yaw * pitch * scale let scale = Mat4::from_scale(Vec3::splat(scale_val));
proj * view * yaw * pitch * roll * scale
} }
pub fn render(&mut self, time_ms: f64) { 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) ((time_ms - self.last_time_ms) as f32 / 1000.0).clamp(0.0, 1.0 / 15.0)
}; };
self.last_time_ms = time_ms; 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; let sub_dt = dt / sub as f32;
for _ in 0..sub { for _ in 0..sub {
self.tilt.step(sub_dt); self.tilt.step(sub_dt);
self.shake.step(sub_dt);
} }
let t = time_ms as f32 * 0.001; let t = time_ms as f32 * 0.001;
self.sun_pulse = 0.5 + 0.5 * (t * 1.4).sin(); 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.bind_vertex_array(Some(&self.cosmos_vao));
gl.draw_arrays(GL::TRIANGLES, 0, 6); 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.blend_func(GL::SRC_ALPHA, GL::ONE);
gl.use_program(Some(&self.chacana_prog.program)); gl.use_program(Some(&self.chacana_prog.program));
let mvp = self.build_mvp(); 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_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_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_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") { if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
gl.uniform1f(Some(u), self.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_rim_color;
uniform vec3 u_sun_color; uniform vec3 u_sun_color;
uniform vec3 u_dark_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; uniform float u_sun_pulse;
const float PI = 3.14159265; 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) { float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b; vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); 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; 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. // 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 cardinal_dots(vec2 p, float ringR, float dotSize) {
float r = length(p); float r = length(p);
@@ -282,6 +352,15 @@ void main() {
// 4 grupos de 3 puntos cardinales sobre el aro principal. // 4 grupos de 3 puntos cardinales sobre el aro principal.
float dots = cardinal_dots(p, ringR_main, 0.008) * 1.10; 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 === // === COMPOSICIÓN ===
vec3 col = vec3(0.0); vec3 col = vec3(0.0);
// Sol detrás (clip a interior). // Sol detrás (clip a interior).
@@ -295,9 +374,11 @@ void main() {
col += u_line_color * ring_main * 1.45; col += u_line_color * ring_main * 1.45;
col += u_rim_color * ring_inner * 1.05; col += u_rim_color * ring_inner * 1.05;
col += u_line_color * dots * 1.85; col += u_line_color * dots * 1.85;
col += particles * 1.25;
float alpha = clamp( 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); 0.0, 1.0);
fragColor = vec4(col, alpha); fragColor = vec4(col, alpha);
} }