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:
@@ -22,6 +22,7 @@ features = [
|
||||
"Window",
|
||||
"Document",
|
||||
"Element",
|
||||
"Node",
|
||||
"HtmlElement",
|
||||
"HtmlCanvasElement",
|
||||
"CssStyleDeclaration",
|
||||
@@ -30,6 +31,7 @@ features = [
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"MouseEvent",
|
||||
"PointerEvent",
|
||||
"KeyboardEvent",
|
||||
"NodeList",
|
||||
"Performance",
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
+335
-119
@@ -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();
|
||||
|
||||
// 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 };
|
||||
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 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);
|
||||
});
|
||||
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
|
||||
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);
|
||||
/// 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();
|
||||
}
|
||||
});
|
||||
document.add_event_listener_with_callback("keydown", kcb.as_ref().unchecked_ref())?;
|
||||
kcb.forget();
|
||||
|
||||
canvas.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
|
||||
cb.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));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
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 };
|
||||
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 _ = el.class_list().remove_1("open");
|
||||
let _ = el.set_attribute("aria-hidden", "true");
|
||||
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 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() {
|
||||
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));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 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(
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user