refactor: streamline technical profile component and remove unused hacker screen functionality
feat: add incident simulator component with interactive architecture diagram and telemetry console style: update global styles for dark theme and enhance incident simulator styles
This commit is contained in:
parent
e064d3c06f
commit
2d875bb9f3
|
|
@ -118,24 +118,7 @@ CREATE TABLE IF NOT EXISTS `project_skills` (
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- ------------------------------------------------------------------------------
|
-- ------------------------------------------------------------------------------
|
||||||
-- 6. TABLA: retro_scores (Tabla de Puntuaciones de Minijuegos Retro)
|
-- 6. TABLA: interactive_messages (Libro de Visitas / Mensajes Interactivos)
|
||||||
-- Justificación: Soporta la interactividad del videojuego retro en la ventana de
|
|
||||||
-- Retro OS. Los visitantes pueden ingresar su tag arcade de 3-4 letras (ej. 'BRY',
|
|
||||||
-- 'JDO') y competir por el Top 10 en tiempo real. Proporciona un endpoint público
|
|
||||||
-- interactivo perfecto para demostrar llamadas reales al backend .NET y MySQL.
|
|
||||||
-- ------------------------------------------------------------------------------
|
|
||||||
CREATE TABLE IF NOT EXISTS `retro_scores` (
|
|
||||||
`id` CHAR(36) NOT NULL,
|
|
||||||
`player_tag` VARCHAR(4) NOT NULL,
|
|
||||||
`game_code` VARCHAR(20) NOT NULL DEFAULT 'ZEN_SNAKE',
|
|
||||||
`score` INT UNSIGNED NOT NULL,
|
|
||||||
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_retro_leaderboard` (`game_code`, `score` DESC, `created_at_utc` ASC)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- ------------------------------------------------------------------------------
|
|
||||||
-- 7. TABLA: interactive_messages (Libro de Visitas / Mensajes Interactivos)
|
|
||||||
-- Justificación: Permite a reclutadores y visitantes dejar una firma o mensaje
|
-- Justificación: Permite a reclutadores y visitantes dejar una firma o mensaje
|
||||||
-- desde el comando interactivo de la terminal Linux (`guestbook sign "Mensaje"`)
|
-- desde el comando interactivo de la terminal Linux (`guestbook sign "Mensaje"`)
|
||||||
-- o el probador de API en vivo. Demuestra operaciones de escritura inmediatas
|
-- o el probador de API en vivo. Demuestra operaciones de escritura inmediatas
|
||||||
|
|
@ -153,6 +136,28 @@ CREATE TABLE IF NOT EXISTS `interactive_messages` (
|
||||||
KEY `idx_messages_approved_date` (`is_approved`, `created_at_utc` DESC)
|
KEY `idx_messages_approved_date` (`is_approved`, `created_at_utc` DESC)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
-- 7. TABLA: contact_inquiries (Consultas Técnicas & Solicitudes de Propuesta)
|
||||||
|
-- Justificación: Almacena leads comerciales B2B y requerimientos de clientes de
|
||||||
|
-- forma privada y segura. Separada estrictamente de 'interactive_messages' (que es
|
||||||
|
-- un libro de visitas público). Soporta filtrado por estado y auditoría por IP.
|
||||||
|
-- ------------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `contact_inquiries` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`full_name_or_company` VARCHAR(100) NOT NULL,
|
||||||
|
`email` VARCHAR(100) NOT NULL,
|
||||||
|
`phone` VARCHAR(30) NULL DEFAULT NULL,
|
||||||
|
`service_type` VARCHAR(50) NOT NULL DEFAULT 'Otro',
|
||||||
|
`project_details` TEXT NOT NULL,
|
||||||
|
`status` ENUM('New', 'Read', 'Replied', 'Archived') NOT NULL DEFAULT 'New',
|
||||||
|
`ip_address` VARCHAR(45) NULL DEFAULT NULL,
|
||||||
|
`created_at_utc` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`replied_at_utc` DATETIME NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_inquiries_status_created` (`status`, `created_at_utc` DESC),
|
||||||
|
KEY `idx_inquiries_created` (`created_at_utc` DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- ==============================================================================
|
-- ==============================================================================
|
||||||
-- SEED DATA INICIAL (Datos de prueba para arranque inmediato)
|
-- SEED DATA INICIAL (Datos de prueba para arranque inmediato)
|
||||||
-- ==============================================================================
|
-- ==============================================================================
|
||||||
|
|
@ -201,15 +206,7 @@ VALUES
|
||||||
('33333333-0001-0000-0000-000000000001', '22222222-0004-0000-0000-000000000004')
|
('33333333-0001-0000-0000-000000000001', '22222222-0004-0000-0000-000000000004')
|
||||||
ON DUPLICATE KEY UPDATE `project_id` = `project_id`;
|
ON DUPLICATE KEY UPDATE `project_id` = `project_id`;
|
||||||
|
|
||||||
-- 5. Scores de Prueba para el Minijuego Retro
|
-- 5. Mensaje de Bienvenida en el Libro de Visitas
|
||||||
INSERT INTO `retro_scores` (`id`, `player_tag`, `game_code`, `score`)
|
|
||||||
VALUES
|
|
||||||
(UUID(), 'ZEN', 'ZEN_SNAKE', 1450),
|
|
||||||
(UUID(), 'NEO', 'ZEN_SNAKE', 1120),
|
|
||||||
(UUID(), 'BRY', 'ZEN_SNAKE', 980)
|
|
||||||
ON DUPLICATE KEY UPDATE `player_tag` = `player_tag`;
|
|
||||||
|
|
||||||
-- 6. Mensaje de Bienvenida en el Libro de Visitas
|
|
||||||
INSERT INTO `interactive_messages` (`id`, `sender_name`, `sender_contact`, `message`, `origin`)
|
INSERT INTO `interactive_messages` (`id`, `sender_name`, `sender_contact`, `message`, `origin`)
|
||||||
VALUES (
|
VALUES (
|
||||||
UUID(),
|
UUID(),
|
||||||
|
|
@ -218,3 +215,16 @@ VALUES (
|
||||||
'Bienvenido a la terminal interactiva. Ejecuta "help" para comenzar.',
|
'Bienvenido a la terminal interactiva. Ejecuta "help" para comenzar.',
|
||||||
'Terminal'
|
'Terminal'
|
||||||
) ON DUPLICATE KEY UPDATE `sender_name` = `sender_name`;
|
) ON DUPLICATE KEY UPDATE `sender_name` = `sender_name`;
|
||||||
|
|
||||||
|
-- 6. Consulta de Contacto Inicial de Prueba
|
||||||
|
INSERT INTO `contact_inquiries` (`id`, `full_name_or_company`, `email`, `phone`, `service_type`, `project_details`, `status`)
|
||||||
|
VALUES (
|
||||||
|
'44444444-0001-0000-0000-000000000001',
|
||||||
|
'Enterprise Solutions Corp',
|
||||||
|
'contacto@enterprisesolutions.com',
|
||||||
|
'+593 99 123 4567',
|
||||||
|
'Desarrollo Backend',
|
||||||
|
'Requerimos modernización de arquitectura monolítica a microservicios con .NET 9, Clean Architecture y CQRS.',
|
||||||
|
'New'
|
||||||
|
) ON DUPLICATE KEY UPDATE `full_name_or_company` = `full_name_or_company`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
|
||||||
switchLangAria: 'Switch to Spanish',
|
switchLangAria: 'Switch to Spanish',
|
||||||
footerCopy: 'Bryan. Clean Architecture & High-Contrast Design.',
|
footerCopy: 'Bryan. Clean Architecture & High-Contrast Design.',
|
||||||
|
|
||||||
|
// Navbar Quick Navigation
|
||||||
|
navHome: 'Home',
|
||||||
|
navRetro: 'Retro Desktop',
|
||||||
|
navProjects: 'Projects',
|
||||||
|
navSimulator: 'Simulator',
|
||||||
|
navTerminal: 'Terminal',
|
||||||
|
navContact: 'Contact',
|
||||||
|
navAll: 'View All',
|
||||||
|
|
||||||
// Hero 3D
|
// Hero 3D
|
||||||
preloading: 'PRELOADING',
|
preloading: 'PRELOADING',
|
||||||
heroAriaExperience: 'ThinkPad 3D Scroll Experience',
|
heroAriaExperience: 'ThinkPad 3D Scroll Experience',
|
||||||
|
|
@ -75,7 +84,7 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
|
||||||
tpInterest3Desc: 'Active participation in tech outreach and community events (FLISOL, Google Developers Group).',
|
tpInterest3Desc: 'Active participation in tech outreach and community events (FLISOL, Google Developers Group).',
|
||||||
|
|
||||||
// Featured Projects
|
// Featured Projects
|
||||||
fpHeaderTag: '01 // PROJECTS',
|
fpHeaderTag: 'PROJECTS',
|
||||||
fpAriaLabel: "Bryan Baño's featured projects",
|
fpAriaLabel: "Bryan Baño's featured projects",
|
||||||
fpPlaceholderImage: 'No screenshots',
|
fpPlaceholderImage: 'No screenshots',
|
||||||
fpAltPrefix: 'Screenshot of ',
|
fpAltPrefix: 'Screenshot of ',
|
||||||
|
|
@ -132,7 +141,7 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
|
||||||
prj4Result3: 'Hardened security via JWT, database-driven role permissions, and prerequisite validation.',
|
prj4Result3: 'Hardened security via JWT, database-driven role permissions, and prerequisite validation.',
|
||||||
|
|
||||||
// Terminal & Workstation
|
// Terminal & Workstation
|
||||||
terminalWorkstationTag: '02 // INTERACTIVE WORKSTATION',
|
terminalWorkstationTag: 'INTERACTIVE WORKSTATION',
|
||||||
terminalHeading: 'Arch Linux Terminal // Fish Shell',
|
terminalHeading: 'Arch Linux Terminal // Fish Shell',
|
||||||
terminalSectionDesc: 'Full-scale interactive console to query technical ecosystem, skills, and live database.',
|
terminalSectionDesc: 'Full-scale interactive console to query technical ecosystem, skills, and live database.',
|
||||||
terminalBanner: `bryan@archlinux
|
terminalBanner: `bryan@archlinux
|
||||||
|
|
@ -251,27 +260,49 @@ Approach: Zero over-engineering (Ponytail), high throughput, and Zen aesthetic d
|
||||||
retroSetSound: 'Web Audio API Sound',
|
retroSetSound: 'Web Audio API Sound',
|
||||||
retroSetSoundDesc: 'Synthetic mechanical and arcade effects',
|
retroSetSoundDesc: 'Synthetic mechanical and arcade effects',
|
||||||
retroScoresHeader: '★ ARCADE LEADERBOARD',
|
retroScoresHeader: '★ ARCADE LEADERBOARD',
|
||||||
retroScoresTitle: 'ZEN SNAKE // TOP 10',
|
retroScoresTitle: 'MINESWEEPER // TOP 10',
|
||||||
retroScoresRefresh: 'REFRESH',
|
retroScoresRefresh: 'REFRESH',
|
||||||
retroScoresRank: '#',
|
retroScoresRank: '#',
|
||||||
retroScoresTag: 'TAG',
|
retroScoresTag: 'TAG',
|
||||||
retroScoresScore: 'SCORE',
|
retroScoresScore: 'TIME (S)',
|
||||||
retroScoresDate: 'DATE',
|
retroScoresDate: 'DATE',
|
||||||
retroScoresEmpty: 'No scores recorded',
|
retroScoresEmpty: 'No scores recorded',
|
||||||
retroGameScore: 'SCORE:',
|
retroGameScore: 'TIME:',
|
||||||
retroGameHigh: 'BEST:',
|
retroGameHigh: 'BEST:',
|
||||||
retroGameStart: '▶ Start',
|
retroGameStart: '▶ Start',
|
||||||
retroGameRestart: 'Restart',
|
retroGameRestart: 'Restart',
|
||||||
retroGamePause: 'Pause',
|
retroGamePause: 'Pause',
|
||||||
retroGameResume: 'Resume',
|
retroGameResume: 'Resume',
|
||||||
retroGameBanner: 'Press any Arrow key or click to start',
|
retroGameBanner: 'Click any tile to start playing',
|
||||||
retroGameOver: 'GAME OVER!',
|
retroGameOver: 'BOOM! GAME OVER',
|
||||||
retroGameScoreLabel: 'Score:',
|
retroGameScoreLabel: 'Time:',
|
||||||
retroGameSave: 'Save',
|
retroGameSave: 'Save',
|
||||||
retroGameSaved: '✓ Score saved',
|
retroGameSaved: '✓ Score saved',
|
||||||
retroGamePlayAgain: '↺ Play Again',
|
retroGameWin: 'VICTORY! MINES CLEARED',
|
||||||
retroGameUp: 'Up',
|
retroGameMines: 'Mines:',
|
||||||
retroGameLeft: 'Left',
|
retroGameFlagMode: 'Flag Mode',
|
||||||
retroGameDown: 'Down',
|
retroGameDigMode: 'Dig Mode',
|
||||||
retroGameRight: 'Right'
|
|
||||||
|
// Cloud Incident Simulator
|
||||||
|
simTag: '03 // CLOUD ARCHITECTURE & SERVERLESS SECURITY',
|
||||||
|
simHeading: 'Real-Time Incident Response Simulator',
|
||||||
|
simSubheading: 'Interactive visualization of an automated active-defense architecture in AWS. Experience how an unauthorized IAM access event is detected, mitigated by a Lambda function, and dispatched via SES in under 180 ms without manual intervention.',
|
||||||
|
simBtnSimulate: 'Simulate Unauthorized IAM Access',
|
||||||
|
simBtnSimulating: 'Containing Threat in Real-Time...',
|
||||||
|
simBtnReset: 'Reset Simulation',
|
||||||
|
simThreatNodeTitle: 'Threat Vector',
|
||||||
|
simThreatNodeSub: 'Anomalous iam:CreateAccessKey',
|
||||||
|
simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge',
|
||||||
|
simCloudTrailNodeSub: 'Event ingestion & pattern match',
|
||||||
|
simLambdaNodeTitle: 'AWS Lambda (Remediation)',
|
||||||
|
simLambdaNodeSub: 'Session revocation & DenyAll',
|
||||||
|
simSesNodeTitle: 'Amazon SES / SecOps Alert',
|
||||||
|
simSesNodeSub: 'Incident report dispatched',
|
||||||
|
simConsoleTitle: 'SecOps Telemetry Console // Live Stream',
|
||||||
|
simMetricLatency: 'Containment Latency',
|
||||||
|
simMetricIntervention: 'Manual Intervention',
|
||||||
|
simMetricArchitecture: 'Serverless Architecture',
|
||||||
|
simStatusIdle: 'SYSTEM NOMINAL // LISTENING FOR EVENTS',
|
||||||
|
simStatusActive: 'THREAT DETECTED // EXECUTING REMEDIATION',
|
||||||
|
simStatusContained: 'THREAT CONTAINED // SECURE STATE RESTORED'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
|
||||||
switchLangAria: 'Cambiar a Inglés',
|
switchLangAria: 'Cambiar a Inglés',
|
||||||
footerCopy: 'Bryan. Clean Architecture & Diseño de Alto Contraste.',
|
footerCopy: 'Bryan. Clean Architecture & Diseño de Alto Contraste.',
|
||||||
|
|
||||||
|
// Navbar Quick Navigation
|
||||||
|
navHome: 'Inicio',
|
||||||
|
navRetro: 'Desktop Retro',
|
||||||
|
navProjects: 'Proyectos',
|
||||||
|
navSimulator: 'Simulador',
|
||||||
|
navTerminal: 'Terminal',
|
||||||
|
navContact: 'Contacto',
|
||||||
|
navAll: 'Ver Todo',
|
||||||
|
|
||||||
// Hero 3D
|
// Hero 3D
|
||||||
preloading: 'PRECARGANDO',
|
preloading: 'PRECARGANDO',
|
||||||
heroAriaExperience: 'Experiencia 3D ThinkPad en Scroll',
|
heroAriaExperience: 'Experiencia 3D ThinkPad en Scroll',
|
||||||
|
|
@ -75,7 +84,7 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
|
||||||
tpInterest3Desc: 'Participación activa en eventos de difusión tecnológica (FLISOL, Google Developers Group).',
|
tpInterest3Desc: 'Participación activa en eventos de difusión tecnológica (FLISOL, Google Developers Group).',
|
||||||
|
|
||||||
// Featured Projects
|
// Featured Projects
|
||||||
fpHeaderTag: '01 // PROYECTOS',
|
fpHeaderTag: 'PROYECTOS',
|
||||||
fpAriaLabel: 'Proyectos reales de Bryan Baño',
|
fpAriaLabel: 'Proyectos reales de Bryan Baño',
|
||||||
fpPlaceholderImage: 'Sin capturas',
|
fpPlaceholderImage: 'Sin capturas',
|
||||||
fpAltPrefix: 'Captura de pantalla de ',
|
fpAltPrefix: 'Captura de pantalla de ',
|
||||||
|
|
@ -132,7 +141,7 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
|
||||||
prj4Result3: 'Seguridad robusta con JWT, permisos por rol desde base de datos y control exhaustivo de requisitos previos.',
|
prj4Result3: 'Seguridad robusta con JWT, permisos por rol desde base de datos y control exhaustivo de requisitos previos.',
|
||||||
|
|
||||||
// Terminal & Workstation
|
// Terminal & Workstation
|
||||||
terminalWorkstationTag: '02 // INTERACTIVE WORKSTATION',
|
terminalWorkstationTag: 'INTERACTIVE WORKSTATION',
|
||||||
terminalHeading: 'Terminal Arch Linux // Fish Shell',
|
terminalHeading: 'Terminal Arch Linux // Fish Shell',
|
||||||
terminalSectionDesc: 'Consola interactiva a escala completa para consultar el ecosistema técnico, habilidades y base de datos.',
|
terminalSectionDesc: 'Consola interactiva a escala completa para consultar el ecosistema técnico, habilidades y base de datos.',
|
||||||
terminalBanner: `bryan@archlinux
|
terminalBanner: `bryan@archlinux
|
||||||
|
|
@ -169,7 +178,7 @@ Enfoque: Cero sobreingeniería (Ponytail), alta concurrencia y diseño estético
|
||||||
terminalUnknownCmd: "fish: Comando desconocido '{cmd}'. Escribe 'help' para ver la lista de comandos disponibles.",
|
terminalUnknownCmd: "fish: Comando desconocido '{cmd}'. Escribe 'help' para ver la lista de comandos disponibles.",
|
||||||
|
|
||||||
// Contact Section
|
// Contact Section
|
||||||
contactEyebrow: '03 // CONTACTO',
|
contactEyebrow: 'CONTACTO',
|
||||||
contactTitle: 'Software a Medida, Arquitectura Limpia & Seguridad por Diseño',
|
contactTitle: 'Software a Medida, Arquitectura Limpia & Seguridad por Diseño',
|
||||||
contactLead: 'Desarrollo Full Stack (Angular, C# .NET), arquitecturas basadas en microservicios, optimización cloud en AWS y diseño de bases de datos relacionales. Cuéntame tu proyecto y te responderé con una propuesta técnica en menos de 24 horas.',
|
contactLead: 'Desarrollo Full Stack (Angular, C# .NET), arquitecturas basadas en microservicios, optimización cloud en AWS y diseño de bases de datos relacionales. Cuéntame tu proyecto y te responderé con una propuesta técnica en menos de 24 horas.',
|
||||||
contactService1Title: 'Desarrollo Full Stack & Arquitectura Limpia',
|
contactService1Title: 'Desarrollo Full Stack & Arquitectura Limpia',
|
||||||
|
|
@ -250,28 +259,50 @@ Enfoque: Cero sobreingeniería (Ponytail), alta concurrencia y diseño estético
|
||||||
retroSetScanlinesDesc: 'Líneas de barrido de monitor CRT analógico',
|
retroSetScanlinesDesc: 'Líneas de barrido de monitor CRT analógico',
|
||||||
retroSetSound: 'Sonido Web Audio API',
|
retroSetSound: 'Sonido Web Audio API',
|
||||||
retroSetSoundDesc: 'Efectos mecánicos y arcade sintéticos',
|
retroSetSoundDesc: 'Efectos mecánicos y arcade sintéticos',
|
||||||
retroScoresHeader: '★ ARCADE LEADERBOARD',
|
retroScoresHeader: '★ TABLA DE RÉCORDS',
|
||||||
retroScoresTitle: 'ZEN SNAKE // TOP 10',
|
retroScoresTitle: 'BUSCAMINAS // TOP 10',
|
||||||
retroScoresRefresh: 'ACTUALIZAR',
|
retroScoresRefresh: 'ACTUALIZAR',
|
||||||
retroScoresRank: '#',
|
retroScoresRank: '#',
|
||||||
retroScoresTag: 'TAG',
|
retroScoresTag: 'TAG',
|
||||||
retroScoresScore: 'PUNTOS',
|
retroScoresScore: 'TIEMPO (S)',
|
||||||
retroScoresDate: 'FECHA',
|
retroScoresDate: 'FECHA',
|
||||||
retroScoresEmpty: 'No hay puntuaciones registradas',
|
retroScoresEmpty: 'No hay puntuaciones registradas',
|
||||||
retroGameScore: 'PUNTOS:',
|
retroGameScore: 'TIEMPO:',
|
||||||
retroGameHigh: 'RÉCORD:',
|
retroGameHigh: 'MEJOR:',
|
||||||
retroGameStart: '▶ Iniciar',
|
retroGameStart: '▶ Iniciar',
|
||||||
retroGameRestart: 'Reiniciar',
|
retroGameRestart: 'Reiniciar',
|
||||||
retroGamePause: 'Pausa',
|
retroGamePause: 'Pausa',
|
||||||
retroGameResume: 'Reanudar',
|
retroGameResume: 'Reanudar',
|
||||||
retroGameBanner: 'Pulsa cualquier Flecha o haz clic para empezar',
|
retroGameBanner: 'Haz clic en una casilla para comenzar',
|
||||||
retroGameOver: '¡FIN DE LA PARTIDA!',
|
retroGameOver: '¡BOOM! HAS PERDIDO',
|
||||||
retroGameScoreLabel: 'Puntuación:',
|
retroGameScoreLabel: 'Tiempo:',
|
||||||
retroGameSave: 'Guardar',
|
retroGameSave: 'Guardar',
|
||||||
retroGameSaved: '✓ Récord guardado',
|
retroGameSaved: '✓ Récord guardado',
|
||||||
retroGamePlayAgain: '↺ Jugar Otra Vez',
|
retroGameWin: '¡VICTORIA! MINAS DESPEJADAS',
|
||||||
retroGameUp: 'Arriba',
|
retroGameMines: 'Minas:',
|
||||||
retroGameLeft: 'Izquierda',
|
retroGameFlagMode: 'Marcar Bandera',
|
||||||
retroGameDown: 'Abajo',
|
retroGameDigMode: 'Descubrir',
|
||||||
retroGameRight: 'Derecha'
|
|
||||||
|
// Simulador de Respuesta a Incidentes Cloud
|
||||||
|
simTag: 'ARQUITECTURA CLOUD & SEGURIDAD SERVERLESS',
|
||||||
|
simHeading: 'Simulador de Respuesta a Incidentes en Tiempo Real',
|
||||||
|
simSubheading: 'Visualización interactiva de una arquitectura de contención reactiva en AWS. Experimenta cómo un evento de acceso IAM no autorizado es detectado, mitigado por una función Lambda y reportado vía SES en menos de 180 ms sin intervención manual.',
|
||||||
|
simBtnSimulate: 'Simular Acceso IAM No Autorizado',
|
||||||
|
simBtnSimulating: 'Conteniendo Amenaza en Tiempo Real...',
|
||||||
|
simBtnReset: 'Reiniciar Simulación',
|
||||||
|
simThreatNodeTitle: 'Vector de Amenaza',
|
||||||
|
simThreatNodeSub: 'iam:CreateAccessKey anómalo',
|
||||||
|
simCloudTrailNodeTitle: 'AWS CloudTrail & EventBridge',
|
||||||
|
simCloudTrailNodeSub: 'Ingesta y análisis de eventos',
|
||||||
|
simLambdaNodeTitle: 'AWS Lambda (Remediación)',
|
||||||
|
simLambdaNodeSub: 'Revocación de sesión & DenyAll',
|
||||||
|
simSesNodeTitle: 'Amazon SES / SecOps Alert',
|
||||||
|
simSesNodeSub: 'Reporte de incidente despachado',
|
||||||
|
simConsoleTitle: 'Consola de Telemetría SecOps // Live Stream',
|
||||||
|
simMetricLatency: 'Tiempo de Contención',
|
||||||
|
simMetricIntervention: 'Intervención Manual',
|
||||||
|
simMetricArchitecture: 'Arquitectura Serverless',
|
||||||
|
simStatusIdle: 'SISTEMA NOMINAL // ESCUCHANDO EVENTOS',
|
||||||
|
simStatusActive: 'AMENAZA DETECTADA // EJECUTANDO MITIGACIÓN',
|
||||||
|
simStatusContained: 'AMENAZA NEUTRALIZADA // ESTADO SEGURO RESTAURADO'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,23 +42,83 @@ export class RetroAudioService {
|
||||||
osc.stop(ctx.currentTime + 0.02);
|
osc.stop(ctx.currentTime + 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
playSnakeEat(): void {
|
playCellReveal(): void {
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const osc = ctx.createOscillator();
|
const osc = ctx.createOscillator();
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
osc.type = 'sine';
|
osc.type = 'triangle';
|
||||||
osc.frequency.setValueAtTime(440, ctx.currentTime);
|
osc.frequency.setValueAtTime(520, ctx.currentTime);
|
||||||
osc.frequency.exponentialRampToValueAtTime(880, ctx.currentTime + 0.08);
|
osc.frequency.exponentialRampToValueAtTime(780, ctx.currentTime + 0.03);
|
||||||
|
|
||||||
gain.gain.setValueAtTime(0.06, ctx.currentTime);
|
gain.gain.setValueAtTime(0.04, ctx.currentTime);
|
||||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03);
|
||||||
|
|
||||||
osc.connect(gain);
|
osc.connect(gain);
|
||||||
gain.connect(ctx.destination);
|
gain.connect(ctx.destination);
|
||||||
osc.start();
|
osc.start();
|
||||||
osc.stop(ctx.currentTime + 0.08);
|
osc.stop(ctx.currentTime + 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
playFlagToggle(): void {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'square';
|
||||||
|
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(1100, ctx.currentTime + 0.025);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.03, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.025);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
playMineExplosion(): void {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(180, ctx.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(30, ctx.currentTime + 0.45);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.12, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
playVictory(): void {
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const notes = [523.25, 659.25, 783.99, 1046.50]; // Do, Mi, Sol, Do alto
|
||||||
|
notes.forEach((freq, index) => {
|
||||||
|
const startTime = ctx.currentTime + index * 0.08;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, startTime);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.06, startTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.15);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(startTime);
|
||||||
|
osc.stop(startTime + 0.15);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
playGameOver(): void {
|
playGameOver(): void {
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,14 @@ import { I18nService } from '../services/i18n.service';
|
||||||
const INITIAL_WINDOWS: Record<WindowId, RetroWindow> = {
|
const INITIAL_WINDOWS: Record<WindowId, RetroWindow> = {
|
||||||
games: {
|
games: {
|
||||||
id: 'games',
|
id: 'games',
|
||||||
title: 'Snake - Juegos de Windows XP',
|
title: 'Buscaminas - Juegos Clásicos',
|
||||||
icon: 'gamepad',
|
icon: 'minesweeper',
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
isMinimized: false,
|
isMinimized: false,
|
||||||
position: { x: 70, y: 20 },
|
position: { x: 70, y: 20 },
|
||||||
size: { width: 360, height: 490 },
|
size: { width: 270, height: 360 },
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
minSize: { width: 320, height: 380 }
|
minSize: { width: 260, height: 340 }
|
||||||
},
|
},
|
||||||
cv: {
|
cv: {
|
||||||
id: 'cv',
|
id: 'cv',
|
||||||
|
|
@ -70,7 +70,7 @@ export class RetroOsStore {
|
||||||
readonly desktopIcons = computed<DesktopIcon[]>(() => {
|
readonly desktopIcons = computed<DesktopIcon[]>(() => {
|
||||||
const t = this.i18n.t();
|
const t = this.i18n.t();
|
||||||
return [
|
return [
|
||||||
{ id: 'icon-games', label: t.retroIconGames, windowId: 'games', iconType: 'gamepad' },
|
{ id: 'icon-games', label: t.retroIconGames, windowId: 'games', iconType: 'minesweeper' },
|
||||||
{ id: 'icon-cv', label: t.retroIconCv, windowId: 'cv', iconType: 'document' },
|
{ id: 'icon-cv', label: t.retroIconCv, windowId: 'cv', iconType: 'document' },
|
||||||
{ id: 'icon-scores', label: t.retroIconScores, windowId: 'scores', iconType: 'trophy' },
|
{ id: 'icon-scores', label: t.retroIconScores, windowId: 'scores', iconType: 'trophy' },
|
||||||
{ id: 'icon-settings', label: t.retroIconSettings, windowId: 'settings', iconType: 'gear' }
|
{ id: 'icon-settings', label: t.retroIconSettings, windowId: 'settings', iconType: 'gear' }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ export interface TranslationDictionary {
|
||||||
readonly switchLangAria: string;
|
readonly switchLangAria: string;
|
||||||
readonly footerCopy: string;
|
readonly footerCopy: string;
|
||||||
|
|
||||||
|
// Navbar Quick Navigation
|
||||||
|
readonly navHome: string;
|
||||||
|
readonly navRetro: string;
|
||||||
|
readonly navProjects: string;
|
||||||
|
readonly navSimulator: string;
|
||||||
|
readonly navTerminal: string;
|
||||||
|
readonly navContact: string;
|
||||||
|
readonly navAll: string;
|
||||||
|
|
||||||
// Hero 3D
|
// Hero 3D
|
||||||
readonly preloading: string;
|
readonly preloading: string;
|
||||||
readonly heroAriaExperience: string;
|
readonly heroAriaExperience: string;
|
||||||
|
|
@ -237,9 +246,31 @@ export interface TranslationDictionary {
|
||||||
readonly retroGameScoreLabel: string;
|
readonly retroGameScoreLabel: string;
|
||||||
readonly retroGameSave: string;
|
readonly retroGameSave: string;
|
||||||
readonly retroGameSaved: string;
|
readonly retroGameSaved: string;
|
||||||
readonly retroGamePlayAgain: string;
|
readonly retroGameWin: string;
|
||||||
readonly retroGameUp: string;
|
readonly retroGameMines: string;
|
||||||
readonly retroGameLeft: string;
|
readonly retroGameFlagMode: string;
|
||||||
readonly retroGameDown: string;
|
readonly retroGameDigMode: string;
|
||||||
readonly retroGameRight: string;
|
|
||||||
|
// Cloud Incident Simulator
|
||||||
|
readonly simTag: string;
|
||||||
|
readonly simHeading: string;
|
||||||
|
readonly simSubheading: string;
|
||||||
|
readonly simBtnSimulate: string;
|
||||||
|
readonly simBtnSimulating: string;
|
||||||
|
readonly simBtnReset: string;
|
||||||
|
readonly simThreatNodeTitle: string;
|
||||||
|
readonly simThreatNodeSub: string;
|
||||||
|
readonly simCloudTrailNodeTitle: string;
|
||||||
|
readonly simCloudTrailNodeSub: string;
|
||||||
|
readonly simLambdaNodeTitle: string;
|
||||||
|
readonly simLambdaNodeSub: string;
|
||||||
|
readonly simSesNodeTitle: string;
|
||||||
|
readonly simSesNodeSub: string;
|
||||||
|
readonly simConsoleTitle: string;
|
||||||
|
readonly simMetricLatency: string;
|
||||||
|
readonly simMetricIntervention: string;
|
||||||
|
readonly simMetricArchitecture: string;
|
||||||
|
readonly simStatusIdle: string;
|
||||||
|
readonly simStatusActive: string;
|
||||||
|
readonly simStatusContained: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
export interface Point {
|
export interface MinesweeperCell {
|
||||||
readonly x: number;
|
readonly row: number;
|
||||||
readonly y: number;
|
readonly col: number;
|
||||||
|
isMine: boolean;
|
||||||
|
isRevealed: boolean;
|
||||||
|
isFlagged: boolean;
|
||||||
|
isExploded?: boolean;
|
||||||
|
adjacentMines: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT';
|
export type MinesweeperFace = 'idle' | 'pressed' | 'dead' | 'won' | 'smile' | 'shock' | 'cool';
|
||||||
|
|
||||||
export type GameDifficulty = 'ZEN' | 'STANDARD' | 'TURBO';
|
|
||||||
|
|
||||||
export interface FoodItem {
|
|
||||||
readonly position: Point;
|
|
||||||
readonly kanji: string;
|
|
||||||
readonly points: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RetroScore {
|
export interface RetroScore {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|
@ -21,11 +18,3 @@ export interface RetroScore {
|
||||||
readonly createdAtUtc: string;
|
readonly createdAtUtc: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
|
||||||
readonly score: number;
|
|
||||||
readonly highScore: number;
|
|
||||||
readonly isGameOver: boolean;
|
|
||||||
readonly isPaused: boolean;
|
|
||||||
readonly isRunning: boolean;
|
|
||||||
readonly difficulty: GameDifficulty;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export interface DesktopIcon {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly windowId: WindowId;
|
readonly windowId: WindowId;
|
||||||
readonly iconType: 'gamepad' | 'document' | 'terminal' | 'gear' | 'trophy';
|
readonly iconType: 'gamepad' | 'document' | 'terminal' | 'gear' | 'trophy' | 'minesweeper';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ZenOsConfig {
|
export interface ZenOsConfig {
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,22 @@ const INITIAL_SEEDS: RetroScore[] = [
|
||||||
{
|
{
|
||||||
id: 'seed-1',
|
id: 'seed-1',
|
||||||
playerTag: 'ZEN',
|
playerTag: 'ZEN',
|
||||||
gameCode: 'ZEN_SNAKE',
|
gameCode: 'MINESWEEPER',
|
||||||
score: 1450,
|
score: 42,
|
||||||
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'seed-2',
|
id: 'seed-2',
|
||||||
playerTag: 'NEO',
|
playerTag: 'NEO',
|
||||||
gameCode: 'ZEN_SNAKE',
|
gameCode: 'MINESWEEPER',
|
||||||
score: 1120,
|
score: 68,
|
||||||
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'seed-3',
|
id: 'seed-3',
|
||||||
playerTag: 'BRY',
|
playerTag: 'BRY',
|
||||||
gameCode: 'ZEN_SNAKE',
|
gameCode: 'MINESWEEPER',
|
||||||
score: 980,
|
score: 95,
|
||||||
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
createdAtUtc: '2026-08-28T00:00:00.000Z'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
@ -54,9 +54,14 @@ export class LocalStorageRetroScoreAdapter implements RetroScorePort {
|
||||||
|
|
||||||
getTopScores(gameCode: string, limit = 10): Observable<RetroScore[]> {
|
getTopScores(gameCode: string, limit = 10): Observable<RetroScore[]> {
|
||||||
const scores = this.readScores();
|
const scores = this.readScores();
|
||||||
|
const isTimeBased = gameCode === 'MINESWEEPER';
|
||||||
const filtered = scores
|
const filtered = scores
|
||||||
.filter(s => s.gameCode === gameCode)
|
.filter(s => s.gameCode === gameCode)
|
||||||
.sort((a, b) => b.score - a.score || a.createdAtUtc.localeCompare(b.createdAtUtc))
|
.sort((a, b) =>
|
||||||
|
isTimeBased
|
||||||
|
? a.score - b.score || a.createdAtUtc.localeCompare(b.createdAtUtc)
|
||||||
|
: b.score - a.score || a.createdAtUtc.localeCompare(b.createdAtUtc)
|
||||||
|
)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
return of(filtered);
|
return of(filtered);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,41 +5,84 @@
|
||||||
|
|
||||||
/* 1. SECCION PRINCIPAL — Sumi-e Background & Ma (Espacio Negativo) */
|
/* 1. SECCION PRINCIPAL — Sumi-e Background & Ma (Espacio Negativo) */
|
||||||
.contact-section {
|
.contact-section {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
padding: clamp(5rem, 8vw, 7rem) 0;
|
padding: clamp(5rem, 8vw, 7rem) 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-matrix-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeInMatrix 1.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInMatrix {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.65;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-container {
|
.contact-container {
|
||||||
max-width: 72rem;
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 1540px;
|
||||||
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 clamp(1rem, 3vw, 2.5rem);
|
padding: 0 clamp(1rem, 3vw, 2.5rem);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. MICRO-HEADER — Patrón de Estado Retro */
|
/* Cabecera Normalizada de Sección */
|
||||||
.contact-micro-header {
|
.contact-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--zen-border-subtle);
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-section-header .header-left,
|
||||||
|
.contact-section-header .title-with-badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-bottom: var(--space-12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-pulse-dot {
|
.contact-section-header .pulse-indicator {
|
||||||
width: 6px;
|
width: 10px;
|
||||||
height: 6px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--zen-accent-kintsugi);
|
background-color: var(--zen-accent-kintsugi);
|
||||||
box-shadow: 0 0 8px var(--zen-accent-kintsugi);
|
box-shadow: 0 0 12px var(--zen-accent-kintsugi);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-header-tag {
|
.contact-section-header .section-title {
|
||||||
font-family: var(--font-mono);
|
margin: 0;
|
||||||
font-size: 0.6875rem;
|
font-family: var(--font-sans);
|
||||||
font-weight: 600;
|
font-size: clamp(1.6rem, 3vw, 2.25rem);
|
||||||
letter-spacing: 0.14em;
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--zen-text-primary);
|
||||||
|
line-height: 1.15;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--zen-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. GRID DE CONTENIDO — 2 Columnas Responsive */
|
/* 3. GRID DE CONTENIDO — 2 Columnas Responsive */
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
<section class="contact-section" id="contacto" role="region" [attr.aria-label]="i18n.t().contactEyebrow">
|
<section class="contact-section" id="contacto" role="region" [attr.aria-label]="i18n.t().contactEyebrow">
|
||||||
|
<!-- Fondo Animado: Lluvia de Código Matrix Verde -->
|
||||||
|
<canvas #rainCanvas class="contact-matrix-canvas" aria-hidden="true"></canvas>
|
||||||
|
|
||||||
<div class="contact-container">
|
<div class="contact-container">
|
||||||
<!-- Micro-Header -->
|
<!-- Cabecera Normalizada de Sección -->
|
||||||
<header class="contact-micro-header">
|
<header class="contact-section-header">
|
||||||
<span class="contact-pulse-dot" aria-hidden="true"></span>
|
<div class="header-left">
|
||||||
<span class="contact-header-tag">{{ i18n.t().contactEyebrow }}</span>
|
<div class="title-with-badge">
|
||||||
|
<span class="pulse-indicator" aria-hidden="true"></span>
|
||||||
|
<h2 class="section-title">{{ i18n.t().contactEyebrow }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="contact-grid">
|
<div class="contact-grid">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
signal,
|
||||||
|
inject,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
OnInit,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
|
|
@ -7,6 +17,20 @@ import {
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
|
interface DigitalRainColumn {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
speed: number;
|
||||||
|
length: number;
|
||||||
|
opacity: number;
|
||||||
|
characters: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BINARY_CHARS = '01';
|
||||||
|
const HEX_CHARS = '0123456789ABCDEF';
|
||||||
|
const CODE_CHARS = '{}[]()<>&|/\\:;=+-_*#$%@?!~^';
|
||||||
|
const TERM_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-contact-section',
|
selector: 'app-contact-section',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -15,7 +39,9 @@ import { I18nService } from '../../../application/services/i18n.service';
|
||||||
styleUrl: './contact-section.component.css',
|
styleUrl: './contact-section.component.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class ContactSectionComponent {
|
export class ContactSectionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('rainCanvas') private readonly rainCanvas?: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
// Estado reactivo del formulario con Signals
|
// Estado reactivo del formulario con Signals
|
||||||
|
|
@ -45,6 +71,191 @@ export class ContactSectionComponent {
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly columns: DigitalRainColumn[] = [];
|
||||||
|
private context?: CanvasRenderingContext2D;
|
||||||
|
private animationFrameId?: number;
|
||||||
|
private intersectionObserver?: IntersectionObserver;
|
||||||
|
private resizeObserver?: ResizeObserver;
|
||||||
|
private motionQuery?: MediaQueryList;
|
||||||
|
private isVisible = true;
|
||||||
|
private isReducedMotion = false;
|
||||||
|
private lastFrameTime = 0;
|
||||||
|
|
||||||
|
ngOnInit(): void {}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (!this.rainCanvas) return;
|
||||||
|
|
||||||
|
const canvas = this.rainCanvas.nativeElement;
|
||||||
|
this.context = canvas.getContext('2d') ?? undefined;
|
||||||
|
if (!this.context) return;
|
||||||
|
|
||||||
|
this.motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
this.isReducedMotion = this.motionQuery.matches;
|
||||||
|
this.motionQuery.addEventListener('change', this.onMotionPreferenceChange);
|
||||||
|
|
||||||
|
this.resizeObserver = new ResizeObserver(() => this.resizeCanvas());
|
||||||
|
this.resizeObserver.observe(canvas.parentElement ?? canvas);
|
||||||
|
|
||||||
|
this.intersectionObserver = new IntersectionObserver(([entry]) => {
|
||||||
|
this.isVisible = entry.isIntersecting;
|
||||||
|
if (this.isVisible && !this.isReducedMotion && this.animationFrameId === undefined) {
|
||||||
|
this.lastFrameTime = 0;
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.animate);
|
||||||
|
}
|
||||||
|
}, { threshold: 0.05 });
|
||||||
|
this.intersectionObserver.observe(canvas);
|
||||||
|
|
||||||
|
this.resizeCanvas();
|
||||||
|
if (!this.isReducedMotion) {
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.animate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.animationFrameId !== undefined) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
}
|
||||||
|
this.motionQuery?.removeEventListener('change', this.onMotionPreferenceChange);
|
||||||
|
this.intersectionObserver?.disconnect();
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onMotionPreferenceChange = (event: MediaQueryListEvent): void => {
|
||||||
|
this.isReducedMotion = event.matches;
|
||||||
|
if (this.isReducedMotion) {
|
||||||
|
if (this.animationFrameId !== undefined) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = undefined;
|
||||||
|
}
|
||||||
|
this.draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isVisible && this.animationFrameId === undefined) {
|
||||||
|
this.lastFrameTime = 0;
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly animate = (timestamp: number): void => {
|
||||||
|
this.animationFrameId = undefined;
|
||||||
|
if (!this.isVisible || this.isReducedMotion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = this.lastFrameTime === 0 ? 0 : Math.min(timestamp - this.lastFrameTime, 50);
|
||||||
|
this.lastFrameTime = timestamp;
|
||||||
|
this.update(elapsed / 1000);
|
||||||
|
this.draw();
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
private resizeCanvas(): void {
|
||||||
|
if (!this.rainCanvas) return;
|
||||||
|
|
||||||
|
const canvas = this.rainCanvas.nativeElement;
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
const bounds = parent ? parent.getBoundingClientRect() : canvas.getBoundingClientRect();
|
||||||
|
const width = Math.max(bounds.width, window.innerWidth || 320);
|
||||||
|
const height = Math.max(bounds.height, 700);
|
||||||
|
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
|
canvas.width = Math.max(1, Math.floor(width * pixelRatio));
|
||||||
|
canvas.height = Math.max(1, Math.floor(height * pixelRatio));
|
||||||
|
this.context?.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
|
this.createColumns(width, height);
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createColumns(width: number, height: number): void {
|
||||||
|
const fontSize = Math.max(14, Math.min(20, width / 58));
|
||||||
|
const columnCount = Math.max(24, Math.floor(width / (fontSize * 0.72)));
|
||||||
|
this.columns.length = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < columnCount; index += 1) {
|
||||||
|
const length = Math.floor(Math.random() * 25) + 14;
|
||||||
|
this.columns.push({
|
||||||
|
x: (index / columnCount) * width + Math.random() * fontSize * 0.6,
|
||||||
|
y: -Math.random() * (height * 0.9) - length * fontSize,
|
||||||
|
speed: Math.floor(Math.random() * 130) + 150,
|
||||||
|
length,
|
||||||
|
opacity: 0.25 + Math.random() * 0.65,
|
||||||
|
characters: Array.from({ length }, () => this.randomCharacter())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(deltaSeconds: number): void {
|
||||||
|
if (!this.rainCanvas) return;
|
||||||
|
const parent = this.rainCanvas.nativeElement.parentElement;
|
||||||
|
const height = parent ? parent.clientHeight : this.rainCanvas.nativeElement.clientHeight;
|
||||||
|
const width = parent ? parent.clientWidth : this.rainCanvas.nativeElement.clientWidth;
|
||||||
|
const fontSize = Math.max(14, Math.min(20, width / 58));
|
||||||
|
|
||||||
|
for (const column of this.columns) {
|
||||||
|
column.y += column.speed * deltaSeconds;
|
||||||
|
if (column.y - column.length * fontSize > height) {
|
||||||
|
column.y = -Math.random() * height * 0.75 - column.length * fontSize;
|
||||||
|
column.speed = Math.floor(Math.random() * 130) + 150;
|
||||||
|
column.length = Math.floor(Math.random() * 25) + 14;
|
||||||
|
column.opacity = 0.25 + Math.random() * 0.65;
|
||||||
|
column.characters = Array.from({ length: column.length }, () => this.randomCharacter());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(): void {
|
||||||
|
if (!this.rainCanvas || !this.context) return;
|
||||||
|
|
||||||
|
const canvas = this.rainCanvas.nativeElement;
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
const width = parent ? parent.clientWidth : canvas.clientWidth;
|
||||||
|
const height = parent ? parent.clientHeight : canvas.clientHeight;
|
||||||
|
const fontSize = Math.max(14, Math.min(20, width / 58));
|
||||||
|
|
||||||
|
this.context.clearRect(0, 0, width, height);
|
||||||
|
this.context.font = `${fontSize}px monospace`;
|
||||||
|
this.context.textAlign = 'center';
|
||||||
|
this.context.textBaseline = 'top';
|
||||||
|
this.context.shadowBlur = 0;
|
||||||
|
|
||||||
|
for (const column of this.columns) {
|
||||||
|
for (let i = 0; i < column.characters.length; i++) {
|
||||||
|
const charY = column.y + i * fontSize;
|
||||||
|
if (charY < -fontSize || charY > height) continue;
|
||||||
|
|
||||||
|
const isHead = i === 0;
|
||||||
|
const isHighlight = i === Math.floor(column.length * 0.35) && column.opacity > 0.4;
|
||||||
|
const trailAlpha = column.opacity * (1 - i / (column.length * 1.15));
|
||||||
|
const edgeFade = Math.min(1, Math.max(0, (height - charY) / (fontSize * 5)));
|
||||||
|
const alpha = Math.max(0, trailAlpha * edgeFade);
|
||||||
|
|
||||||
|
if (alpha <= 0) continue;
|
||||||
|
|
||||||
|
this.context.fillStyle = isHead
|
||||||
|
? `rgba(220, 255, 226, ${Math.min(1, alpha + 0.35)})`
|
||||||
|
: isHighlight
|
||||||
|
? `rgba(80, 255, 126, ${alpha})`
|
||||||
|
: `rgba(18, 174, 78, ${alpha})`;
|
||||||
|
|
||||||
|
if (isHead || isHighlight) {
|
||||||
|
this.context.shadowColor = 'rgba(37, 255, 104, 0.85)';
|
||||||
|
this.context.shadowBlur = isHead ? 9 : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.fillText(column.characters[i], column.x, charY);
|
||||||
|
this.context.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomCharacter(): string {
|
||||||
|
const r = Math.random();
|
||||||
|
const set = r < 0.48 ? BINARY_CHARS : r < 0.68 ? HEX_CHARS : r < 0.72 ? CODE_CHARS : TERM_CHARS;
|
||||||
|
return set[Math.floor(Math.random() * set.length)];
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.contactForm.invalid) {
|
if (this.contactForm.invalid) {
|
||||||
this.contactForm.markAllAsTouched();
|
this.contactForm.markAllAsTouched();
|
||||||
|
|
@ -55,7 +266,6 @@ export class ContactSectionComponent {
|
||||||
this.errorMessage.set(null);
|
this.errorMessage.set(null);
|
||||||
this.isSubmitting.set(true);
|
this.isSubmitting.set(true);
|
||||||
|
|
||||||
// Simulación de envío con confirmación visual inmediata
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.isSubmitting.set(false);
|
this.isSubmitting.set(false);
|
||||||
this.isSuccess.set(true);
|
this.isSuccess.set(true);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-projects-wrapper {
|
.featured-projects-wrapper {
|
||||||
|
|
@ -13,94 +12,96 @@
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-micro-header {
|
/* Cabecera limpia y abierta de la sección de Proyectos */
|
||||||
|
.projects-section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-2) var(--space-4);
|
width: 100%;
|
||||||
background: rgba(18, 22, 28, 0.6);
|
padding-bottom: var(--space-2);
|
||||||
border: 1px solid var(--zen-border-subtle);
|
border-bottom: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: var(--zen-radius-xs);
|
margin-bottom: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-indicator {
|
.title-with-badge {
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--zen-accent-kintsugi);
|
|
||||||
box-shadow: 0 0 8px var(--zen-accent-kintsugi);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-tag {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-nav-controls {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-counter {
|
.pulse-indicator {
|
||||||
font-family: var(--font-mono);
|
width: 10px;
|
||||||
font-size: 0.8125rem;
|
height: 10px;
|
||||||
font-weight: 700;
|
border-radius: 50%;
|
||||||
color: var(--zen-text-secondary);
|
background-color: var(--zen-accent-kintsugi);
|
||||||
letter-spacing: 0.1em;
|
box-shadow: 0 0 12px var(--zen-accent-kintsugi);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-buttons {
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: clamp(1.6rem, 3vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--zen-text-primary);
|
||||||
|
line-height: 1.15;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-nav-btn {
|
.carousel-nav-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 28px;
|
width: 38px;
|
||||||
height: 28px;
|
height: 38px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--zen-border-subtle);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
font-size: 1.15rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-nav-btn:hover:not(:disabled) {
|
.carousel-nav-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
border-color: var(--zen-accent-kintsugi);
|
border-color: var(--zen-accent-kintsugi);
|
||||||
transform: scale(1.06);
|
color: var(--zen-accent-kintsugi);
|
||||||
|
transform: scale(1.08);
|
||||||
|
box-shadow: 0 0 12px rgba(224, 175, 104, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-nav-btn:disabled {
|
.carousel-nav-btn:active {
|
||||||
opacity: 0.28;
|
transform: scale(0.95);
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cuadrícula de Proyectos (Grid limpio sin scroll horizontal) */
|
/* Cuadrícula de Proyectos en Carrusel (Ocupa 100% de ancho) */
|
||||||
.projects-grid {
|
.projects-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-4);
|
gap: var(--space-5);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
.projects-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
.projects-grid {
|
.projects-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
@ -112,13 +113,14 @@
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(16, 20, 25, 0.92), rgba(12, 16, 20, 0.88)),
|
linear-gradient(180deg, rgba(16, 20, 26, 0.94), rgba(12, 16, 22, 0.9)),
|
||||||
rgba(11, 15, 18, 0.85);
|
rgba(11, 15, 18, 0.88);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-top: 3px solid var(--prj-accent, var(--zen-accent-kintsugi));
|
border-radius: var(--zen-radius-md);
|
||||||
border-radius: 1.15rem;
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
|
||||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
|
@ -138,23 +140,23 @@
|
||||||
|
|
||||||
.project-card:hover,
|
.project-card:hover,
|
||||||
.project-card:focus-visible {
|
.project-card:focus-visible {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-4px);
|
||||||
border-color: var(--prj-accent, var(--zen-accent-kintsugi));
|
border-color: var(--zen-accent-kintsugi);
|
||||||
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.24), 0 0 18px rgba(125, 207, 255, 0.1);
|
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.28), 0 0 16px rgba(224, 175, 104, 0.12);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card.is-selected {
|
.project-card.is-selected {
|
||||||
border-color: var(--prj-accent, var(--zen-accent-kintsugi));
|
border-color: var(--zen-accent-kintsugi);
|
||||||
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28), 0 0 22px rgba(125, 207, 255, 0.12);
|
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.32), 0 0 20px rgba(224, 175, 104, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-image-slot {
|
.project-image-slot {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 180px;
|
aspect-ratio: 16 / 9;
|
||||||
background-color: #090b0e;
|
background-color: #080b0f;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: 0.9rem;
|
border-radius: var(--zen-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -163,8 +165,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-image-slot.no-image {
|
.project-image-slot.no-image {
|
||||||
background: #080c12;
|
background: #080c14;
|
||||||
border-color: rgba(125, 207, 255, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-blueprint-slot {
|
.tech-blueprint-slot {
|
||||||
|
|
@ -176,7 +177,7 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at center, rgba(125, 207, 255, 0.08) 0%, transparent 70%),
|
radial-gradient(circle at center, rgba(125, 207, 255, 0.08) 0%, transparent 70%),
|
||||||
#0a0e16;
|
#080b12;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +204,7 @@
|
||||||
|
|
||||||
.blueprint-icon {
|
.blueprint-icon {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
color: var(--zen-accent-kintsugi);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,7 +213,7 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
color: var(--zen-accent-kintsugi);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blueprint-title {
|
.blueprint-title {
|
||||||
|
|
@ -269,7 +270,6 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
filter: saturate(1.08) contrast(1.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-content {
|
.project-content {
|
||||||
|
|
@ -277,7 +277,7 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.8rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-meta-row {
|
.project-meta-row {
|
||||||
|
|
@ -296,7 +296,7 @@
|
||||||
|
|
||||||
.meta-bullet {
|
.meta-bullet {
|
||||||
font-size: 0.5rem;
|
font-size: 0.5rem;
|
||||||
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
color: var(--zen-accent-kintsugi);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-institution {
|
.meta-institution {
|
||||||
|
|
@ -318,17 +318,17 @@
|
||||||
|
|
||||||
.project-title {
|
.project-title {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 1.45rem;
|
font-size: 1.35rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.2;
|
line-height: 1.25;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-impact {
|
.project-impact {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.82rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -343,6 +343,7 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
padding-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-chip {
|
.tech-chip {
|
||||||
|
|
@ -352,11 +353,11 @@
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.67rem;
|
font-size: 0.67rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.03em;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--zen-text-muted);
|
color: var(--zen-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-action {
|
.project-action {
|
||||||
|
|
@ -364,32 +365,39 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
color: var(--zen-accent-kintsugi);
|
||||||
|
transition: color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-card:hover .project-action {
|
||||||
|
color: #f7cb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Detalle */
|
||||||
.project-detail-overlay {
|
.project-detail-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 100;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: clamp(1rem, 2vw, 2.5rem);
|
padding: clamp(1rem, 2vw, 2.5rem);
|
||||||
background: rgba(4, 7, 10, 0.82);
|
background: rgba(5, 7, 10, 0.85);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-detail-shell {
|
.project-detail-shell {
|
||||||
width: min(1200px, 100%);
|
width: min(1300px, 100%);
|
||||||
max-height: 92vh;
|
max-height: 90vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-radius: 1.4rem;
|
border-radius: var(--zen-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dots Indicadores */
|
||||||
.carousel-dots-row {
|
.carousel-dots-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -427,19 +435,15 @@
|
||||||
.carousel-dot.is-active .dot-inner {
|
.carousel-dot.is-active .dot-inner {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--prj-accent, var(--zen-accent-kintsugi));
|
background: var(--zen-accent-kintsugi);
|
||||||
box-shadow: 0 0 10px var(--prj-accent, var(--zen-accent-kintsugi));
|
box-shadow: 0 0 10px var(--zen-accent-kintsugi);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 540px) {
|
@media (max-width: 640px) {
|
||||||
.project-card {
|
.project-card {
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-image-slot {
|
|
||||||
height: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-meta-row {
|
.project-meta-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,46 @@
|
||||||
<div class="featured-projects-wrapper" role="region" [attr.aria-label]="i18n.t().fpAriaLabel">
|
<div class="featured-projects-wrapper" role="region" [attr.aria-label]="i18n.t().fpAriaLabel">
|
||||||
<!-- Cabecera de la sección con controles del carrusel -->
|
<!-- Cabecera limpia y prominente de la sección con controles del carrusel -->
|
||||||
<header class="section-micro-header">
|
<header class="projects-section-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="pulse-indicator" aria-hidden="true"></span>
|
<div class="title-with-badge">
|
||||||
<span class="header-tag">{{ i18n.t().fpHeaderTag }}</span>
|
<span class="pulse-indicator" aria-hidden="true"></span>
|
||||||
|
<h2 class="section-title">{{ i18n.t().fpHeaderTag }}</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="carousel-nav-controls">
|
<div class="carousel-nav-controls">
|
||||||
<span class="carousel-counter" aria-live="polite">
|
<button
|
||||||
0{{ currentIndex() + 1 }} / 0{{ totalPages() }}
|
type="button"
|
||||||
</span>
|
class="carousel-nav-btn"
|
||||||
<div class="carousel-buttons">
|
(click)="prev()"
|
||||||
<button
|
[attr.aria-label]="i18n.t().fpCarouselPrev"
|
||||||
type="button"
|
title="Anterior"
|
||||||
class="carousel-nav-btn"
|
>
|
||||||
(click)="prev()"
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||||
[attr.aria-label]="i18n.t().fpCarouselPrev"
|
<polyline points="15 18 9 12 15 6"></polyline>
|
||||||
>
|
</svg>
|
||||||
‹
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="carousel-nav-btn"
|
||||||
class="carousel-nav-btn"
|
(click)="next()"
|
||||||
(click)="next()"
|
[attr.aria-label]="i18n.t().fpCarouselNext"
|
||||||
[attr.aria-label]="i18n.t().fpCarouselNext"
|
title="Siguiente"
|
||||||
>
|
>
|
||||||
›
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||||
</button>
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
</div>
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Cuadrícula de Proyectos (Grid estático sin scroll horizontal) -->
|
<!-- Cuadrícula de Proyectos en Carrusel (Ocupa todo el espacio horizontal) -->
|
||||||
<div class="projects-grid">
|
<div class="projects-grid">
|
||||||
@for (prj of visibleProjects(); track prj.id) {
|
@for (prj of visibleProjects(); track prj.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="project-card"
|
class="project-card"
|
||||||
[class.is-selected]="selectedProjectId() === prj.id"
|
[class.is-selected]="selectedProjectId() === prj.id"
|
||||||
[style.--prj-accent]="prj.accentColor"
|
|
||||||
[attr.aria-pressed]="selectedProjectId() === prj.id"
|
[attr.aria-pressed]="selectedProjectId() === prj.id"
|
||||||
(click)="selectProject(prj.id)"
|
(click)="selectProject(prj.id)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -38,17 +38,25 @@ export interface ProjectCardData {
|
||||||
export class FeaturedProjectsComponent {
|
export class FeaturedProjectsComponent {
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
// Navegación de cuadrícula limpia (sin carrusel ni scroll horizontal)
|
// Navegación de cuadrícula limpia con carrusel responsivo
|
||||||
protected readonly currentIndex = signal<number>(0);
|
protected readonly currentIndex = signal<number>(0);
|
||||||
protected readonly isMobile = signal<boolean>(
|
protected readonly screenCols = signal<number>(
|
||||||
typeof window !== 'undefined' ? window.innerWidth < 768 : false
|
typeof window !== 'undefined'
|
||||||
|
? window.innerWidth >= 1200
|
||||||
|
? 3
|
||||||
|
: window.innerWidth >= 768
|
||||||
|
? 2
|
||||||
|
: 1
|
||||||
|
: 3
|
||||||
);
|
);
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
onResize(): void {
|
onResize(): void {
|
||||||
const mobile = window.innerWidth < 768;
|
if (typeof window === 'undefined') return;
|
||||||
if (this.isMobile() !== mobile) {
|
const w = window.innerWidth;
|
||||||
this.isMobile.set(mobile);
|
const cols = w >= 1200 ? 3 : w >= 768 ? 2 : 1;
|
||||||
|
if (this.screenCols() !== cols) {
|
||||||
|
this.screenCols.set(cols);
|
||||||
if (this.currentIndex() > this.maxIndex()) {
|
if (this.currentIndex() > this.maxIndex()) {
|
||||||
this.currentIndex.set(this.maxIndex());
|
this.currentIndex.set(this.maxIndex());
|
||||||
}
|
}
|
||||||
|
|
@ -138,8 +146,8 @@ export class FeaturedProjectsComponent {
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Número de tarjetas visibles según tamaño de pantalla (1 en móvil, 3 en desktop)
|
// Número de tarjetas visibles según tamaño de pantalla (1 en móvil, 2 en tablet, 3 en desktop)
|
||||||
protected readonly cardsPerPage = computed(() => (this.isMobile() ? 1 : 3));
|
protected readonly cardsPerPage = computed(() => this.screenCols());
|
||||||
|
|
||||||
// Índice máximo permitido para desplazamiento
|
// Índice máximo permitido para desplazamiento
|
||||||
protected readonly maxIndex = computed(() => {
|
protected readonly maxIndex = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-5);
|
padding: clamp(1.25rem, 2.5vw, 2.25rem);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
box-sizing: border-box;
|
||||||
border-top: 3px solid var(--prj-accent, var(--zen-accent-kintsugi));
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: 1.25rem;
|
border-radius: var(--zen-radius-md);
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(12, 16, 22, 0.96), rgba(17, 22, 28, 0.9)),
|
linear-gradient(135deg, rgba(14, 18, 24, 0.98), rgba(18, 22, 30, 0.94)),
|
||||||
rgba(11, 14, 18, 0.9);
|
#0c1015;
|
||||||
box-shadow: 0 24px 50px rgba(0, 0, 0, 0.32);
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-detail-header {
|
.project-detail-header {
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--zen-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-kicker {
|
.detail-kicker {
|
||||||
|
|
@ -30,10 +32,11 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
font-size: 0.7rem;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.12em;
|
||||||
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
color: var(--zen-accent-kintsugi);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +47,7 @@
|
||||||
background: rgba(187, 154, 247, 0.12);
|
background: rgba(187, 154, 247, 0.12);
|
||||||
border: 1px solid rgba(187, 154, 247, 0.3);
|
border: 1px solid rgba(187, 154, 247, 0.3);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
color: #bb9af7;
|
color: #bb9af7;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
|
@ -51,8 +55,10 @@
|
||||||
|
|
||||||
.project-detail-header h3 {
|
.project-detail-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.5rem, 2.2vw, 2.2rem);
|
font-family: var(--font-sans);
|
||||||
line-height: 1.15;
|
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,47 +68,58 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: 999px;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform var(--transition-fast), border-color var(--transition-fast);
|
transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button:hover {
|
.close-button:hover {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: var(--zen-accent-kintsugi);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--zen-accent-kintsugi);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-detail-layout {
|
.project-detail-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 0.9fr) minmax(0, 1.4fr);
|
grid-template-columns: 1.15fr 1fr;
|
||||||
gap: var(--space-5);
|
gap: var(--space-6);
|
||||||
align-items: stretch;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-detail-visual {
|
.project-detail-visual {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
min-height: 260px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-image-viewport {
|
.main-image-viewport {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-height: 220px;
|
aspect-ratio: 16 / 10;
|
||||||
border-radius: 1rem;
|
max-height: 460px;
|
||||||
|
border-radius: var(--zen-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
background: #0a0d12;
|
background: #070a0e;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-image-viewport img {
|
.main-image-viewport img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
background-color: #070a0e;
|
||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,29 +127,31 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-btn {
|
.thumb-btn {
|
||||||
height: 58px;
|
aspect-ratio: 16 / 10;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0.5rem;
|
border-radius: var(--zen-radius-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
background: #0d1117;
|
background: #080b0f;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.55;
|
opacity: 0.6;
|
||||||
transition: border-color var(--transition-fast), transform var(--transition-fast), opacity var(--transition-fast);
|
transition: border-color var(--transition-fast), transform var(--transition-fast), opacity var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-btn:hover {
|
.thumb-btn:hover {
|
||||||
opacity: 0.85;
|
opacity: 0.9;
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-btn.is-active {
|
.thumb-btn.is-active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-color: var(--prj-accent, var(--zen-accent-kintsugi));
|
border: 2px solid var(--zen-accent-kintsugi);
|
||||||
box-shadow: 0 0 0 1px var(--prj-accent, var(--zen-accent-kintsugi));
|
box-shadow: 0 0 12px rgba(224, 175, 104, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-btn img {
|
.thumb-btn img {
|
||||||
|
|
@ -162,12 +181,10 @@
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 220px;
|
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.03), transparent 70%);
|
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.03), transparent 70%);
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Panel de Arquitectura Técnica para proyectos sin capturas (SIPLECE) */
|
/* Panel de Arquitectura Técnica para proyectos sin capturas (SIPLECE) */
|
||||||
|
|
@ -177,14 +194,14 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 280px;
|
min-height: 320px;
|
||||||
border-radius: 1rem;
|
border-radius: var(--zen-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(125, 207, 255, 0.2);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at center, rgba(125, 207, 255, 0.08) 0%, transparent 70%),
|
radial-gradient(circle at center, rgba(125, 207, 255, 0.06) 0%, transparent 70%),
|
||||||
#080c14;
|
#080b12;
|
||||||
padding: var(--space-4);
|
padding: var(--space-5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,8 +209,8 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(125, 207, 255, 0.07) 1px, transparent 1px),
|
linear-gradient(rgba(125, 207, 255, 0.06) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(125, 207, 255, 0.07) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(125, 207, 255, 0.06) 1px, transparent 1px);
|
||||||
background-size: 20px 20px;
|
background-size: 20px 20px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
@ -205,12 +222,12 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
max-width: 320px;
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blueprint-panel-icon {
|
.blueprint-panel-icon {
|
||||||
font-size: 2.4rem;
|
font-size: 2.4rem;
|
||||||
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
color: var(--zen-accent-kintsugi);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +236,7 @@
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
color: var(--zen-accent-kintsugi);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blueprint-panel-title {
|
.blueprint-panel-title {
|
||||||
|
|
@ -233,7 +250,7 @@
|
||||||
.blueprint-panel-subtitle {
|
.blueprint-panel-subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.75rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
@ -241,26 +258,28 @@
|
||||||
.blueprint-specs-list {
|
.blueprint-specs-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-3);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blueprint-specs-list .spec-item {
|
.blueprint-specs-list .spec-item {
|
||||||
padding: 0.35rem 0.65rem;
|
padding: 0.4rem 0.75rem;
|
||||||
border-radius: 0.45rem;
|
border-radius: var(--zen-radius-xs);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.7rem;
|
font-size: 0.72rem;
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cuerpo de Detalle del Proyecto */
|
||||||
.project-detail-body {
|
.project-detail-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-detail-meta {
|
.project-detail-meta {
|
||||||
|
|
@ -275,8 +294,9 @@
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
font-size: 0.72rem;
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
}
|
}
|
||||||
|
|
@ -287,67 +307,74 @@
|
||||||
|
|
||||||
.project-detail-summary {
|
.project-detail-summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.96rem;
|
font-family: var(--font-sans);
|
||||||
line-height: 1.7;
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.65;
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-block {
|
.detail-block {
|
||||||
padding: var(--space-3);
|
padding: var(--space-3) var(--space-4);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: 0.9rem;
|
border-radius: var(--zen-radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-block h4 {
|
.detail-block h4 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.4rem;
|
||||||
font-size: 0.72rem;
|
font-family: var(--font-sans);
|
||||||
letter-spacing: 0.12em;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--prj-accent, var(--zen-accent-kintsugi));
|
color: var(--zen-accent-kintsugi);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-block p,
|
.detail-block p,
|
||||||
.detail-block li {
|
.detail-block li {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
color: var(--zen-text-secondary);
|
color: var(--zen-text-secondary);
|
||||||
line-height: 1.7;
|
line-height: 1.6;
|
||||||
font-size: 0.92rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-block ul {
|
.results-block ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.15rem;
|
padding-left: 1.15rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.45rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-tags {
|
.detail-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
padding-top: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-tags span {
|
.detail-tags span {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.4rem 0.7rem;
|
padding: 0.35rem 0.65rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(125, 207, 255, 0.08);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(125, 207, 255, 0.18);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-secondary);
|
||||||
font-size: 0.7rem;
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 900px) {
|
||||||
.project-detail-panel {
|
.project-detail-panel {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-detail-layout {
|
.project-detail-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-detail-header {
|
.project-detail-header {
|
||||||
|
|
|
||||||
|
|
@ -210,22 +210,6 @@
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.lateral-panel {
|
|
||||||
max-width: min(90%, 300px) !important; left: 50% !important; right: auto !important;
|
|
||||||
transform: translateX(-50%) translateY(16px);
|
|
||||||
}
|
|
||||||
.side-left { top: 12% !important; }
|
|
||||||
.side-right { top: auto !important; bottom: 8% !important; }
|
|
||||||
.lateral-panel.active { transform: translateX(-50%) translateY(0); }
|
|
||||||
|
|
||||||
.lead-name {
|
|
||||||
font-size: clamp(1.8rem, 7vw, 2.6rem);
|
|
||||||
}
|
|
||||||
.lead-desc {
|
|
||||||
max-width: 28ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.chip-gold {
|
.chip-gold {
|
||||||
border-color: rgba(197, 155, 39, 0.45);
|
border-color: rgba(197, 155, 39, 0.45);
|
||||||
color: var(--zen-accent-kintsugi);
|
color: var(--zen-accent-kintsugi);
|
||||||
|
|
@ -239,6 +223,159 @@
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.lateral-panel {
|
.lateral-panel {
|
||||||
max-width: min(90%, 300px) !important;
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
width: calc(100% - 24px) !important;
|
||||||
|
max-width: 440px !important;
|
||||||
|
left: 50% !important;
|
||||||
|
right: auto !important;
|
||||||
|
transform: translateX(-50%) translateY(12px);
|
||||||
|
background: rgba(14, 17, 23, 0.88);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: var(--zen-radius-sm, 6px);
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.75);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lateral-panel.active {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FASE 1: Lead arriba, Filosofía abajo */
|
||||||
|
.pos-f1-left {
|
||||||
|
top: 56px !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-f1-left .lead-desc,
|
||||||
|
.pos-f1-left .lead-footer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-f1-right {
|
||||||
|
top: auto !important;
|
||||||
|
bottom: max(16px, env(safe-area-inset-bottom, 16px)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FASE 2: Frontend arriba, Backend abajo */
|
||||||
|
.pos-f2-right {
|
||||||
|
top: 56px !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-f2-left {
|
||||||
|
top: auto !important;
|
||||||
|
bottom: max(16px, env(safe-area-inset-bottom, 16px)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FASE 3: Laboratorio arriba, Clean Arch abajo */
|
||||||
|
.pos-f3-right {
|
||||||
|
top: 56px !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-f3-left {
|
||||||
|
top: auto !important;
|
||||||
|
bottom: max(16px, env(safe-area-inset-bottom, 16px)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tipografía y espaciado compacto para móvil */
|
||||||
|
.lead-header {
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-status {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-tag {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-role {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-desc {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-badges, .panel-tags {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-metric {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-social-links {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-link {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-link svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-desc {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-eyebrow {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-loader-pill {
|
||||||
|
top: 60px;
|
||||||
|
right: 12px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,9 +258,10 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
drawH = canvas.width / imgAspect;
|
drawH = canvas.width / imgAspect;
|
||||||
offsetY = (canvas.height - drawH) / 2;
|
offsetY = (canvas.height - drawH) / 2;
|
||||||
} else {
|
} else {
|
||||||
drawH = canvas.height;
|
drawW = canvas.width;
|
||||||
drawW = canvas.height * imgAspect;
|
drawH = canvas.width / imgAspect;
|
||||||
offsetX = (canvas.width - drawW) / 2;
|
offsetX = 0;
|
||||||
|
offsetY = (canvas.height - drawH) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
:host { display: block; width: 100%; }
|
||||||
|
|
||||||
|
.zen-sim-container {
|
||||||
|
max-width: 1540px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4, 16px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cabecera Normalizada de Sección */
|
||||||
|
.sim-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: var(--space-2, 8px);
|
||||||
|
border-bottom: 1px solid var(--zen-border-subtle, #1e2533);
|
||||||
|
margin-bottom: var(--space-1, 4px);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-with-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--zen-accent-kintsugi, #e0af68);
|
||||||
|
box-shadow: 0 0 12px var(--zen-accent-kintsugi, #e0af68);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: clamp(1.6rem, 3vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--zen-text-primary, #f0f3f8);
|
||||||
|
line-height: 1.15;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9aa5b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-beacon {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #38bdf8;
|
||||||
|
box-shadow: 0 0 8px #38bdf8;
|
||||||
|
animation: pulseBeacon 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-sim-board {
|
||||||
|
background: #0a0d14;
|
||||||
|
border: 1px solid #1e2533;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-canvas {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arch-node {
|
||||||
|
background: #11151f;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
position: relative;
|
||||||
|
min-height: 125px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-badge-tag {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.node-badge-tag.threat { background: rgba(239, 68, 68, 0.2); color: #f87171; }
|
||||||
|
.node-badge-tag.detection { background: rgba(56, 189, 248, 0.2); color: #38bdf8; }
|
||||||
|
.node-badge-tag.remediation { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
|
||||||
|
.node-badge-tag.notification { background: rgba(197, 155, 39, 0.2); color: #e2b94a; }
|
||||||
|
|
||||||
|
.node-icon-frame {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #9aa5b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-svg { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
.node-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.node-title {
|
||||||
|
font-family: var(--font-sans, sans-serif);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0f3f8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail, .node-meta-ip, .node-meta-svc {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #9aa5b8;
|
||||||
|
}
|
||||||
|
.node-meta-ip, .node-meta-svc { font-size: 0.625rem; color: #64748b; }
|
||||||
|
|
||||||
|
.node-status-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threat-node.active { border-color: #ef4444; background: #1c1316; }
|
||||||
|
.threat-node.active .node-icon-frame, .threat-node.active .node-status-indicator.ping { color: #ef4444; background: #ef4444; }
|
||||||
|
|
||||||
|
.detection-node.active { border-color: #38bdf8; background: #0f1c29; }
|
||||||
|
.detection-node.active .node-icon-frame, .detection-node.active .node-status-indicator.ping { color: #38bdf8; background: #38bdf8; }
|
||||||
|
|
||||||
|
.remediation-node.active { border-color: #22c55e; background: #0f2316; }
|
||||||
|
.remediation-node.active .node-icon-frame, .remediation-node.active .node-status-indicator.ping { color: #22c55e; background: #22c55e; }
|
||||||
|
|
||||||
|
.notification-node.active { border-color: #c59b27; background: #231c0e; }
|
||||||
|
.notification-node.active .node-icon-frame, .notification-node.active .node-status-indicator.ping { color: #c59b27; background: #c59b27; }
|
||||||
|
|
||||||
|
.arch-node.past { border-color: rgba(34, 197, 94, 0.4); }
|
||||||
|
.arch-node.past .node-status-indicator { background: #22c55e; }
|
||||||
|
|
||||||
|
.flow-connector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #1e293b;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-arrow {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
background: #0a0d14;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-connector.flowing { color: #38bdf8; }
|
||||||
|
.flow-connector.flowing .beam-line {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #38bdf8, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-action-deck {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-trigger-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #c59b27;
|
||||||
|
color: #07080b;
|
||||||
|
border: 1px solid #e2b94a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-family: var(--font-sans, sans-serif);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-trigger-btn:disabled { opacity: 0.65; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.sim-reset-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: #9aa5b8;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
font-family: var(--font-sans, sans-serif);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secops-console-frame {
|
||||||
|
background: #06080c;
|
||||||
|
border: 1px solid #1e2533;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-titlebar {
|
||||||
|
background: #0f131a;
|
||||||
|
padding: 6px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #1e2533;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-dots { display: flex; gap: 5px; }
|
||||||
|
.dot { width: 7px; height: 7px; border-radius: 50%; }
|
||||||
|
.dot.red { background: #ef4444; }
|
||||||
|
.dot.yellow { background: #eab308; }
|
||||||
|
.dot.green { background: #22c55e; }
|
||||||
|
|
||||||
|
.console-title { font-size: 0.6875rem; color: #94a3b8; }
|
||||||
|
|
||||||
|
.console-pill {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
color: #38bdf8;
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-body {
|
||||||
|
padding: 8px 10px;
|
||||||
|
min-height: 100px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row { display: flex; gap: 8px; color: #94a3b8; }
|
||||||
|
.log-time { color: #475569; flex-shrink: 0; }
|
||||||
|
.log-tag { font-weight: 700; flex-shrink: 0; }
|
||||||
|
.log-row.warn .log-tag, .log-row.warn .log-msg { color: #f87171; }
|
||||||
|
.log-row.action .log-tag, .log-row.action .log-msg { color: #fde047; }
|
||||||
|
.log-row.success .log-tag, .log-row.success .log-msg { color: #4ade80; }
|
||||||
|
|
||||||
|
.console-cursor-row { display: flex; align-items: center; gap: 6px; margin-top: 3px; }
|
||||||
|
.console-prompt { color: #34d399; font-size: 0.6875rem; }
|
||||||
|
.cursor-block { width: 6px; height: 11px; background: #38bdf8; }
|
||||||
|
|
||||||
|
.arch-metrics-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #1a2230;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card { display: flex; flex-direction: column; gap: 2px; text-align: center; }
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #5e697d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-val {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0f3f8;
|
||||||
|
}
|
||||||
|
.metric-val.highlight { color: #38bdf8; }
|
||||||
|
.metric-val.success { color: #4ade80; }
|
||||||
|
|
||||||
|
.metric-divider { width: 1px; height: 20px; background: #1e293b; }
|
||||||
|
|
||||||
|
@keyframes pulseBeacon { 0%, 100% { opacity: 0.6; transform: scale(1); } 50% { opacity: 1; transform: scale(1.3); } }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.diagram-canvas { grid-template-columns: 1fr; gap: 10px; }
|
||||||
|
.flow-connector { height: 16px; width: 100%; transform: rotate(90deg); }
|
||||||
|
.metric-divider { display: none; }
|
||||||
|
.arch-metrics-bar { flex-direction: column; gap: 10px; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
<section class="zen-sim-container" aria-label="Simulador de Respuesta a Incidentes Cloud">
|
||||||
|
<!-- Cabecera Normalizada de Sección -->
|
||||||
|
<header class="sim-section-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="title-with-badge">
|
||||||
|
<span class="pulse-indicator" aria-hidden="true"></span>
|
||||||
|
<h2 class="section-title">{{ i18n.t().navSimulator }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-pill" [class.danger]="stage() === 'breach' || stage() === 'detection'" [class.contained]="isContained()" [class.active]="stage() === 'remediation' || stage() === 'notification'">
|
||||||
|
<span class="status-beacon"></span>
|
||||||
|
<span class="status-text">
|
||||||
|
@if (stage() === 'idle') {
|
||||||
|
{{ i18n.t().simStatusIdle }}
|
||||||
|
} @else if (isContained()) {
|
||||||
|
{{ i18n.t().simStatusContained }}
|
||||||
|
} @else {
|
||||||
|
{{ i18n.t().simStatusActive }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Panel Principal: Diagrama de Arquitectura Interactivo -->
|
||||||
|
<div class="zen-sim-board">
|
||||||
|
<!-- Diagrama de Flujo en 4 Nodos -->
|
||||||
|
<div class="diagram-canvas">
|
||||||
|
<!-- NODO 1: Vector de Amenaza -->
|
||||||
|
<div class="arch-node threat-node" [class.active]="stage() === 'breach'" [class.past]="stage() !== 'idle' && stage() !== 'breach'">
|
||||||
|
<div class="node-badge-tag threat">VECTOR // 01</div>
|
||||||
|
<div class="node-icon-frame">
|
||||||
|
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M12 2L3 7v6c0 5.5 3.8 10.7 9 12 5.2-1.3 9-6.5 9-12V7l-9-5z"/>
|
||||||
|
<path d="M12 8v4m0 4h.01" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="node-info">
|
||||||
|
<h3 class="node-title">{{ i18n.t().simThreatNodeTitle }}</h3>
|
||||||
|
<span class="node-detail">{{ i18n.t().simThreatNodeSub }}</span>
|
||||||
|
<span class="node-meta-ip">IP: {{ threatIp() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-status-indicator" [class.ping]="stage() === 'breach'"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONECTOR 1 -> 2 -->
|
||||||
|
<div class="flow-connector" [class.flowing]="stage() === 'breach' || stage() === 'detection' || stage() === 'remediation' || stage() === 'notification' || isContained()">
|
||||||
|
<div class="beam-line"></div>
|
||||||
|
<span class="flow-arrow">►</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NODO 2: AWS CloudTrail & EventBridge -->
|
||||||
|
<div class="arch-node detection-node" [class.active]="stage() === 'detection'" [class.past]="stage() === 'remediation' || stage() === 'notification' || isContained()">
|
||||||
|
<div class="node-badge-tag detection">AWS // 02</div>
|
||||||
|
<div class="node-icon-frame">
|
||||||
|
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="node-info">
|
||||||
|
<h3 class="node-title">{{ i18n.t().simCloudTrailNodeTitle }}</h3>
|
||||||
|
<span class="node-detail">{{ i18n.t().simCloudTrailNodeSub }}</span>
|
||||||
|
<span class="node-meta-svc">us-east-1 // EventBridge Rule</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-status-indicator" [class.ping]="stage() === 'detection'"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONECTOR 2 -> 3 -->
|
||||||
|
<div class="flow-connector" [class.flowing]="stage() === 'detection' || stage() === 'remediation' || stage() === 'notification' || isContained()">
|
||||||
|
<div class="beam-line"></div>
|
||||||
|
<span class="flow-arrow">►</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NODO 3: AWS Lambda (Mitigación) -->
|
||||||
|
<div class="arch-node remediation-node" [class.active]="stage() === 'remediation'" [class.past]="stage() === 'notification' || isContained()">
|
||||||
|
<div class="node-badge-tag remediation">SERVERLESS // 03</div>
|
||||||
|
<div class="node-icon-frame">
|
||||||
|
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="node-info">
|
||||||
|
<h3 class="node-title">{{ i18n.t().simLambdaNodeTitle }}</h3>
|
||||||
|
<span class="node-detail">{{ i18n.t().simLambdaNodeSub }}</span>
|
||||||
|
<span class="node-meta-svc">STS Invalidation & DenyPolicy</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-status-indicator" [class.ping]="stage() === 'remediation'"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONECTOR 3 -> 4 -->
|
||||||
|
<div class="flow-connector" [class.flowing]="stage() === 'remediation' || stage() === 'notification' || isContained()">
|
||||||
|
<div class="beam-line"></div>
|
||||||
|
<span class="flow-arrow">►</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NODO 4: Amazon SES / SecOps Alert -->
|
||||||
|
<div class="arch-node notification-node" [class.active]="stage() === 'notification'" [class.past]="isContained()">
|
||||||
|
<div class="node-badge-tag notification">SECOPS // 04</div>
|
||||||
|
<div class="node-icon-frame">
|
||||||
|
<svg class="node-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||||
|
<polyline points="22,6 12,13 2,6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="node-info">
|
||||||
|
<h3 class="node-title">{{ i18n.t().simSesNodeTitle }}</h3>
|
||||||
|
<span class="node-detail">{{ i18n.t().simSesNodeSub }}</span>
|
||||||
|
<span class="node-meta-svc">secops-team@bryan.dev</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-status-indicator" [class.ping]="stage() === 'notification'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botonera de Control de la Simulación -->
|
||||||
|
<div class="sim-action-deck">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sim-trigger-btn"
|
||||||
|
[disabled]="isSimulating()"
|
||||||
|
(click)="startSimulation()"
|
||||||
|
[attr.aria-label]="i18n.t().simBtnSimulate"
|
||||||
|
>
|
||||||
|
<span class="btn-glow"></span>
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
<span class="btn-text">
|
||||||
|
@if (isSimulating()) {
|
||||||
|
{{ i18n.t().simBtnSimulating }}
|
||||||
|
} @else {
|
||||||
|
{{ i18n.t().simBtnSimulate }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sim-reset-btn"
|
||||||
|
[disabled]="isSimulating()"
|
||||||
|
(click)="resetSimulation()"
|
||||||
|
[attr.aria-label]="i18n.t().simBtnReset"
|
||||||
|
>
|
||||||
|
<span>↺ {{ i18n.t().simBtnReset }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Consola de Telemetría SecOps en Tiempo Real -->
|
||||||
|
<div class="secops-console-frame">
|
||||||
|
<header class="console-titlebar">
|
||||||
|
<div class="console-dots">
|
||||||
|
<span class="dot red"></span>
|
||||||
|
<span class="dot yellow"></span>
|
||||||
|
<span class="dot green"></span>
|
||||||
|
</div>
|
||||||
|
<span class="console-title">{{ i18n.t().simConsoleTitle }}</span>
|
||||||
|
<span class="console-pill">JSON LOGSTREAM</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="console-body" role="log" aria-live="polite">
|
||||||
|
@for (log of logs(); track $index) {
|
||||||
|
<div class="log-row" [class.warn]="log.level === 'WARN'" [class.action]="log.level === 'ACTION'" [class.success]="log.level === 'SUCCESS'">
|
||||||
|
<span class="log-time">[{{ log.timestamp }}]</span>
|
||||||
|
<span class="log-tag">[{{ log.tag }}]</span>
|
||||||
|
<span class="log-msg">{{ log.message }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="console-cursor-row">
|
||||||
|
<span class="console-prompt">secops@aws-perimeter:~$</span>
|
||||||
|
<span class="cursor-block"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Métricas de Arquitectura Serverless -->
|
||||||
|
<footer class="arch-metrics-bar">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">{{ i18n.t().simMetricLatency }}</span>
|
||||||
|
<span class="metric-val highlight">< {{ executionTimeMs() }} ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-divider"></div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">{{ i18n.t().simMetricIntervention }}</span>
|
||||||
|
<span class="metric-val success">0.0 s (100% Auto)</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-divider"></div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-label">{{ i18n.t().simMetricArchitecture }}</span>
|
||||||
|
<span class="metric-val">EventBridge + Lambda + SES</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
import { RetroAudioService } from '../../../application/services/retro-audio.service';
|
||||||
|
|
||||||
|
export type SimulationStage = 'idle' | 'breach' | 'detection' | 'remediation' | 'notification' | 'contained';
|
||||||
|
|
||||||
|
export interface SecOpsLog {
|
||||||
|
readonly timestamp: string;
|
||||||
|
readonly level: 'WARN' | 'INFO' | 'ACTION' | 'SUCCESS';
|
||||||
|
readonly tag: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-incident-simulator',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './incident-simulator.component.html',
|
||||||
|
styleUrl: './incident-simulator.component.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class IncidentSimulatorComponent implements OnInit, OnDestroy {
|
||||||
|
protected readonly i18n = inject(I18nService);
|
||||||
|
private readonly audio = inject(RetroAudioService);
|
||||||
|
|
||||||
|
protected readonly stage = signal<SimulationStage>('idle');
|
||||||
|
protected readonly logs = signal<SecOpsLog[]>([]);
|
||||||
|
protected readonly threatIp = signal<string>('198.51.100.42');
|
||||||
|
protected readonly executionTimeMs = signal<number>(184);
|
||||||
|
|
||||||
|
protected readonly isSimulating = computed<boolean>(() => {
|
||||||
|
const s = this.stage();
|
||||||
|
return s === 'breach' || s === 'detection' || s === 'remediation' || s === 'notification';
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly isContained = computed<boolean>(() => this.stage() === 'contained');
|
||||||
|
|
||||||
|
private timeoutIds: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initDefaultLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initDefaultLogs(): void {
|
||||||
|
const time = this.getCurrentTimestamp();
|
||||||
|
this.logs.set([
|
||||||
|
{
|
||||||
|
timestamp: time,
|
||||||
|
level: 'INFO',
|
||||||
|
tag: 'GUARDDUTY',
|
||||||
|
message: 'Cloud perimeter telemetry nominal. 0 active security findings in us-east-1.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: time,
|
||||||
|
level: 'INFO',
|
||||||
|
tag: 'EVENTBRIDGE',
|
||||||
|
message: 'Active rule pattern matched: Rule-SecOps-IAM-AutoRemediation [ENABLED].'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
startSimulation(): void {
|
||||||
|
if (this.isSimulating()) return;
|
||||||
|
|
||||||
|
this.clearTimeouts();
|
||||||
|
this.audio.playClick();
|
||||||
|
this.logs.set([]);
|
||||||
|
|
||||||
|
// Fase 1: Intento de Acceso No Autorizado (Vector de Amenaza)
|
||||||
|
this.stage.set('breach');
|
||||||
|
this.appendLog({
|
||||||
|
level: 'WARN',
|
||||||
|
tag: 'IAM-ALERT',
|
||||||
|
message: `Anomalous API request: iam:CreateAccessKey from untrusted IP ${this.threatIp()} for principal "svc-deployer".`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fase 2: Captura en CloudTrail & Enrutamiento en EventBridge (después de 700ms)
|
||||||
|
const t1 = setTimeout(() => {
|
||||||
|
this.stage.set('detection');
|
||||||
|
this.audio.playCellReveal();
|
||||||
|
this.appendLog({
|
||||||
|
level: 'INFO',
|
||||||
|
tag: 'CLOUDTRAIL',
|
||||||
|
message: 'Management event ingested in us-east-1. EventBridge rule "SecOps-AutoRemediate" triggered synchronously.'
|
||||||
|
});
|
||||||
|
}, 700);
|
||||||
|
this.timeoutIds.push(t1);
|
||||||
|
|
||||||
|
// Fase 3: Remediación Automatizada en AWS Lambda (después de 1500ms)
|
||||||
|
const t2 = setTimeout(() => {
|
||||||
|
this.stage.set('remediation');
|
||||||
|
this.audio.playFlagToggle();
|
||||||
|
this.appendLog({
|
||||||
|
level: 'ACTION',
|
||||||
|
tag: 'LAMBDA',
|
||||||
|
message: 'Executing fn-iam-incident-response: Revoking active STS sessions & attaching inline DenyAllSecurityPolicy.'
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
this.timeoutIds.push(t2);
|
||||||
|
|
||||||
|
// Fase 4: Despacho de Notificación Amazon SES (después de 2300ms)
|
||||||
|
const t3 = setTimeout(() => {
|
||||||
|
this.stage.set('notification');
|
||||||
|
this.audio.playCellReveal();
|
||||||
|
this.appendLog({
|
||||||
|
level: 'INFO',
|
||||||
|
tag: 'SES-ALERT',
|
||||||
|
message: 'Dispatching cryptographic audit report to secops-team@bryan.dev via Amazon SES.'
|
||||||
|
});
|
||||||
|
}, 2300);
|
||||||
|
this.timeoutIds.push(t3);
|
||||||
|
|
||||||
|
// Fase 5: Amenaza Contenida con Éxito (después de 3100ms)
|
||||||
|
const t4 = setTimeout(() => {
|
||||||
|
this.stage.set('contained');
|
||||||
|
this.audio.playVictory();
|
||||||
|
this.appendLog({
|
||||||
|
level: 'SUCCESS',
|
||||||
|
tag: 'CONTAINED',
|
||||||
|
message: `Threat neutralized successfully in ${this.executionTimeMs()}ms. Zero credentials compromised. State: SECURE.`
|
||||||
|
});
|
||||||
|
}, 3100);
|
||||||
|
this.timeoutIds.push(t4);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSimulation(): void {
|
||||||
|
this.clearTimeouts();
|
||||||
|
this.audio.playClick();
|
||||||
|
this.stage.set('idle');
|
||||||
|
this.initDefaultLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendLog(entry: Omit<SecOpsLog, 'timestamp'>): void {
|
||||||
|
const timestamp = this.getCurrentTimestamp();
|
||||||
|
this.logs.update(prev => [...prev, { ...entry, timestamp }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentTimestamp(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const h = String(now.getHours()).padStart(2, '0');
|
||||||
|
const m = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
||||||
|
return `${h}:${m}:${s}.${ms}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimeouts(): void {
|
||||||
|
this.timeoutIds.forEach(id => clearTimeout(id));
|
||||||
|
this.timeoutIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.clearTimeouts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: var(--zen-bg-base);
|
background-color: #121517;
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: var(--zen-bg-base);
|
background-color: #121517;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header Minimalista Flotante */
|
/* Header Minimalista Flotante */
|
||||||
|
|
@ -25,28 +25,81 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 0 clamp(1rem, 3vw, 2.5rem);
|
padding: 0 clamp(0.75rem, 2vw, 2rem);
|
||||||
background: rgba(10, 10, 11, 0.75);
|
background: rgba(10, 10, 11, 0.85);
|
||||||
backdrop-filter: blur(12px) saturate(160%);
|
backdrop-filter: blur(14px) saturate(160%);
|
||||||
border-bottom: 1px solid var(--zen-border-subtle);
|
border-bottom: 1px solid var(--zen-border-subtle);
|
||||||
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block {
|
.brand-block-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
height: 32px;
|
height: 30px;
|
||||||
width: auto;
|
width: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: var(--zen-radius-xs);
|
border-radius: var(--zen-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Barra de Navegación Rápida */
|
||||||
|
.zen-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--zen-text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: var(--zen-radius-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--zen-text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--zen-accent-kintsugi);
|
||||||
|
background: rgba(197, 155, 39, 0.1);
|
||||||
|
border-color: rgba(197, 155, 39, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.nav-all {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.header-right-group {
|
.header-right-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: var(--space-3);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-location-block {
|
.time-location-block {
|
||||||
|
|
@ -59,14 +112,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-clock {
|
.live-clock {
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--zen-text-primary);
|
color: var(--zen-text-primary);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-label {
|
.location-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
color: var(--zen-text-muted);
|
color: var(--zen-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,7 +131,7 @@
|
||||||
background: var(--zen-bg-surface);
|
background: var(--zen-bg-surface);
|
||||||
border: 1px solid var(--zen-border-subtle);
|
border: 1px solid var(--zen-border-subtle);
|
||||||
border-radius: var(--zen-radius-xs);
|
border-radius: var(--zen-radius-xs);
|
||||||
padding: 4px 10px;
|
padding: 4px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-fast);
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +142,112 @@
|
||||||
background: var(--zen-bg-elevated);
|
background: var(--zen-bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-container { width: 100%; }
|
.hero-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vista Aislada (Single Component View) */
|
||||||
|
.isolated-view {
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
padding-top: calc(56px + var(--space-4));
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #121517;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-container.isolated-view {
|
||||||
|
padding-top: 0;
|
||||||
|
background-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view {
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 56px clamp(1rem, 3vw, 2.5rem) 0 clamp(1rem, 3vw, 2.5rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .stage-grid {
|
||||||
|
height: 100%;
|
||||||
|
max-height: calc(100vh - 72px);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .retro-module-card {
|
||||||
|
height: 100%;
|
||||||
|
max-height: calc(100vh - 72px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .retro-module-card app-retro-os {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .vintage-monitor-wrapper {
|
||||||
|
max-height: calc(100vh - 90px);
|
||||||
|
width: auto;
|
||||||
|
aspect-ratio: 1398 / 1564;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .profile-module-card {
|
||||||
|
max-height: calc(100vh - 72px);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.interactive-stage.isolated-view {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
display: block;
|
||||||
|
padding: calc(56px + var(--space-4)) clamp(0.75rem, 3vw, 1.5rem) var(--space-12) clamp(0.75rem, 3vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .stage-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .retro-module-card {
|
||||||
|
order: 1;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .profile-module-card {
|
||||||
|
order: 2;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive-stage.isolated-view .vintage-monitor-wrapper {
|
||||||
|
max-height: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ==============================================================================
|
/* ==============================================================================
|
||||||
MÓDULOS INTERACTIVOS (50/50 HORIZONTAL)
|
MÓDULOS INTERACTIVOS (50/50 HORIZONTAL)
|
||||||
|
|
@ -97,10 +255,9 @@
|
||||||
|
|
||||||
.interactive-stage {
|
.interactive-stage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem) var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem) var(--space-12) clamp(1rem, 3vw, 2.5rem);
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
background: linear-gradient(180deg, #000000 0%, #121517 var(--space-12));
|
background: linear-gradient(180deg, #000000 0%, #121517 var(--space-12));
|
||||||
scroll-margin-top: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-grid {
|
.stage-grid {
|
||||||
|
|
@ -145,46 +302,82 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-stage {
|
|
||||||
width: 100%;
|
|
||||||
height: clamp(64px, 10vh, 104px);
|
|
||||||
background: #121517;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-stage app-technical-profile {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================================================================
|
/* ==============================================================================
|
||||||
SECCIÓN DE PROYECTOS DESTACADOS (CASOS REALES A ANCHO COMPLETO)
|
SECCIÓN DE PROYECTOS DESTACADOS
|
||||||
============================================================================== */
|
============================================================================== */
|
||||||
|
|
||||||
.projects-stage {
|
.projects-stage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-stage.isolated-view {
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
padding-top: calc(56px + var(--space-6));
|
||||||
|
padding-bottom: var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #121517;
|
||||||
}
|
}
|
||||||
|
|
||||||
.projects-stage-inner {
|
.projects-stage-inner {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 1540px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==============================================================================
|
/* ==============================================================================
|
||||||
SECCIÓN DEDICADA: TERMINAL ARCH LINUX (AMPLIA Y CONFORTABLE)
|
SECCIÓN: SIMULADOR DE RESPUESTA A INCIDENTES CLOUD
|
||||||
|
============================================================================== */
|
||||||
|
|
||||||
|
.simulator-stage {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-12) clamp(1rem, 3vw, 2.5rem);
|
||||||
|
background-color: #121517;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulator-stage.isolated-view {
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
padding-top: calc(56px + var(--space-4));
|
||||||
|
padding-bottom: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #121517;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================================================================
|
||||||
|
SECCIÓN: TERMINAL ARCH LINUX
|
||||||
============================================================================== */
|
============================================================================== */
|
||||||
|
|
||||||
.terminal-stage {
|
.terminal-stage {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-stage.isolated-view {
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
padding-top: calc(56px + var(--space-4));
|
||||||
|
padding-bottom: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #121517;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-stage-inner {
|
.terminal-stage-inner {
|
||||||
max-width: 1040px;
|
max-width: 1540px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -192,62 +385,90 @@
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-header-meta {
|
/* Cabecera Normalizada de Sección */
|
||||||
display: flex;
|
.terminal-section-header {
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono-section-tag {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--zen-accent-kintsugi);
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-section-heading {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--zen-text-primary);
|
|
||||||
margin: 0;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-section-desc {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--zen-text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
max-width: 680px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer Minimalista */
|
|
||||||
.zen-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-6) clamp(1rem, 3vw, 2.5rem);
|
width: 100%;
|
||||||
border-top: 1px solid var(--zen-border-subtle);
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--zen-border-subtle);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-section-header .header-left,
|
||||||
|
.terminal-section-header .title-with-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-section-header .pulse-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--zen-accent-kintsugi);
|
||||||
|
box-shadow: 0 0 12px var(--zen-accent-kintsugi);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-section-header .section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: clamp(1.6rem, 3vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--zen-text-primary);
|
||||||
|
line-height: 1.15;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==============================================================================
|
||||||
|
SECCIÓN: CONTACTO
|
||||||
|
============================================================================== */
|
||||||
|
|
||||||
|
.contact-stage {
|
||||||
|
width: 100%;
|
||||||
background-color: #121517;
|
background-color: #121517;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-copy {
|
.contact-stage.isolated-view {
|
||||||
font-family: var(--font-sans);
|
min-height: calc(100vh - 56px);
|
||||||
font-size: 0.8125rem;
|
padding-top: 56px;
|
||||||
color: var(--zen-text-muted);
|
background-color: #121517;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-version {
|
@media (max-width: 768px) {
|
||||||
font-family: var(--font-sans);
|
.zen-header {
|
||||||
font-size: 0.75rem;
|
padding: 0 8px;
|
||||||
font-weight: 600;
|
gap: 6px;
|
||||||
color: var(--zen-accent-matcha);
|
height: 50px;
|
||||||
background: rgba(158, 206, 106, 0.08);
|
}
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: var(--zen-radius-xs);
|
.time-location-block {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-nav {
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 20px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 4px 7px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-btn {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,72 @@
|
||||||
<div class="landing-container">
|
<div class="landing-container">
|
||||||
<!-- Header Minimalista Flotante (Glassmorphism & Zen High-Contrast) -->
|
<!-- Header Minimalista Flotante (Glassmorphism & Zen High-Contrast) -->
|
||||||
<header class="zen-header">
|
<header class="zen-header">
|
||||||
<div class="brand-block">
|
<button type="button" class="brand-block-btn" (click)="setSection('hero')" [attr.aria-label]="i18n.t().navHome">
|
||||||
<img src="assets/images/logo.jpg" alt="Bryan Baño" class="brand-logo" />
|
<img src="assets/images/logo.jpg" alt="Bryan Baño" class="brand-logo" />
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
|
<!-- Barra de Navegación Rápida Zen -->
|
||||||
|
<nav #navContainer class="zen-nav" aria-label="Navegación principal">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-item"
|
||||||
|
data-nav="hero"
|
||||||
|
[class.active]="activeSection() === 'hero'"
|
||||||
|
(click)="setSection('hero')"
|
||||||
|
>
|
||||||
|
{{ i18n.t().navHome }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-item"
|
||||||
|
data-nav="retro"
|
||||||
|
[class.active]="activeSection() === 'retro'"
|
||||||
|
(click)="setSection('retro')"
|
||||||
|
>
|
||||||
|
{{ i18n.t().navRetro }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-item"
|
||||||
|
data-nav="projects"
|
||||||
|
[class.active]="activeSection() === 'projects'"
|
||||||
|
(click)="setSection('projects')"
|
||||||
|
>
|
||||||
|
{{ i18n.t().navProjects }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-item"
|
||||||
|
data-nav="simulator"
|
||||||
|
[class.active]="activeSection() === 'simulator'"
|
||||||
|
(click)="setSection('simulator')"
|
||||||
|
>
|
||||||
|
{{ i18n.t().navSimulator }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-item"
|
||||||
|
data-nav="terminal"
|
||||||
|
[class.active]="activeSection() === 'terminal'"
|
||||||
|
(click)="setSection('terminal')"
|
||||||
|
>
|
||||||
|
{{ i18n.t().navTerminal }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-item"
|
||||||
|
data-nav="contact"
|
||||||
|
[class.active]="activeSection() === 'contact'"
|
||||||
|
(click)="setSection('contact')"
|
||||||
|
>
|
||||||
|
{{ i18n.t().navContact }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="header-right-group">
|
<div class="header-right-group">
|
||||||
<div class="time-location-block">
|
<div class="time-location-block">
|
||||||
|
|
@ -19,56 +82,67 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Hero con Laptop 3D desarmable al scroll (300 frames) -->
|
<!-- Hero con Laptop 3D desarmable al scroll (300 frames) -->
|
||||||
<main class="hero-container">
|
@if (activeSection() === 'hero') {
|
||||||
<app-hero-3d></app-hero-3d>
|
<main class="hero-container isolated-view">
|
||||||
</main>
|
<app-hero-3d></app-hero-3d>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Contenedor Base para Módulos Interactivos (50/50 Perfil Técnico y Retro Monitor) -->
|
<!-- Contenedor Base para Módulos Interactivos (50/50 Perfil Técnico y Retro Monitor) -->
|
||||||
<section class="interactive-stage">
|
@if (activeSection() === 'retro') {
|
||||||
<div class="stage-grid">
|
<section class="interactive-stage isolated-view">
|
||||||
<!-- Slot 1: Perfil Técnico & Filosofía de Ingeniería -->
|
<div class="stage-grid">
|
||||||
<article class="module-card profile-module-card">
|
<!-- Slot 1: Perfil Técnico & Filosofía de Ingeniería -->
|
||||||
<app-technical-profile [showMatrix]="false"></app-technical-profile>
|
<article class="module-card profile-module-card">
|
||||||
</article>
|
<app-technical-profile></app-technical-profile>
|
||||||
|
</article>
|
||||||
|
|
||||||
<!-- Slot 2: Retro OS en Monitor CRT Antiguo (50% Horizontal) -->
|
<!-- Slot 2: Retro OS en Monitor CRT Antiguo (50% Horizontal) -->
|
||||||
<article class="module-card retro-module-card">
|
<article class="module-card retro-module-card">
|
||||||
<app-retro-os></app-retro-os>
|
<app-retro-os></app-retro-os>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
}
|
||||||
<section class="matrix-stage" aria-label="Lluvia de codigo Matrix">
|
|
||||||
<app-technical-profile [showProfile]="false"></app-technical-profile>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Sección de Proyectos Destacados (Casos Reales del CV a Ancho Completo) -->
|
<!-- Sección de Proyectos Destacados (Casos Reales del CV a Ancho Completo) -->
|
||||||
<section class="projects-stage">
|
@if (activeSection() === 'projects') {
|
||||||
<div class="projects-stage-inner">
|
<section class="projects-stage isolated-view">
|
||||||
<app-featured-projects></app-featured-projects>
|
<div class="projects-stage-inner">
|
||||||
</div>
|
<app-featured-projects></app-featured-projects>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Sección Simulador de Respuesta a Incidentes (Visualización de Arquitectura Cloud Security Serverless) -->
|
||||||
|
@if (activeSection() === 'simulator') {
|
||||||
|
<section class="simulator-stage isolated-view">
|
||||||
|
<app-incident-simulator></app-incident-simulator>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Sección Dedicada para la Terminal Interactiva Arch Linux / Fish Shell (Espaciosa y a Escala Completa) -->
|
<!-- Sección Dedicada para la Terminal Interactiva Arch Linux / Fish Shell (Espaciosa y a Escala Completa) -->
|
||||||
<section class="terminal-stage">
|
@if (activeSection() === 'terminal') {
|
||||||
<div class="terminal-stage-inner">
|
<section class="terminal-stage isolated-view">
|
||||||
<div class="terminal-header-meta">
|
<div class="terminal-stage-inner">
|
||||||
<span class="mono-section-tag">{{ i18n.t().terminalWorkstationTag }}</span>
|
<!-- Cabecera Normalizada de Sección -->
|
||||||
<h2 class="terminal-section-heading">{{ i18n.t().terminalHeading }}</h2>
|
<header class="terminal-section-header">
|
||||||
<p class="terminal-section-desc">
|
<div class="header-left">
|
||||||
{{ i18n.t().terminalSectionDesc }}
|
<div class="title-with-badge">
|
||||||
</p>
|
<span class="pulse-indicator" aria-hidden="true"></span>
|
||||||
|
<h2 class="section-title">{{ i18n.t().navTerminal }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<app-terminal></app-terminal>
|
||||||
</div>
|
</div>
|
||||||
<app-terminal></app-terminal>
|
</section>
|
||||||
</div>
|
}
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Sección de Contacto Corporativa Premium (Full Width debajo de la Terminal) -->
|
<!-- Sección de Contacto Corporativa Premium (Full Width debajo de la Terminal) -->
|
||||||
<app-contact-section></app-contact-section>
|
@if (activeSection() === 'contact') {
|
||||||
|
<section class="contact-stage isolated-view">
|
||||||
<!-- Footer Minimalista -->
|
<app-contact-section></app-contact-section>
|
||||||
<footer class="zen-footer">
|
</section>
|
||||||
<p class="footer-copy">© {{ currentYear() }} {{ i18n.t().footerCopy }}</p>
|
}
|
||||||
<span class="footer-version">v1.0.0-alpha</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, signal, inject } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, signal, inject, ViewChild, ElementRef } from '@angular/core';
|
||||||
import { Hero3dComponent } from '../hero-3d/hero-3d.component';
|
import { Hero3dComponent } from '../hero-3d/hero-3d.component';
|
||||||
import { RetroOsComponent } from '../retro-os/retro-os.component';
|
import { RetroOsComponent } from '../retro-os/retro-os.component';
|
||||||
import { TerminalComponent } from '../terminal/terminal.component';
|
import { TerminalComponent } from '../terminal/terminal.component';
|
||||||
import { FeaturedProjectsComponent } from '../featured-projects/featured-projects/featured-projects.component';
|
import { FeaturedProjectsComponent } from '../featured-projects/featured-projects/featured-projects.component';
|
||||||
import { ContactSectionComponent } from '../contact-section/contact-section.component';
|
import { ContactSectionComponent } from '../contact-section/contact-section.component';
|
||||||
import { TechnicalProfileComponent } from '../technical-profile/technical-profile.component';
|
import { TechnicalProfileComponent } from '../technical-profile/technical-profile.component';
|
||||||
|
import { IncidentSimulatorComponent } from '../incident-simulator/incident-simulator.component';
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
|
export type LandingSection = 'hero' | 'retro' | 'projects' | 'simulator' | 'terminal' | 'contact';
|
||||||
|
|
||||||
|
export const LANDING_SECTIONS: readonly LandingSection[] = [
|
||||||
|
'hero',
|
||||||
|
'retro',
|
||||||
|
'projects',
|
||||||
|
'simulator',
|
||||||
|
'terminal',
|
||||||
|
'contact'
|
||||||
|
];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-landing',
|
selector: 'app-landing',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -16,7 +28,8 @@ import { I18nService } from '../../../application/services/i18n.service';
|
||||||
TerminalComponent,
|
TerminalComponent,
|
||||||
FeaturedProjectsComponent,
|
FeaturedProjectsComponent,
|
||||||
ContactSectionComponent,
|
ContactSectionComponent,
|
||||||
TechnicalProfileComponent
|
TechnicalProfileComponent,
|
||||||
|
IncidentSimulatorComponent
|
||||||
],
|
],
|
||||||
templateUrl: './landing.component.html',
|
templateUrl: './landing.component.html',
|
||||||
styleUrl: './landing.component.css',
|
styleUrl: './landing.component.css',
|
||||||
|
|
@ -26,6 +39,7 @@ export class LandingComponent implements OnInit, OnDestroy {
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
protected readonly currentYear = signal(new Date().getFullYear());
|
protected readonly currentYear = signal(new Date().getFullYear());
|
||||||
protected readonly currentTime = signal<string>('');
|
protected readonly currentTime = signal<string>('');
|
||||||
|
protected readonly activeSection = signal<LandingSection>('hero');
|
||||||
|
|
||||||
private clockInterval: ReturnType<typeof setInterval> | null = null;
|
private clockInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', {
|
private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
|
@ -36,9 +50,167 @@ export class LandingComponent implements OnInit, OnDestroy {
|
||||||
hour12: false
|
hour12: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ViewChild('navContainer') private navContainerRef?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
private isTransitioning = false;
|
||||||
|
private touchStartY = 0;
|
||||||
|
private touchStartX = 0;
|
||||||
|
private wheelDeltaAccum = 0;
|
||||||
|
private wheelResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.updateClock();
|
this.updateClock();
|
||||||
this.clockInterval = setInterval(() => this.updateClock(), 1000);
|
this.clockInterval = setInterval(() => this.updateClock(), 1000);
|
||||||
|
this.parseInitialHash();
|
||||||
|
this.setupScrollTransitions();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSection(section: LandingSection, entryPosition: 'top' | 'bottom' = 'top'): void {
|
||||||
|
if (this.activeSection() === section && this.isTransitioning) return;
|
||||||
|
this.isTransitioning = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isTransitioning = false;
|
||||||
|
}, 750);
|
||||||
|
|
||||||
|
this.activeSection.set(section);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const hash = section === 'hero' ? '' : `#${section}`;
|
||||||
|
const url = window.location.pathname + window.location.search + hash;
|
||||||
|
window.history.replaceState(null, '', url);
|
||||||
|
this.scrollActiveNavIntoView(section);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (entryPosition === 'bottom') {
|
||||||
|
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||||
|
window.scrollTo({ top: maxScroll, behavior: 'instant' });
|
||||||
|
} else {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
|
}
|
||||||
|
}, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupScrollTransitions(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.addEventListener('wheel', this.onWheel, { passive: false });
|
||||||
|
window.addEventListener('touchstart', this.onTouchStart, { passive: true });
|
||||||
|
window.addEventListener('touchend', this.onTouchEnd, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWheel = (e: WheelEvent): void => {
|
||||||
|
if (this.isTransitioning) return;
|
||||||
|
|
||||||
|
// Ignorar si el usuario está interactuando con un modal o scroll interno
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('.project-detail-overlay, .modal-scroll-area')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollY = window.scrollY || window.pageYOffset;
|
||||||
|
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||||
|
const isAtTop = scrollY <= 6;
|
||||||
|
const isAtBottom = scrollY >= maxScroll - 6;
|
||||||
|
|
||||||
|
if (e.deltaY > 0 && isAtBottom) {
|
||||||
|
this.wheelDeltaAccum += e.deltaY;
|
||||||
|
if (this.wheelDeltaAccum > 50) {
|
||||||
|
this.wheelDeltaAccum = 0;
|
||||||
|
this.goToNextSection();
|
||||||
|
}
|
||||||
|
} else if (e.deltaY < 0 && isAtTop) {
|
||||||
|
this.wheelDeltaAccum += e.deltaY;
|
||||||
|
if (this.wheelDeltaAccum < -50) {
|
||||||
|
this.wheelDeltaAccum = 0;
|
||||||
|
this.goToPrevSection();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.wheelDeltaAccum = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.wheelResetTimer) clearTimeout(this.wheelResetTimer);
|
||||||
|
this.wheelResetTimer = setTimeout(() => {
|
||||||
|
this.wheelDeltaAccum = 0;
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTouchStart = (e: TouchEvent): void => {
|
||||||
|
if (e.touches.length > 0) {
|
||||||
|
this.touchStartY = e.touches[0].clientY;
|
||||||
|
this.touchStartX = e.touches[0].clientX;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTouchEnd = (e: TouchEvent): void => {
|
||||||
|
if (this.isTransitioning || e.changedTouches.length === 0) return;
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('.project-detail-overlay, .modal-scroll-area')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchEndY = e.changedTouches[0].clientY;
|
||||||
|
const touchEndX = e.changedTouches[0].clientX;
|
||||||
|
const diffY = this.touchStartY - touchEndY;
|
||||||
|
const diffX = this.touchStartX - touchEndX;
|
||||||
|
|
||||||
|
if (Math.abs(diffY) > 50 && Math.abs(diffY) > Math.abs(diffX) * 1.3) {
|
||||||
|
const scrollY = window.scrollY || window.pageYOffset;
|
||||||
|
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||||
|
const isAtTop = scrollY <= 10;
|
||||||
|
const isAtBottom = scrollY >= maxScroll - 10;
|
||||||
|
|
||||||
|
if (diffY > 0 && isAtBottom) {
|
||||||
|
this.goToNextSection();
|
||||||
|
} else if (diffY < 0 && isAtTop) {
|
||||||
|
this.goToPrevSection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private goToNextSection(): void {
|
||||||
|
const currentIndex = LANDING_SECTIONS.indexOf(this.activeSection());
|
||||||
|
if (currentIndex >= 0 && currentIndex < LANDING_SECTIONS.length - 1) {
|
||||||
|
const next = LANDING_SECTIONS[currentIndex + 1];
|
||||||
|
this.setSection(next, 'top');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private goToPrevSection(): void {
|
||||||
|
const currentIndex = LANDING_SECTIONS.indexOf(this.activeSection());
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const prev = LANDING_SECTIONS[currentIndex - 1];
|
||||||
|
this.setSection(prev, 'bottom');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollActiveNavIntoView(section: LandingSection): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.navContainerRef) return;
|
||||||
|
const activeEl = this.navContainerRef.nativeElement.querySelector(`[data-nav="${section}"]`);
|
||||||
|
if (activeEl) {
|
||||||
|
activeEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseInitialHash(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const hash = window.location.hash.toLowerCase().replace('#', '');
|
||||||
|
let targetSection: LandingSection = 'hero';
|
||||||
|
if (hash === 'retro' || hash === 'desktop') {
|
||||||
|
targetSection = 'retro';
|
||||||
|
} else if (hash === 'projects' || hash === 'proyectos') {
|
||||||
|
targetSection = 'projects';
|
||||||
|
} else if (hash === 'simulator' || hash === 'simulador') {
|
||||||
|
targetSection = 'simulator';
|
||||||
|
} else if (hash === 'terminal') {
|
||||||
|
targetSection = 'terminal';
|
||||||
|
} else if (hash === 'contact' || hash === 'contacto') {
|
||||||
|
targetSection = 'contact';
|
||||||
|
}
|
||||||
|
this.activeSection.set(targetSection);
|
||||||
|
this.scrollActiveNavIntoView(targetSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateClock(): void {
|
private updateClock(): void {
|
||||||
|
|
@ -49,5 +221,13 @@ export class LandingComponent implements OnInit, OnDestroy {
|
||||||
if (this.clockInterval !== null) {
|
if (this.clockInterval !== null) {
|
||||||
clearInterval(this.clockInterval);
|
clearInterval(this.clockInterval);
|
||||||
}
|
}
|
||||||
|
if (this.wheelResetTimer !== null) {
|
||||||
|
clearTimeout(this.wheelResetTimer);
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('wheel', this.onWheel);
|
||||||
|
window.removeEventListener('touchstart', this.onTouchStart);
|
||||||
|
window.removeEventListener('touchend', this.onTouchEnd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,40 @@
|
||||||
>
|
>
|
||||||
<div class="xp-icon-graphic">
|
<div class="xp-icon-graphic">
|
||||||
@switch (icon.iconType) {
|
@switch (icon.iconType) {
|
||||||
|
@case ('minesweeper') {
|
||||||
|
<!-- Icono clásico de Buscaminas Windows (Mina naval con pinchos sobre relieve 3D) -->
|
||||||
|
<svg class="xp-icon-svg" viewBox="0 0 36 36" fill="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="mineTileGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#dfdfdf" />
|
||||||
|
<stop offset="100%" stop-color="#b0b0b0" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="mineBodyGrad" cx="35%" cy="35%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="#555555" />
|
||||||
|
<stop offset="50%" stop-color="#1c1c1c" />
|
||||||
|
<stop offset="100%" stop-color="#000000" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<ellipse cx="18" cy="31" rx="12" ry="3.5" fill="#1b2a32" opacity="0.45" />
|
||||||
|
<!-- Baldosa clásica con bisel 3D -->
|
||||||
|
<rect x="4" y="4" width="28" height="28" rx="2" fill="url(#mineTileGrad)" stroke="#808080" stroke-width="1" />
|
||||||
|
<path d="M4 31V5H31" stroke="#ffffff" stroke-width="1.8" />
|
||||||
|
<path d="M5 31H31V5" stroke="#707070" stroke-width="1.8" />
|
||||||
|
<!-- Pinchos de la mina naval -->
|
||||||
|
<line x1="18" y1="8" x2="18" y2="28" stroke="#111111" stroke-width="2.5" stroke-linecap="round" />
|
||||||
|
<line x1="8" y1="18" x2="28" y2="18" stroke="#111111" stroke-width="2.5" stroke-linecap="round" />
|
||||||
|
<line x1="10.9" y1="10.9" x2="25.1" y2="25.1" stroke="#111111" stroke-width="2.5" stroke-linecap="round" />
|
||||||
|
<line x1="10.9" y1="25.1" x2="25.1" y2="10.9" stroke="#111111" stroke-width="2.5" stroke-linecap="round" />
|
||||||
|
<!-- Esfera central de la mina -->
|
||||||
|
<circle cx="18" cy="18" r="7.5" fill="url(#mineBodyGrad)" />
|
||||||
|
<!-- Brillo especular clásico -->
|
||||||
|
<rect x="14.5" y="14.5" width="2.8" height="2.8" fill="#ffffff" rx="0.5" />
|
||||||
|
<!-- Destello rojo / mecha -->
|
||||||
|
<circle cx="23" cy="13" r="1.5" fill="#ff3333" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
@case ('gamepad') {
|
@case ('gamepad') {
|
||||||
<!-- Icono característico Juegos Windows XP (Pinball / Joystick 3D) -->
|
<!-- Icono Joystick 3D fallback -->
|
||||||
<svg class="xp-icon-svg" viewBox="0 0 36 36" fill="none">
|
<svg class="xp-icon-svg" viewBox="0 0 36 36" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="xpJoyGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="xpJoyGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
|
|
||||||
|
|
@ -1,139 +1,417 @@
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: #ece9d8;
|
|
||||||
user-select: none;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-game-wrapper {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px;
|
justify-content: center;
|
||||||
gap: 6px;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
user-select: none;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
padding: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Barra Superior de Estadísticas y Acciones */
|
.xp-minesweeper-window {
|
||||||
.xp-game-header {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marco exterior con relieve 3D sobresaliente (Outset) */
|
||||||
|
.xp-minesweeper-frame {
|
||||||
|
background: #c0c0c0;
|
||||||
|
border-top: 3px solid #ffffff;
|
||||||
|
border-left: 3px solid #ffffff;
|
||||||
|
border-right: 3px solid #7b7b7b;
|
||||||
|
border-bottom: 3px solid #7b7b7b;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HUD Superior con relieve 3D hundido (Inset) */
|
||||||
|
.xp-ms-hud {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background: #f0ede0;
|
background: #c0c0c0;
|
||||||
border: 1px solid #7f9db9;
|
border-top: 2px solid #7b7b7b;
|
||||||
border-radius: 3px;
|
border-left: 2px solid #7b7b7b;
|
||||||
padding: 4px 8px;
|
border-right: 2px solid #ffffff;
|
||||||
box-shadow: inset 0 1px 0 #ffffff;
|
border-bottom: 2px solid #ffffff;
|
||||||
|
padding: 4px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xp-hud-stats { display: flex; gap: 10px; }
|
/* Contadores LED Digitales (7 Segmentos) */
|
||||||
.hud-item { display: flex; align-items: center; gap: 4px; }
|
.xp-led-display {
|
||||||
.hud-tag { font-size: 0.625rem; font-weight: 700; color: #333333; }
|
|
||||||
.hud-num { font-size: 0.8125rem; font-weight: 700; color: #0055ea; }
|
|
||||||
.hud-num.highlight { color: #d83a17; }
|
|
||||||
.xp-hud-actions { display: flex; gap: 4px; }
|
|
||||||
|
|
||||||
/* Botones Clásicos Windows XP con borde naranja al hacer hover */
|
|
||||||
.xp-classic-btn {
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #ece9d8 100%);
|
|
||||||
border: 1px solid #7f9db9;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #000000;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: inset 0 1px 0 #ffffff, 0 1px 2px rgba(0, 0, 0, 0.15);
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-classic-btn:hover:not(:disabled) {
|
|
||||||
border-color: #f29536;
|
|
||||||
background: linear-gradient(180deg, #fff7ea 0%, #ffdca8 100%);
|
|
||||||
box-shadow: inset 0 1px 0 #ffffff, 0 0 4px rgba(242, 149, 54, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xp-classic-btn:active:not(:disabled) { background: #d8d4c2; transform: translateY(1px); }
|
|
||||||
.xp-classic-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* Marco del Tablero Canvas */
|
|
||||||
.xp-canvas-box {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 300px;
|
background: #000000;
|
||||||
height: 300px;
|
border-top: 1px solid #7b7b7b;
|
||||||
border: 2px inset #7f9db9;
|
border-left: 1px solid #7b7b7b;
|
||||||
border-radius: 2px;
|
border-right: 1px solid #ffffff;
|
||||||
background: #1c2833;
|
border-bottom: 1px solid #ffffff;
|
||||||
cursor: pointer;
|
padding: 1px 4px;
|
||||||
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.4);
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
min-width: 44px;
|
||||||
|
text-align: right;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xp-snake-canvas { width: 300px; height: 300px; display: block; }
|
.led-ghost {
|
||||||
|
|
||||||
/* Banner de Instrucción en la parte inferior del canvas */
|
|
||||||
.xp-start-banner {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
top: 1px;
|
||||||
left: 8px;
|
right: 4px;
|
||||||
right: 8px;
|
color: #380000;
|
||||||
background: rgba(0, 0, 0, 0.78);
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card de Game Over */
|
.led-digits {
|
||||||
.xp-gameover-card {
|
position: relative;
|
||||||
position: absolute;
|
color: #ff0000;
|
||||||
inset: 20px;
|
text-shadow: 0 0 2px rgba(255, 0, 0, 0.8);
|
||||||
background: rgba(236, 233, 216, 0.96);
|
z-index: 2;
|
||||||
border: 2px solid #0055ea;
|
}
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
/* Botón Cara Sonriente Clásica */
|
||||||
|
.xp-smiley-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: #c0c0c0;
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #7b7b7b;
|
||||||
|
border-bottom: 2px solid #7b7b7b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-smiley-btn:active {
|
||||||
|
border-top: 2px solid #7b7b7b;
|
||||||
|
border-left: 2px solid #7b7b7b;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-icon {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablero de Juego con relieve hundido (Inset) */
|
||||||
|
.xp-ms-board-inset {
|
||||||
|
border-top: 3px solid #7b7b7b;
|
||||||
|
border-left: 3px solid #7b7b7b;
|
||||||
|
border-right: 3px solid #ffffff;
|
||||||
|
border-bottom: 3px solid #ffffff;
|
||||||
|
background: #c0c0c0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-ms-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gameover-text { font-size: 1rem; font-weight: 700; color: #d83a17; }
|
.xp-ms-row {
|
||||||
.gameover-pts { font-size: 0.8125rem; font-weight: 600; color: #000000; }
|
display: flex;
|
||||||
.xp-save-row { display: flex; gap: 4px; }
|
}
|
||||||
.xp-input-tag { width: 50px; text-align: center; font-weight: 700; border: 1px solid #7f9db9; font-size: 0.8125rem; text-transform: uppercase; }
|
|
||||||
.xp-saved-label { font-size: 0.6875rem; color: #2e7d32; font-weight: 600; }
|
|
||||||
.btn-retry { padding: 4px 12px; }
|
|
||||||
|
|
||||||
/* Controles Direccionales D-Pad */
|
/* Casilla Individual de Buscaminas */
|
||||||
.xp-dpad-container { width: 100%; max-width: 300px; display: flex; justify-content: center; }
|
.xp-ms-cell {
|
||||||
.xp-dpad-grid { display: grid; grid-template-columns: repeat(3, 36px); grid-template-rows: repeat(2, 30px); gap: 4px; }
|
width: 24px;
|
||||||
.xp-arrow-btn {
|
height: 24px;
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #ece9d8 100%);
|
background: #c0c0c0;
|
||||||
border: 1px solid #7f9db9;
|
border-top: 2px solid #ffffff;
|
||||||
border-radius: 3px;
|
border-left: 2px solid #ffffff;
|
||||||
color: #0055ea;
|
border-right: 2px solid #7b7b7b;
|
||||||
font-size: 0.8125rem;
|
border-bottom: 2px solid #7b7b7b;
|
||||||
font-weight: 900;
|
padding: 0;
|
||||||
cursor: pointer;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: inset 0 1px 0 #ffffff, 0 1px 2px rgba(0, 0, 0, 0.15);
|
font-family: 'Courier New', Tahoma, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.xp-arrow-btn:hover { border-color: #f29536; background: linear-gradient(180deg, #fff7ea 0%, #ffdca8 100%); }
|
|
||||||
.xp-arrow-btn:active { background: #d8d4c2; transform: translateY(1px); }
|
.xp-ms-cell:active:not(.revealed):not(.flagged) {
|
||||||
.xp-arrow-btn.up { grid-column: 2; grid-row: 1; }
|
border: 1px solid #7b7b7b;
|
||||||
.xp-arrow-btn.left { grid-column: 1; grid-row: 2; }
|
}
|
||||||
.xp-arrow-btn.down { grid-column: 2; grid-row: 2; }
|
|
||||||
.xp-arrow-btn.right { grid-column: 3; grid-row: 2; }
|
/* Casilla Descubierta */
|
||||||
|
.xp-ms-cell.revealed {
|
||||||
|
border: 1px solid #7b7b7b;
|
||||||
|
background: #c0c0c0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mina explotada */
|
||||||
|
.xp-ms-cell.exploded {
|
||||||
|
background: #ff0000 !important;
|
||||||
|
border: 1px solid #7b7b7b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Iconos de celda */
|
||||||
|
.cell-icon {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-icon.flag {
|
||||||
|
filter: drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-icon.false-flag {
|
||||||
|
color: #ff0000;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-num {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paleta de Colores de Números Clásica de Buscaminas */
|
||||||
|
.num-1 .cell-num { color: #0000ff; } /* 1: Azul */
|
||||||
|
.num-2 .cell-num { color: #008000; } /* 2: Verde */
|
||||||
|
.num-3 .cell-num { color: #ff0000; } /* 3: Rojo */
|
||||||
|
.num-4 .cell-num { color: #000080; } /* 4: Azul marino */
|
||||||
|
.num-5 .cell-num { color: #800000; } /* 5: Granate */
|
||||||
|
.num-6 .cell-num { color: #008080; } /* 6: Verde azulado / Cyan oscuro */
|
||||||
|
.num-7 .cell-num { color: #000000; } /* 7: Negro */
|
||||||
|
.num-8 .cell-num { color: #808080; } /* 8: Gris */
|
||||||
|
|
||||||
|
/* Barra de Estado Clásica Inferior (Windows 95/XP Style) */
|
||||||
|
.xp-ms-statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 236px;
|
||||||
|
background: #c0c0c0;
|
||||||
|
border-top: 2px solid #7b7b7b;
|
||||||
|
border-left: 2px solid #7b7b7b;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
padding: 1px 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-status-mode-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: #c0c0c0;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #7b7b7b;
|
||||||
|
border-bottom: 1px solid #7b7b7b;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000000;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-status-mode-btn:active {
|
||||||
|
border-top: 1px solid #7b7b7b;
|
||||||
|
border-left: 1px solid #7b7b7b;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-text {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-status-record {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Cuadro de Diálogo Clásico Windows Win32 (Récord / Victoria)
|
||||||
|
========================================================================== */
|
||||||
|
.xp-dialog-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-window {
|
||||||
|
width: 220px;
|
||||||
|
background: #ece9d8;
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #555555;
|
||||||
|
border-bottom: 2px solid #555555;
|
||||||
|
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.6);
|
||||||
|
font-family: 'Tahoma', 'Segoe UI', Geneva, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Barra de título azul clásica de Windows XP */
|
||||||
|
.xp-dialog-titlebar {
|
||||||
|
background: linear-gradient(90deg, #0055ea 0%, #3f8cf3 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-btn-close {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #d13a17;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-body {
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-content-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-trophy {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-message-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-main-msg {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-sub-msg {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-textbox {
|
||||||
|
width: 50px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #7b7b7b;
|
||||||
|
border-left: 2px solid #7b7b7b;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botonera clásica */
|
||||||
|
.xp-dialog-button-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-button {
|
||||||
|
background: #ece9d8;
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #7b7b7b;
|
||||||
|
border-bottom: 2px solid #7b7b7b;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-button.default-btn {
|
||||||
|
border: 2px solid #000000;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xp-dialog-button:active {
|
||||||
|
border-top: 2px solid #7b7b7b;
|
||||||
|
border-left: 2px solid #7b7b7b;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,153 @@
|
||||||
<div class="xp-game-wrapper">
|
<div class="xp-minesweeper-window" (contextmenu)="$event.preventDefault()">
|
||||||
<!-- Barra Superior Clásica de Juegos de Windows XP -->
|
<!-- Marco Exterior Biselado Clásico (Windows 95/XP Style) -->
|
||||||
<header class="xp-game-header">
|
<div class="xp-minesweeper-frame">
|
||||||
<div class="xp-hud-stats">
|
<!-- Barra Superior de HUD (Contador Minas, Cara Sonriente, Cronómetro) -->
|
||||||
<div class="hud-item">
|
<header class="xp-ms-hud">
|
||||||
<span class="hud-tag">{{ i18n.t().retroGameScore }}</span>
|
<!-- Pantalla LED 7-Segmentos: Minas Restantes -->
|
||||||
<span class="hud-num">{{ score() }}</span>
|
<div class="xp-led-display" title="Minas restantes">
|
||||||
|
<span class="led-ghost">888</span>
|
||||||
|
<span class="led-digits">{{ formattedMines() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-item">
|
|
||||||
<span class="hud-tag">{{ i18n.t().retroGameHigh }}</span>
|
|
||||||
<span class="hud-num highlight">{{ highScore() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xp-hud-actions">
|
<!-- Botón Cara Sonriente Clásica -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="xp-classic-btn"
|
class="xp-smiley-btn"
|
||||||
(click)="startGame()"
|
(click)="onFaceClick()"
|
||||||
title="Iniciar o reiniciar la partida"
|
(mousedown)="onFaceMouseDown()"
|
||||||
|
(mouseup)="onFaceMouseUp()"
|
||||||
|
title="Reiniciar partida"
|
||||||
|
[attr.aria-label]="'Reiniciar Buscaminas'"
|
||||||
>
|
>
|
||||||
{{ isPlaying() ? i18n.t().retroGameRestart : i18n.t().retroGameStart }}
|
@switch (face()) {
|
||||||
|
@case ('dead') {
|
||||||
|
<span class="face-icon">😵</span>
|
||||||
|
}
|
||||||
|
@case ('won') {
|
||||||
|
<span class="face-icon">😎</span>
|
||||||
|
}
|
||||||
|
@case ('pressed') {
|
||||||
|
<span class="face-icon">😮</span>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<span class="face-icon">🙂</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="xp-classic-btn"
|
|
||||||
[disabled]="!isPlaying() || isGameOver()"
|
|
||||||
(click)="togglePause()"
|
|
||||||
title="Pausar el juego"
|
|
||||||
>
|
|
||||||
{{ isPaused() ? i18n.t().retroGameResume : i18n.t().retroGamePause }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Contenedor del Tablero Canvas (Siempre visible, interactivo al clic) -->
|
<!-- Pantalla LED 7-Segmentos: Tiempo Transcurrido -->
|
||||||
<div class="xp-canvas-box" (click)="onCanvasClick()" title="Haz clic aquí o pulsa cualquier flecha para jugar">
|
<div class="xp-led-display" title="Tiempo transcurrido (segundos)">
|
||||||
<canvas #gameCanvas width="300" height="300" class="xp-snake-canvas"></canvas>
|
<span class="led-ghost">888</span>
|
||||||
|
<span class="led-digits">{{ formattedTime() }}</span>
|
||||||
<!-- Banner sutil de instrucciones (sin tapar a la serpiente) -->
|
|
||||||
@if (!isPlaying() && !isGameOver()) {
|
|
||||||
<div class="xp-start-banner">
|
|
||||||
<span>{{ i18n.t().retroGameBanner }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</header>
|
||||||
|
|
||||||
<!-- Banner de Game Over -->
|
<!-- Tablero Cuadrícula 9x9 con Bisel Inset -->
|
||||||
@if (isGameOver()) {
|
<div class="xp-ms-board-inset">
|
||||||
<div class="xp-gameover-card" (click)="$event.stopPropagation()">
|
<div class="xp-ms-grid">
|
||||||
<span class="gameover-text">{{ i18n.t().retroGameOver }}</span>
|
@for (row of grid(); track $index; let r = $index) {
|
||||||
<span class="gameover-pts">{{ i18n.t().retroGameScoreLabel }} {{ score() }}</span>
|
<div class="xp-ms-row">
|
||||||
|
@for (cell of row; track cell.col; let c = $index) {
|
||||||
@if (!isScoreSaved() && score() > 0) {
|
<button
|
||||||
<div class="xp-save-row">
|
type="button"
|
||||||
<input
|
class="xp-ms-cell"
|
||||||
type="text"
|
[class.revealed]="cell.isRevealed"
|
||||||
maxlength="4"
|
[class.exploded]="cell.isExploded"
|
||||||
[value]="playerTag()"
|
[class.flagged]="cell.isFlagged"
|
||||||
(input)="playerTag.set($any($event.target).value.toUpperCase())"
|
[class.mine]="cell.isMine && cell.isRevealed"
|
||||||
class="xp-input-tag"
|
[class.num-1]="cell.isRevealed && cell.adjacentMines === 1"
|
||||||
placeholder="TAG"
|
[class.num-2]="cell.isRevealed && cell.adjacentMines === 2"
|
||||||
/>
|
[class.num-3]="cell.isRevealed && cell.adjacentMines === 3"
|
||||||
<button type="button" class="xp-classic-btn btn-save" (click)="submitScore()">
|
[class.num-4]="cell.isRevealed && cell.adjacentMines === 4"
|
||||||
{{ i18n.t().retroGameSave }}
|
[class.num-5]="cell.isRevealed && cell.adjacentMines === 5"
|
||||||
</button>
|
[class.num-6]="cell.isRevealed && cell.adjacentMines === 6"
|
||||||
|
[class.num-7]="cell.isRevealed && cell.adjacentMines === 7"
|
||||||
|
[class.num-8]="cell.isRevealed && cell.adjacentMines === 8"
|
||||||
|
(click)="onCellClick(r, c)"
|
||||||
|
(contextmenu)="onCellRightClick($event, r, c)"
|
||||||
|
(mousedown)="onCellMouseDown(cell)"
|
||||||
|
(dblclick)="onCellDblClick(r, c)"
|
||||||
|
[attr.aria-label]="'Fila ' + (r + 1) + ', Columna ' + (c + 1)"
|
||||||
|
>
|
||||||
|
@if (cell.isFlagged && !(isGameOver() && !cell.isMine)) {
|
||||||
|
<span class="cell-icon flag">🚩</span>
|
||||||
|
} @else if (isGameOver() && !cell.isMine && cell.isFlagged) {
|
||||||
|
<span class="cell-icon false-flag">❌</span>
|
||||||
|
} @else if (cell.isRevealed && cell.isMine) {
|
||||||
|
<span class="cell-icon bomb">💣</span>
|
||||||
|
} @else if (cell.isRevealed && cell.adjacentMines > 0) {
|
||||||
|
<span class="cell-num">{{ cell.adjacentMines }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (isScoreSaved()) {
|
|
||||||
<span class="xp-saved-label">{{ i18n.t().retroGameSaved }}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button type="button" class="xp-classic-btn btn-retry" (click)="startGame()">
|
|
||||||
{{ i18n.t().retroGamePlayAgain }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controles Direccionales Táctiles / Ratón (D-Pad) -->
|
<!-- Barra de Estado Clásica Inferior (Windows 95/XP Style) -->
|
||||||
<footer class="xp-dpad-container" (click)="$event.stopPropagation()">
|
<footer class="xp-ms-statusbar">
|
||||||
<div class="xp-dpad-grid">
|
<button
|
||||||
<button type="button" class="xp-arrow-btn up" (click)="setDirection('UP')" [attr.aria-label]="i18n.t().retroGameUp">▲</button>
|
type="button"
|
||||||
<button type="button" class="xp-arrow-btn left" (click)="setDirection('LEFT')" [attr.aria-label]="i18n.t().retroGameLeft">◀</button>
|
class="xp-status-mode-btn"
|
||||||
<button type="button" class="xp-arrow-btn down" (click)="setDirection('DOWN')" [attr.aria-label]="i18n.t().retroGameDown">▼</button>
|
(click)="toggleFlagMode()"
|
||||||
<button type="button" class="xp-arrow-btn right" (click)="setDirection('RIGHT')" [attr.aria-label]="i18n.t().retroGameRight">▶</button>
|
[title]="isFlagMode() ? 'Modo actual: Banderas (clic para Cavar)' : 'Modo actual: Cavar (clic para Banderas)'"
|
||||||
</div>
|
>
|
||||||
|
<span class="mode-icon">{{ isFlagMode() ? '🚩' : '⛏️' }}</span>
|
||||||
|
<span class="mode-text">{{ isFlagMode() ? i18n.t().retroGameFlagMode : i18n.t().retroGameDigMode }}</span>
|
||||||
|
</button>
|
||||||
|
@if (bestTime() !== null) {
|
||||||
|
<div class="xp-status-record" title="Mejor récord registrado">
|
||||||
|
<span>★ {{ bestTime() }}s</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- Cuadro de Diálogo Clásico Modal de Récord / Victoria (Estilo Windows Win32) -->
|
||||||
|
@if (showWinDialog()) {
|
||||||
|
<div class="xp-dialog-overlay" (click)="closeWinDialog()">
|
||||||
|
<div class="xp-dialog-window" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Barra de título clásica del diálogo -->
|
||||||
|
<header class="xp-dialog-titlebar">
|
||||||
|
<span class="xp-dialog-title">Buscaminas</span>
|
||||||
|
<button type="button" class="xp-dialog-btn-close" (click)="closeWinDialog()" aria-label="Cerrar">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Cuerpo del diálogo -->
|
||||||
|
<div class="xp-dialog-body">
|
||||||
|
<div class="xp-dialog-content-row">
|
||||||
|
<span class="xp-dialog-trophy">🏆</span>
|
||||||
|
<div class="xp-dialog-message-box">
|
||||||
|
<p class="xp-dialog-main-msg">¡Enhorabuena!</p>
|
||||||
|
<p class="xp-dialog-sub-msg">Has despejado el campo en <strong>{{ elapsedSeconds() }}</strong> segundos.</p>
|
||||||
|
<div class="xp-dialog-input-group">
|
||||||
|
<label for="xpPlayerName" class="xp-dialog-label">Nombre:</label>
|
||||||
|
<input
|
||||||
|
id="xpPlayerName"
|
||||||
|
type="text"
|
||||||
|
maxlength="4"
|
||||||
|
[value]="playerTag()"
|
||||||
|
(input)="playerTag.set($any($event.target).value.toUpperCase())"
|
||||||
|
class="xp-dialog-textbox"
|
||||||
|
placeholder="XP"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botonera clásica Windows -->
|
||||||
|
<div class="xp-dialog-button-row">
|
||||||
|
<button type="button" class="xp-dialog-button default-btn" (click)="submitScoreAndClose()">
|
||||||
|
Aceptar
|
||||||
|
</button>
|
||||||
|
<button type="button" class="xp-dialog-button" (click)="closeWinDialog()">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
AfterViewInit,
|
OnInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
ElementRef,
|
|
||||||
viewChild,
|
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
signal,
|
signal,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { RetroAudioService } from '../../../../../application/services/retro-audio.service';
|
import { RetroAudioService } from '../../../../../application/services/retro-audio.service';
|
||||||
import { RetroScorePort } from '../../../../../domain/ports/retro-score.port';
|
import { RetroScorePort } from '../../../../../domain/ports/retro-score.port';
|
||||||
import { Direction, Point } from '../../../../../domain/models/retro-game.model';
|
import { MinesweeperCell, MinesweeperFace } from '../../../../../domain/models/retro-game.model';
|
||||||
import { I18nService } from '../../../../../application/services/i18n.service';
|
import { I18nService } from '../../../../../application/services/i18n.service';
|
||||||
|
|
||||||
const GRID_SIZE = 15;
|
const ROWS = 9;
|
||||||
const CELL_SIZE = 20; // 15 * 20 = 300px
|
const COLS = 9;
|
||||||
const TICK_RATE_MS = 110;
|
const TOTAL_MINES = 10;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-retro-game-canvas',
|
selector: 'app-retro-game-canvas',
|
||||||
|
|
@ -25,332 +24,395 @@ const TICK_RATE_MS = 110;
|
||||||
styleUrl: './retro-game-canvas.component.css',
|
styleUrl: './retro-game-canvas.component.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class RetroGameCanvasComponent implements AfterViewInit, OnDestroy {
|
export class RetroGameCanvasComponent implements OnInit, OnDestroy {
|
||||||
private readonly audio = inject(RetroAudioService);
|
private readonly audio = inject(RetroAudioService);
|
||||||
private readonly scorePort = inject(RetroScorePort);
|
private readonly scorePort = inject(RetroScorePort);
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
|
|
||||||
protected readonly canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('gameCanvas');
|
// Estados del juego con Angular Signals
|
||||||
private ctx: CanvasRenderingContext2D | null = null;
|
protected readonly grid = signal<MinesweeperCell[][]>([]);
|
||||||
|
|
||||||
protected readonly score = signal<number>(0);
|
|
||||||
protected readonly highScore = signal<number>(0);
|
|
||||||
protected readonly isGameOver = signal<boolean>(false);
|
|
||||||
protected readonly isPaused = signal<boolean>(false);
|
|
||||||
protected readonly isPlaying = signal<boolean>(false);
|
protected readonly isPlaying = signal<boolean>(false);
|
||||||
|
protected readonly isGameOver = signal<boolean>(false);
|
||||||
|
protected readonly isWon = signal<boolean>(false);
|
||||||
|
protected readonly face = signal<MinesweeperFace>('idle');
|
||||||
|
protected readonly elapsedSeconds = signal<number>(0);
|
||||||
|
protected readonly bestTime = signal<number | null>(null);
|
||||||
|
protected readonly isFlagMode = signal<boolean>(false); // Para dispositivos táctiles
|
||||||
protected readonly playerTag = signal<string>('XP');
|
protected readonly playerTag = signal<string>('XP');
|
||||||
protected readonly isScoreSaved = signal<boolean>(false);
|
protected readonly isScoreSaved = signal<boolean>(false);
|
||||||
|
protected readonly showWinDialog = signal<boolean>(false);
|
||||||
|
|
||||||
private snake: Point[] = [{ x: 7, y: 7 }, { x: 7, y: 8 }, { x: 7, y: 9 }];
|
private timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private direction: Direction = 'UP';
|
private minesPlaced = false;
|
||||||
private nextDirection: Direction = 'UP';
|
|
||||||
private food: Point = { x: 7, y: 3 };
|
|
||||||
|
|
||||||
private animationFrameId: number | null = null;
|
// Contador de minas restantes (Minas totales - banderas colocadas)
|
||||||
private lastTickTime = 0;
|
protected readonly remainingMines = computed<number>(() => {
|
||||||
|
let flags = 0;
|
||||||
ngAfterViewInit(): void {
|
const currentGrid = this.grid();
|
||||||
const canvas = this.canvasRef()?.nativeElement;
|
for (let r = 0; r < ROWS; r++) {
|
||||||
if (!canvas) return;
|
for (let c = 0; c < COLS; c++) {
|
||||||
this.ctx = canvas.getContext('2d');
|
if (currentGrid[r]?.[c]?.isFlagged) {
|
||||||
if (this.ctx) {
|
flags++;
|
||||||
this.ctx.imageSmoothingEnabled = false;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.loadHighScore();
|
return TOTAL_MINES - flags;
|
||||||
// Dibujar inmediatamente el tablero con la serpiente y la manzana 100% visibles
|
});
|
||||||
this.resetState();
|
|
||||||
this.render();
|
// Contador de minas en formato clásico 3 dígitos LED (ej: "010", "009", "-01")
|
||||||
|
protected readonly formattedMines = computed<string>(() => {
|
||||||
|
const rem = this.remainingMines();
|
||||||
|
if (rem < 0) {
|
||||||
|
return `-${Math.abs(rem).toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return Math.min(rem, 999).toString().padStart(3, '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contador de tiempo en formato 3 dígitos LED (ej: "000", "042", "999")
|
||||||
|
protected readonly formattedTime = computed<string>(() => {
|
||||||
|
const sec = this.elapsedSeconds();
|
||||||
|
return Math.min(sec, 999).toString().padStart(3, '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadBestTime();
|
||||||
|
this.resetGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetState(): void {
|
resetGame(): void {
|
||||||
this.snake = [{ x: 7, y: 7 }, { x: 7, y: 8 }, { x: 7, y: 9 }];
|
this.stopTimer();
|
||||||
this.direction = 'UP';
|
this.minesPlaced = false;
|
||||||
this.nextDirection = 'UP';
|
|
||||||
this.food = { x: 7, y: 3 };
|
|
||||||
this.score.set(0);
|
|
||||||
this.isGameOver.set(false);
|
|
||||||
this.isPaused.set(false);
|
|
||||||
this.isPlaying.set(false);
|
this.isPlaying.set(false);
|
||||||
|
this.isGameOver.set(false);
|
||||||
|
this.isWon.set(false);
|
||||||
|
this.showWinDialog.set(false);
|
||||||
|
this.face.set('idle');
|
||||||
|
this.elapsedSeconds.set(0);
|
||||||
this.isScoreSaved.set(false);
|
this.isScoreSaved.set(false);
|
||||||
}
|
|
||||||
|
|
||||||
startGame(): void {
|
const newGrid: MinesweeperCell[][] = [];
|
||||||
if (this.animationFrameId !== null) {
|
for (let r = 0; r < ROWS; r++) {
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
const row: MinesweeperCell[] = [];
|
||||||
this.animationFrameId = null;
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
row.push({
|
||||||
|
row: r,
|
||||||
|
col: c,
|
||||||
|
isMine: false,
|
||||||
|
isRevealed: false,
|
||||||
|
isFlagged: false,
|
||||||
|
adjacentMines: 0,
|
||||||
|
isExploded: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
newGrid.push(row);
|
||||||
}
|
}
|
||||||
this.resetState();
|
this.grid.set(newGrid);
|
||||||
this.spawnFood();
|
|
||||||
this.isPlaying.set(true);
|
|
||||||
|
|
||||||
this.audio.playClick();
|
|
||||||
this.lastTickTime = performance.now();
|
|
||||||
this.loop(performance.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCanvasClick(): void {
|
onFaceClick(): void {
|
||||||
if (!this.isPlaying()) {
|
this.audio.playClick();
|
||||||
this.startGame();
|
this.resetGame();
|
||||||
} else if (this.isGameOver()) {
|
}
|
||||||
this.startGame();
|
|
||||||
|
onFaceMouseDown(): void {
|
||||||
|
if (!this.isGameOver() && !this.isWon()) {
|
||||||
|
this.face.set('pressed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
togglePause(): void {
|
onFaceMouseUp(): void {
|
||||||
if (!this.isPlaying() || this.isGameOver()) return;
|
if (!this.isGameOver() && !this.isWon()) {
|
||||||
this.isPaused.update(p => !p);
|
this.face.set('idle');
|
||||||
this.audio.playClick();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:keydown', ['$event'])
|
toggleFlagMode(): void {
|
||||||
handleKeyDown(event: KeyboardEvent): void {
|
this.audio.playClick();
|
||||||
const validKeys = [
|
this.isFlagMode.update(v => !v);
|
||||||
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
|
}
|
||||||
'w', 's', 'a', 'd', 'W', 'S', 'A', 'D', ' ', 'Enter'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!validKeys.includes(event.key)) return;
|
onCellClick(row: number, col: number): void {
|
||||||
|
if (this.isGameOver() || this.isWon()) {
|
||||||
|
this.audio.playClick();
|
||||||
|
this.resetGame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Prevenir el scroll del navegador con las flechas
|
if (this.isFlagMode()) {
|
||||||
|
this.toggleFlag(row, col);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.revealCell(row, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCellRightClick(event: MouseEvent, row: number, col: number): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (this.isGameOver() || this.isWon()) return;
|
||||||
|
this.toggleFlag(row, col);
|
||||||
|
}
|
||||||
|
|
||||||
// Si aún no está jugando, iniciar inmediatamente al pulsar cualquier flecha o tecla de control
|
onCellMouseDown(cell: MinesweeperCell): void {
|
||||||
if (!this.isPlaying() || this.isGameOver()) {
|
if (this.isGameOver() || this.isWon() || cell.isRevealed || cell.isFlagged) return;
|
||||||
this.startGame();
|
this.face.set('pressed');
|
||||||
if (event.key === 'ArrowDown' || event.key === 's' || event.key === 'S') this.nextDirection = 'DOWN';
|
}
|
||||||
if (event.key === 'ArrowLeft' || event.key === 'a' || event.key === 'A') this.nextDirection = 'LEFT';
|
|
||||||
if (event.key === 'ArrowRight' || event.key === 'd' || event.key === 'D') this.nextDirection = 'RIGHT';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.key) {
|
@HostListener('window:mouseup')
|
||||||
case 'ArrowUp':
|
onGlobalMouseUp(): void {
|
||||||
case 'w':
|
if (!this.isGameOver() && !this.isWon()) {
|
||||||
case 'W':
|
this.face.set('idle');
|
||||||
if (this.direction !== 'DOWN') this.nextDirection = 'UP';
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
case 's':
|
|
||||||
case 'S':
|
|
||||||
if (this.direction !== 'UP') this.nextDirection = 'DOWN';
|
|
||||||
break;
|
|
||||||
case 'ArrowLeft':
|
|
||||||
case 'a':
|
|
||||||
case 'A':
|
|
||||||
if (this.direction !== 'RIGHT') this.nextDirection = 'LEFT';
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
case 'd':
|
|
||||||
case 'D':
|
|
||||||
if (this.direction !== 'LEFT') this.nextDirection = 'RIGHT';
|
|
||||||
break;
|
|
||||||
case ' ':
|
|
||||||
this.togglePause();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setDirection(newDir: Direction): void {
|
// Doble clic o clic en número descubierto (Chording clásico)
|
||||||
if (!this.isPlaying() || this.isGameOver()) {
|
onCellDblClick(row: number, col: number): void {
|
||||||
this.startGame();
|
if (this.isGameOver() || this.isWon()) return;
|
||||||
this.nextDirection = newDir;
|
const currentGrid = this.grid();
|
||||||
return;
|
const cell = currentGrid[row]?.[col];
|
||||||
|
if (!cell || !cell.isRevealed || cell.adjacentMines === 0) return;
|
||||||
|
|
||||||
|
// Contar banderas adyacentes
|
||||||
|
const neighbors = this.getNeighbors(row, col);
|
||||||
|
const flaggedCount = neighbors.filter(n => currentGrid[n.r][n.c].isFlagged).length;
|
||||||
|
|
||||||
|
if (flaggedCount === cell.adjacentMines) {
|
||||||
|
neighbors.forEach(n => {
|
||||||
|
const target = currentGrid[n.r][n.c];
|
||||||
|
if (!target.isRevealed && !target.isFlagged) {
|
||||||
|
this.revealCell(n.r, n.c);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (this.isPaused()) return;
|
|
||||||
if (newDir === 'UP' && this.direction !== 'DOWN') this.nextDirection = 'UP';
|
|
||||||
if (newDir === 'DOWN' && this.direction !== 'UP') this.nextDirection = 'DOWN';
|
|
||||||
if (newDir === 'LEFT' && this.direction !== 'RIGHT') this.nextDirection = 'LEFT';
|
|
||||||
if (newDir === 'RIGHT' && this.direction !== 'LEFT') this.nextDirection = 'RIGHT';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private loop = (timestamp: number): void => {
|
private startTimer(): void {
|
||||||
if (!this.isPlaying()) return;
|
this.stopTimer();
|
||||||
|
this.timerInterval = setInterval(() => {
|
||||||
|
this.elapsedSeconds.update(s => Math.min(s + 1, 999));
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.isPaused() && !this.isGameOver()) {
|
private stopTimer(): void {
|
||||||
if (timestamp - this.lastTickTime >= TICK_RATE_MS) {
|
if (this.timerInterval !== null) {
|
||||||
this.update();
|
clearInterval(this.timerInterval);
|
||||||
this.lastTickTime = timestamp;
|
this.timerInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateMines(firstRow: number, firstCol: number): void {
|
||||||
|
const currentGrid = this.grid().map(row => row.map(cell => ({ ...cell })));
|
||||||
|
let placed = 0;
|
||||||
|
|
||||||
|
// Generar minas asegurando que la casilla inicial y sus 8 vecinas estén limpias
|
||||||
|
while (placed < TOTAL_MINES) {
|
||||||
|
const r = Math.floor(Math.random() * ROWS);
|
||||||
|
const c = Math.floor(Math.random() * COLS);
|
||||||
|
|
||||||
|
const isFirstSafeZone = Math.abs(r - firstRow) <= 1 && Math.abs(c - firstCol) <= 1;
|
||||||
|
|
||||||
|
if (!currentGrid[r][c].isMine && !isFirstSafeZone) {
|
||||||
|
currentGrid[r][c].isMine = true;
|
||||||
|
placed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.render();
|
|
||||||
if (!this.isGameOver()) {
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.loop);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private update(): void {
|
// Calcular números adyacentes
|
||||||
this.direction = this.nextDirection;
|
for (let r = 0; r < ROWS; r++) {
|
||||||
const head = { ...this.snake[0] };
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
if (!currentGrid[r][c].isMine) {
|
||||||
switch (this.direction) {
|
let count = 0;
|
||||||
case 'UP': head.y -= 1; break;
|
for (let dr = -1; dr <= 1; dr++) {
|
||||||
case 'DOWN': head.y += 1; break;
|
for (let dc = -1; dc <= 1; dc++) {
|
||||||
case 'LEFT': head.x -= 1; break;
|
if (dr === 0 && dc === 0) continue;
|
||||||
case 'RIGHT': head.x += 1; break;
|
const nr = r + dr;
|
||||||
}
|
const nc = c + dc;
|
||||||
|
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && currentGrid[nr][nc].isMine) {
|
||||||
// Choque con paredes
|
count++;
|
||||||
if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) {
|
}
|
||||||
this.triggerGameOver();
|
}
|
||||||
return;
|
}
|
||||||
}
|
currentGrid[r][c].adjacentMines = count;
|
||||||
|
}
|
||||||
// Choque con el propio cuerpo
|
}
|
||||||
if (this.snake.some(segment => segment.x === head.x && segment.y === head.y)) {
|
}
|
||||||
this.triggerGameOver();
|
|
||||||
return;
|
this.grid.set(currentGrid);
|
||||||
}
|
this.minesPlaced = true;
|
||||||
|
}
|
||||||
this.snake.unshift(head);
|
|
||||||
|
private revealCell(row: number, col: number): void {
|
||||||
// Comer fruta
|
const currentGrid = this.grid().map(r => r.map(c => ({ ...c })));
|
||||||
if (head.x === this.food.x && head.y === this.food.y) {
|
const cell = currentGrid[row]?.[col];
|
||||||
const nextScore = this.score() + 10;
|
|
||||||
this.score.set(nextScore);
|
if (!cell || cell.isRevealed || cell.isFlagged) return;
|
||||||
if (nextScore > this.highScore()) {
|
|
||||||
this.highScore.set(nextScore);
|
// Primer clic: colocar minas e iniciar cronómetro
|
||||||
|
if (!this.minesPlaced) {
|
||||||
|
this.generateMines(row, col);
|
||||||
|
this.startTimer();
|
||||||
|
this.isPlaying.set(true);
|
||||||
|
// Re-obtener la celda actualizada tras colocar minas
|
||||||
|
const updatedGrid = this.grid().map(r => r.map(c => ({ ...c })));
|
||||||
|
this.floodFill(updatedGrid, row, col);
|
||||||
|
this.grid.set(updatedGrid);
|
||||||
|
this.audio.playCellReveal();
|
||||||
|
this.checkWinCondition(updatedGrid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si hace clic en una mina: GAME OVER
|
||||||
|
if (cell.isMine) {
|
||||||
|
cell.isRevealed = true;
|
||||||
|
cell.isExploded = true;
|
||||||
|
this.grid.set(currentGrid);
|
||||||
|
this.handleGameOver(row, col);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Casilla segura: descubrir con flood-fill si está vacía
|
||||||
|
this.floodFill(currentGrid, row, col);
|
||||||
|
this.grid.set(currentGrid);
|
||||||
|
this.audio.playCellReveal();
|
||||||
|
this.checkWinCondition(currentGrid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private floodFill(grid: MinesweeperCell[][], row: number, col: number): void {
|
||||||
|
const cell = grid[row]?.[col];
|
||||||
|
if (!cell || cell.isRevealed || cell.isFlagged || cell.isMine) return;
|
||||||
|
|
||||||
|
cell.isRevealed = true;
|
||||||
|
|
||||||
|
// Si no tiene minas adyacentes (casilla vacía '0'), expandir en cascada
|
||||||
|
if (cell.adjacentMines === 0) {
|
||||||
|
const neighbors = this.getNeighbors(row, col);
|
||||||
|
for (const n of neighbors) {
|
||||||
|
this.floodFill(grid, n.r, n.c);
|
||||||
}
|
}
|
||||||
this.audio.playSnakeEat();
|
|
||||||
this.spawnFood();
|
|
||||||
} else {
|
|
||||||
this.snake.pop();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private triggerGameOver(): void {
|
private toggleFlag(row: number, col: number): void {
|
||||||
|
const currentGrid = this.grid().map(r => r.map(c => ({ ...c })));
|
||||||
|
const cell = currentGrid[row]?.[col];
|
||||||
|
|
||||||
|
if (!cell || cell.isRevealed) return;
|
||||||
|
|
||||||
|
cell.isFlagged = !cell.isFlagged;
|
||||||
|
this.grid.set(currentGrid);
|
||||||
|
this.audio.playFlagToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNeighbors(r: number, c: number): Array<{ r: number; c: number }> {
|
||||||
|
const neighbors: Array<{ r: number; c: number }> = [];
|
||||||
|
for (let dr = -1; dr <= 1; dr++) {
|
||||||
|
for (let dc = -1; dc <= 1; dc++) {
|
||||||
|
if (dr === 0 && dc === 0) continue;
|
||||||
|
const nr = r + dr;
|
||||||
|
const nc = c + dc;
|
||||||
|
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS) {
|
||||||
|
neighbors.push({ r: nr, c: nc });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleGameOver(explodedRow: number, explodedCol: number): void {
|
||||||
|
this.stopTimer();
|
||||||
this.isGameOver.set(true);
|
this.isGameOver.set(true);
|
||||||
this.audio.playGameOver();
|
this.face.set('dead');
|
||||||
this.render(); // Renderizar el estado de choque final
|
this.audio.playMineExplosion();
|
||||||
}
|
|
||||||
|
|
||||||
private spawnFood(): void {
|
// Revelar todas las demás minas
|
||||||
let valid = false;
|
const currentGrid = this.grid().map(r => r.map(c => ({ ...c })));
|
||||||
let newX = 0;
|
for (let r = 0; r < ROWS; r++) {
|
||||||
let newY = 0;
|
for (let c = 0; c < COLS; c++) {
|
||||||
while (!valid) {
|
const cell = currentGrid[r][c];
|
||||||
newX = Math.floor(Math.random() * GRID_SIZE);
|
if (cell.isMine && !cell.isFlagged) {
|
||||||
newY = Math.floor(Math.random() * GRID_SIZE);
|
cell.isRevealed = true;
|
||||||
valid = !this.snake.some(s => s.x === newX && s.y === newY);
|
}
|
||||||
|
// Bandera incorrecta
|
||||||
|
if (!cell.isMine && cell.isFlagged) {
|
||||||
|
cell.isRevealed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.food = { x: newX, y: newY };
|
this.grid.set(currentGrid);
|
||||||
}
|
}
|
||||||
|
|
||||||
private render(): void {
|
private checkWinCondition(grid: MinesweeperCell[][]): void {
|
||||||
if (!this.ctx) return;
|
let revealedSafeCount = 0;
|
||||||
const ctx = this.ctx;
|
const totalSafeCells = ROWS * COLS - TOTAL_MINES;
|
||||||
const width = GRID_SIZE * CELL_SIZE; // 300
|
|
||||||
const height = GRID_SIZE * CELL_SIZE; // 300
|
|
||||||
|
|
||||||
// Tablero estilo Arcade clásico con cuadrícula nítida
|
for (let r = 0; r < ROWS; r++) {
|
||||||
ctx.fillStyle = '#1c2833';
|
for (let c = 0; c < COLS; c++) {
|
||||||
ctx.fillRect(0, 0, width, height);
|
if (!grid[r][c].isMine && grid[r][c].isRevealed) {
|
||||||
|
revealedSafeCount++;
|
||||||
// Cuadrícula suave de casillas
|
|
||||||
for (let r = 0; r < GRID_SIZE; r++) {
|
|
||||||
for (let c = 0; c < GRID_SIZE; c++) {
|
|
||||||
if ((r + c) % 2 === 0) {
|
|
||||||
ctx.fillStyle = '#212f3d';
|
|
||||||
ctx.fillRect(c * CELL_SIZE, r * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Borde exterior
|
if (revealedSafeCount === totalSafeCells) {
|
||||||
ctx.strokeStyle = '#34495e';
|
this.stopTimer();
|
||||||
ctx.lineWidth = 2;
|
this.isWon.set(true);
|
||||||
ctx.strokeRect(1, 1, width - 2, height - 2);
|
this.face.set('won');
|
||||||
|
this.showWinDialog.set(true);
|
||||||
|
this.audio.playVictory();
|
||||||
|
|
||||||
// 1. DIBUJAR FRUTA (Manzana roja con tallo y hoja)
|
// Auto-marcar todas las minas restantes con banderas
|
||||||
const fx = this.food.x * CELL_SIZE + CELL_SIZE / 2;
|
const finalGrid = grid.map(r => r.map(c => ({
|
||||||
const fy = this.food.y * CELL_SIZE + CELL_SIZE / 2;
|
...c,
|
||||||
|
isFlagged: c.isMine ? true : c.isFlagged
|
||||||
|
})));
|
||||||
|
this.grid.set(finalGrid);
|
||||||
|
|
||||||
ctx.save();
|
const time = this.elapsedSeconds();
|
||||||
// Brillo de la manzana
|
const currentBest = this.bestTime();
|
||||||
ctx.fillStyle = '#e74c3c';
|
if (currentBest === null || time < currentBest) {
|
||||||
ctx.beginPath();
|
this.bestTime.set(time);
|
||||||
ctx.arc(fx, fy, 8, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Tallo marrón y hoja verde
|
|
||||||
ctx.fillStyle = '#795548';
|
|
||||||
ctx.fillRect(fx - 1, fy - 10, 2, 4);
|
|
||||||
ctx.fillStyle = '#2ecc71';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(fx + 3, fy - 8, 3, 1.5, Math.PI / 4, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// 2. DIBUJAR SERPIENTE (Verde brillante y con ojos claramente visibles)
|
|
||||||
this.snake.forEach((seg, i) => {
|
|
||||||
const isHead = i === 0;
|
|
||||||
const px = seg.x * CELL_SIZE;
|
|
||||||
const py = seg.y * CELL_SIZE;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
if (isHead) {
|
|
||||||
// Cabeza verde lima destacada
|
|
||||||
ctx.fillStyle = this.isGameOver() ? '#e74c3c' : '#2ecc71';
|
|
||||||
ctx.fillRect(px + 1, py + 1, CELL_SIZE - 2, CELL_SIZE - 2);
|
|
||||||
|
|
||||||
// Ojos blancos con pupilas negras
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
if (this.direction === 'UP' || this.direction === 'DOWN') {
|
|
||||||
ctx.fillRect(px + 3, py + 4, 4, 4);
|
|
||||||
ctx.fillRect(px + 13, py + 4, 4, 4);
|
|
||||||
ctx.fillStyle = '#000000';
|
|
||||||
ctx.fillRect(px + 4, py + (this.direction === 'UP' ? 4 : 6), 2, 2);
|
|
||||||
ctx.fillRect(px + 14, py + (this.direction === 'UP' ? 4 : 6), 2, 2);
|
|
||||||
} else {
|
|
||||||
ctx.fillRect(px + 4, py + 3, 4, 4);
|
|
||||||
ctx.fillRect(px + 4, py + 13, 4, 4);
|
|
||||||
ctx.fillStyle = '#000000';
|
|
||||||
ctx.fillRect(px + (this.direction === 'LEFT' ? 4 : 6), py + 4, 2, 2);
|
|
||||||
ctx.fillRect(px + (this.direction === 'LEFT' ? 4 : 6), py + 14, 2, 2);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Cuerpo verde esmeralda con borde
|
|
||||||
ctx.fillStyle = '#27ae60';
|
|
||||||
ctx.fillRect(px + 2, py + 2, CELL_SIZE - 4, CELL_SIZE - 4);
|
|
||||||
ctx.strokeStyle = '#1e8449';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeRect(px + 2, py + 2, CELL_SIZE - 4, CELL_SIZE - 4);
|
|
||||||
}
|
}
|
||||||
ctx.restore();
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
closeWinDialog(): void {
|
||||||
|
this.audio.playClick();
|
||||||
|
this.showWinDialog.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitScoreAndClose(): void {
|
||||||
|
this.submitScore();
|
||||||
|
this.showWinDialog.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
submitScore(): void {
|
submitScore(): void {
|
||||||
const finalScore = this.score();
|
const finalTime = this.elapsedSeconds();
|
||||||
const tag = (this.playerTag() || 'XP').trim().toUpperCase().slice(0, 4);
|
const tag = (this.playerTag() || 'XP').trim().toUpperCase().slice(0, 4);
|
||||||
if (finalScore <= 0 || this.isScoreSaved()) return;
|
if (finalTime <= 0 || this.isScoreSaved()) return;
|
||||||
|
|
||||||
this.scorePort.saveScore({
|
this.scorePort.saveScore({
|
||||||
playerTag: tag,
|
playerTag: tag,
|
||||||
gameCode: 'ZEN_SNAKE',
|
gameCode: 'MINESWEEPER',
|
||||||
score: finalScore
|
score: finalTime
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isScoreSaved.set(true);
|
this.isScoreSaved.set(true);
|
||||||
this.audio.playHighScore();
|
this.audio.playHighScore();
|
||||||
this.loadHighScore();
|
this.loadBestTime();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadHighScore(): void {
|
private loadBestTime(): void {
|
||||||
this.scorePort.getTopScores('ZEN_SNAKE', 1).subscribe({
|
this.scorePort.getTopScores('MINESWEEPER', 1).subscribe({
|
||||||
next: (scores) => {
|
next: (scores) => {
|
||||||
if (scores.length > 0 && scores[0].score > this.highScore()) {
|
if (scores.length > 0) {
|
||||||
this.highScore.set(scores[0].score);
|
this.bestTime.set(scores[0].score);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.isPlaying.set(false);
|
this.stopTimer();
|
||||||
if (this.animationFrameId !== null) {
|
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export class RetroScoresComponent implements OnInit {
|
||||||
|
|
||||||
loadScores(): void {
|
loadScores(): void {
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.scorePort.getTopScores('ZEN_SNAKE', 10).subscribe({
|
this.scorePort.getTopScores('MINESWEEPER', 10).subscribe({
|
||||||
next: (data) => {
|
next: (data) => {
|
||||||
this.scores.set(data);
|
this.scores.set(data);
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
<div class="start-sep"></div>
|
<div class="start-sep"></div>
|
||||||
|
|
||||||
<div class="start-item" (click)="onSelect('games')">
|
<div class="start-item" (click)="onSelect('games')">
|
||||||
<span class="item-icon game-icon">🎮</span>
|
<span class="item-icon game-icon">💣</span>
|
||||||
<span class="item-title bold">{{ i18n.t().retroMenuGames }}</span>
|
<span class="item-title bold">{{ i18n.t().retroMenuGames }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="start-item" (click)="onSelect()">
|
<div class="start-item" (click)="onSelect()">
|
||||||
|
|
|
||||||
|
|
@ -174,3 +174,33 @@
|
||||||
.tray-expand { font-size: 0.5rem; background: #1754ce; border-radius: 50%; width: 11px; height: 11px; display: flex; align-items: center; justify-content: center; }
|
.tray-expand { font-size: 0.5rem; background: #1754ce; border-radius: 50%; width: 11px; height: 11px; display: flex; align-items: center; justify-content: center; }
|
||||||
.tray-mini-icon { font-size: 0.625rem; }
|
.tray-mini-icon { font-size: 0.625rem; }
|
||||||
.xp-tray-clock { font-family: var(--font-sans); font-size: 0.6875rem; font-weight: 600; margin-left: 2px; }
|
.xp-tray-clock { font-family: var(--font-sans); font-size: 0.6875rem; font-weight: 600; margin-left: 2px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.xp-custom-taskbar {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
.xp-start-button {
|
||||||
|
height: 22px;
|
||||||
|
width: 64px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.start-text {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
.start-flag, .xp-flag-svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
.xp-quick-launch {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.xp-tray-clock {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
}
|
||||||
|
.tray-mini-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tray-expand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Eyebrow */
|
|
||||||
.editorial-eyebrow {
|
.editorial-eyebrow {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 var(--space-1) 0;
|
margin: 0 0 var(--space-1) 0;
|
||||||
|
|
@ -55,7 +54,6 @@
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Título Principal H2 */
|
|
||||||
.editorial-title {
|
.editorial-title {
|
||||||
margin: 0 0 var(--space-4) 0;
|
margin: 0 0 var(--space-4) 0;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|
@ -141,32 +139,6 @@
|
||||||
color: var(--zen-text-muted);
|
color: var(--zen-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hacker-screen {
|
|
||||||
flex: 0 0 clamp(64px, 10vh, 104px);
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
background: none;
|
|
||||||
background-color: transparent;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
isolation: isolate;
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hacker-rain {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
display: block;
|
|
||||||
background: transparent;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.ascii-portrait {
|
.ascii-portrait {
|
||||||
width: 56%;
|
width: 56%;
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,24 @@
|
||||||
<div class="profile-editorial-content">
|
<div class="profile-editorial-content">
|
||||||
@if (showProfile()) {
|
<section class="about-me" [attr.aria-label]="i18n.t().tpAboutAria">
|
||||||
<section class="about-me" [attr.aria-label]="i18n.t().tpAboutAria">
|
<div class="about-copy">
|
||||||
<div class="about-copy">
|
<span class="editorial-eyebrow">{{ i18n.t().tpEyebrow }}</span>
|
||||||
<span class="editorial-eyebrow">{{ i18n.t().tpEyebrow }}</span>
|
<p class="about-greeting">{{ i18n.t().tpAboutGreeting }}</p>
|
||||||
<p class="about-greeting">{{ i18n.t().tpAboutGreeting }}</p>
|
|
||||||
|
|
||||||
<figure class="ascii-portrait">
|
<figure class="ascii-portrait">
|
||||||
@if (asciiArt()) {
|
@if (asciiArt()) {
|
||||||
<pre aria-label="Retrato ASCII de Bryan">{{ asciiArt() }}</pre>
|
<pre aria-label="Retrato ASCII de Bryan">{{ asciiArt() }}</pre>
|
||||||
} @else if (asciiLoadError()) {
|
} @else if (asciiLoadError()) {
|
||||||
<figcaption class="ascii-status" role="status">No se pudo cargar el retrato.</figcaption>
|
<figcaption class="ascii-status" role="status">No se pudo cargar el retrato.</figcaption>
|
||||||
} @else {
|
} @else {
|
||||||
<figcaption class="ascii-status" role="status">Cargando retrato...</figcaption>
|
<figcaption class="ascii-status" role="status">Cargando retrato...</figcaption>
|
||||||
}
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
<h2 class="editorial-title">{{ i18n.t().tpAboutName }}</h2>
|
|
||||||
@for (paragraph of aboutParagraphs(); track $index) {
|
|
||||||
<p class="about-description">{{ paragraph }}</p>
|
|
||||||
}
|
}
|
||||||
<span class="about-link">About me <span aria-hidden="true">→</span></span>
|
</figure>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (showMatrix()) {
|
<h2 class="editorial-title">{{ i18n.t().tpAboutName }}</h2>
|
||||||
<section class="hacker-screen" aria-label="Lluvia de codigo Matrix">
|
@for (paragraph of aboutParagraphs(); track $index) {
|
||||||
<canvas #rainCanvas class="hacker-rain" aria-hidden="true"></canvas>
|
<p class="about-description">{{ paragraph }}</p>
|
||||||
</section>
|
}
|
||||||
}
|
<span class="about-link">About me <span aria-hidden="true">→</span></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,13 @@
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
ElementRef,
|
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
OnInit,
|
||||||
signal,
|
signal
|
||||||
ViewChild
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { I18nService } from '../../../application/services/i18n.service';
|
import { I18nService } from '../../../application/services/i18n.service';
|
||||||
|
|
||||||
interface DigitalRainColumn {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
speed: number;
|
|
||||||
length: number;
|
|
||||||
opacity: number;
|
|
||||||
characters: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const BINARY_CHARACTERS = '01';
|
|
||||||
const HEX_CHARACTERS = '0123456789ABCDEF';
|
|
||||||
const CODE_CHARACTERS = '{}[]()<>&|/\\:;=+-_*#$%@?!~^';
|
|
||||||
const TERMINAL_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-technical-profile',
|
selector: 'app-technical-profile',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -34,32 +15,15 @@ const TERMINAL_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy
|
||||||
styleUrl: './technical-profile.component.css',
|
styleUrl: './technical-profile.component.css',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class TechnicalProfileComponent implements AfterViewInit, OnDestroy, OnInit {
|
export class TechnicalProfileComponent implements OnInit {
|
||||||
@ViewChild('rainCanvas') private readonly rainCanvas?: ElementRef<HTMLCanvasElement>;
|
|
||||||
|
|
||||||
protected readonly i18n = inject(I18nService);
|
protected readonly i18n = inject(I18nService);
|
||||||
readonly showProfile = input(true);
|
|
||||||
readonly showMatrix = input(true);
|
|
||||||
protected readonly asciiArt = signal('');
|
protected readonly asciiArt = signal('');
|
||||||
protected readonly asciiLoadError = signal(false);
|
protected readonly asciiLoadError = signal(false);
|
||||||
protected readonly aboutParagraphs = computed(() =>
|
protected readonly aboutParagraphs = computed(() =>
|
||||||
this.i18n.t().tpAboutDescription.split('\n\n').filter((p) => p.trim().length > 0)
|
this.i18n.t().tpAboutDescription.split('\n\n').filter((p) => p.trim().length > 0)
|
||||||
);
|
);
|
||||||
private readonly columns: DigitalRainColumn[] = [];
|
|
||||||
private context?: CanvasRenderingContext2D;
|
|
||||||
private animationFrameId?: number;
|
|
||||||
private intersectionObserver?: IntersectionObserver;
|
|
||||||
private resizeObserver?: ResizeObserver;
|
|
||||||
private motionQuery?: MediaQueryList;
|
|
||||||
private isVisible = true;
|
|
||||||
private isReducedMotion = false;
|
|
||||||
private lastFrameTime = 0;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!this.showProfile()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadTextAsset('assets/portfolio.txt')
|
this.loadTextAsset('assets/portfolio.txt')
|
||||||
.then((portrait) => this.asciiArt.set(portrait))
|
.then((portrait) => this.asciiArt.set(portrait))
|
||||||
.catch(() => this.asciiLoadError.set(true));
|
.catch(() => this.asciiLoadError.set(true));
|
||||||
|
|
@ -70,199 +34,7 @@ export class TechnicalProfileComponent implements AfterViewInit, OnDestroy, OnIn
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`ASCII asset request failed: ${response.status}`);
|
throw new Error(`ASCII asset request failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.text();
|
return response.text();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
if (!this.showMatrix() || !this.rainCanvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = this.rainCanvas.nativeElement;
|
|
||||||
this.context = canvas.getContext('2d') ?? undefined;
|
|
||||||
if (!this.context) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
this.isReducedMotion = this.motionQuery.matches;
|
|
||||||
this.motionQuery.addEventListener('change', this.onMotionPreferenceChange);
|
|
||||||
|
|
||||||
this.resizeObserver = new ResizeObserver(() => this.resizeCanvas());
|
|
||||||
this.resizeObserver.observe(canvas.parentElement ?? canvas);
|
|
||||||
|
|
||||||
this.intersectionObserver = new IntersectionObserver(([entry]) => {
|
|
||||||
this.isVisible = entry.isIntersecting;
|
|
||||||
if (this.isVisible && !this.isReducedMotion && this.animationFrameId === undefined) {
|
|
||||||
this.lastFrameTime = 0;
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
||||||
}
|
|
||||||
}, { threshold: 0.05 });
|
|
||||||
this.intersectionObserver.observe(canvas);
|
|
||||||
|
|
||||||
this.resizeCanvas();
|
|
||||||
if (!this.isReducedMotion) {
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
if (this.animationFrameId !== undefined) {
|
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
|
||||||
}
|
|
||||||
this.motionQuery?.removeEventListener('change', this.onMotionPreferenceChange);
|
|
||||||
this.intersectionObserver?.disconnect();
|
|
||||||
this.resizeObserver?.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly onMotionPreferenceChange = (event: MediaQueryListEvent): void => {
|
|
||||||
this.isReducedMotion = event.matches;
|
|
||||||
if (this.isReducedMotion) {
|
|
||||||
if (this.animationFrameId !== undefined) {
|
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
|
||||||
this.animationFrameId = undefined;
|
|
||||||
}
|
|
||||||
this.draw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isVisible && this.animationFrameId === undefined) {
|
|
||||||
this.lastFrameTime = 0;
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly animate = (timestamp: number): void => {
|
|
||||||
this.animationFrameId = undefined;
|
|
||||||
if (!this.isVisible || this.isReducedMotion) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = this.lastFrameTime === 0 ? 0 : Math.min(timestamp - this.lastFrameTime, 50);
|
|
||||||
this.lastFrameTime = timestamp;
|
|
||||||
this.update(elapsed / 1000);
|
|
||||||
this.draw();
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
private resizeCanvas(): void {
|
|
||||||
if (!this.rainCanvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = this.rainCanvas.nativeElement;
|
|
||||||
const bounds = canvas.getBoundingClientRect();
|
|
||||||
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
canvas.width = Math.max(1, Math.floor(bounds.width * pixelRatio));
|
|
||||||
canvas.height = Math.max(1, Math.floor(bounds.height * pixelRatio));
|
|
||||||
this.context?.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
||||||
this.createColumns(bounds.width, bounds.height);
|
|
||||||
this.draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private createColumns(width: number, height: number): void {
|
|
||||||
const fontSize = Math.max(14, Math.min(20, width / 58));
|
|
||||||
const columnCount = Math.max(24, Math.floor(width / (fontSize * 0.72)));
|
|
||||||
this.columns.length = 0;
|
|
||||||
|
|
||||||
for (let index = 0; index < columnCount; index += 1) {
|
|
||||||
const length = this.randomInteger(14, 38);
|
|
||||||
this.columns.push({
|
|
||||||
x: (index / columnCount) * width + Math.random() * fontSize * 0.6,
|
|
||||||
y: Math.random() * height * 1.4 - height,
|
|
||||||
speed: this.randomInteger(150, 280),
|
|
||||||
length,
|
|
||||||
opacity: 0.16 + Math.random() * 0.68,
|
|
||||||
characters: Array.from({ length }, () => this.randomCharacter())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private update(deltaSeconds: number): void {
|
|
||||||
if (!this.rainCanvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const height = this.rainCanvas.nativeElement.clientHeight;
|
|
||||||
const fontSize = Math.max(14, Math.min(20, this.rainCanvas.nativeElement.clientWidth / 58));
|
|
||||||
|
|
||||||
for (const column of this.columns) {
|
|
||||||
column.y += column.speed * deltaSeconds;
|
|
||||||
if (column.y - column.length * fontSize > height) {
|
|
||||||
column.y = -Math.random() * height * 0.75 - column.length * fontSize;
|
|
||||||
column.speed = this.randomInteger(150, 280);
|
|
||||||
column.length = this.randomInteger(14, 38);
|
|
||||||
column.opacity = 0.16 + Math.random() * 0.68;
|
|
||||||
column.characters = Array.from({ length: column.length }, () => this.randomCharacter());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private draw(): void {
|
|
||||||
if (!this.rainCanvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = this.rainCanvas.nativeElement;
|
|
||||||
const context = this.context;
|
|
||||||
if (!context) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = canvas.clientWidth;
|
|
||||||
const height = canvas.clientHeight;
|
|
||||||
const fontSize = Math.max(14, Math.min(20, width / 58));
|
|
||||||
context.clearRect(0, 0, width, height);
|
|
||||||
context.font = `${fontSize}px ${getComputedStyle(canvas).fontFamily}`;
|
|
||||||
context.textAlign = 'center';
|
|
||||||
context.textBaseline = 'top';
|
|
||||||
context.shadowBlur = 0;
|
|
||||||
|
|
||||||
for (const column of this.columns) {
|
|
||||||
for (let index = 0; index < column.characters.length; index += 1) {
|
|
||||||
const characterY = column.y + index * fontSize;
|
|
||||||
if (characterY < -fontSize || characterY > height) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHead = index === 0;
|
|
||||||
const isHighlight = index === Math.floor(column.length * 0.35) && column.opacity > 0.5;
|
|
||||||
const trailAlpha = column.opacity * (1 - index / (column.length * 1.15));
|
|
||||||
const edgeFade = Math.min(1, Math.max(0, (height - characterY) / (fontSize * 5)));
|
|
||||||
const alpha = Math.max(0, trailAlpha * edgeFade);
|
|
||||||
if (alpha === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
context.fillStyle = isHead
|
|
||||||
? `rgba(220, 255, 226, ${Math.min(1, alpha + 0.3)})`
|
|
||||||
: isHighlight
|
|
||||||
? `rgba(80, 255, 126, ${alpha})`
|
|
||||||
: `rgba(18, 174, 78, ${alpha})`;
|
|
||||||
if (isHead || isHighlight) {
|
|
||||||
context.shadowColor = 'rgba(37, 255, 104, 0.8)';
|
|
||||||
context.shadowBlur = isHead ? 9 : 5;
|
|
||||||
}
|
|
||||||
context.fillText(column.characters[index], column.x, characterY);
|
|
||||||
context.shadowBlur = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private randomCharacter(): string {
|
|
||||||
const characterSet = Math.random() < 0.48
|
|
||||||
? BINARY_CHARACTERS
|
|
||||||
: Math.random() < 0.68
|
|
||||||
? HEX_CHARACTERS
|
|
||||||
: Math.random() < 0.72
|
|
||||||
? CODE_CHARACTERS
|
|
||||||
: TERMINAL_CHARACTERS;
|
|
||||||
|
|
||||||
return characterSet[Math.floor(Math.random() * characterSet.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
private randomInteger(min: number, max: number): number {
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,10 @@
|
||||||
:root,
|
:root,
|
||||||
[data-theme='zen-dark'] {
|
[data-theme='zen-dark'] {
|
||||||
--zen-bg-abyss: #07080b;
|
--zen-bg-abyss: #07080b;
|
||||||
--zen-bg-base: #0c0e14;
|
--zen-bg-base: #121517;
|
||||||
--zen-bg-surface: #131720;
|
--zen-bg-surface: #181d24;
|
||||||
--zen-bg-elevated: #1a202c;
|
--zen-bg-elevated: #1f2530;
|
||||||
--zen-bg-overlay: rgba(12, 14, 20, 0.85);
|
--zen-bg-overlay: rgba(18, 21, 23, 0.85);
|
||||||
|
|
||||||
--zen-border-subtle: #1e2533;
|
--zen-border-subtle: #1e2533;
|
||||||
--zen-border-focus: #3d4a63;
|
--zen-border-focus: #3d4a63;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue