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:
bryancito1090 2026-09-04 16:53:45 -05:00
parent e064d3c06f
commit 2d875bb9f3
35 changed files with 3161 additions and 1200 deletions

View File

@ -118,24 +118,7 @@ CREATE TABLE IF NOT EXISTS `project_skills` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ------------------------------------------------------------------------------
-- 6. TABLA: retro_scores (Tabla de Puntuaciones de Minijuegos Retro)
-- 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)
-- 6. TABLA: interactive_messages (Libro de Visitas / Mensajes Interactivos)
-- Justificación: Permite a reclutadores y visitantes dejar una firma o mensaje
-- desde el comando interactivo de la terminal Linux (`guestbook sign "Mensaje"`)
-- 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)
) 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)
-- ==============================================================================
@ -201,15 +206,7 @@ VALUES
('33333333-0001-0000-0000-000000000001', '22222222-0004-0000-0000-000000000004')
ON DUPLICATE KEY UPDATE `project_id` = `project_id`;
-- 5. Scores de Prueba para el Minijuego Retro
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
-- 5. Mensaje de Bienvenida en el Libro de Visitas
INSERT INTO `interactive_messages` (`id`, `sender_name`, `sender_contact`, `message`, `origin`)
VALUES (
UUID(),
@ -218,3 +215,16 @@ VALUES (
'Bienvenido a la terminal interactiva. Ejecuta "help" para comenzar.',
'Terminal'
) 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`;

View File

@ -6,6 +6,15 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
switchLangAria: 'Switch to Spanish',
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
preloading: 'PRELOADING',
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).',
// Featured Projects
fpHeaderTag: '01 // PROJECTS',
fpHeaderTag: 'PROJECTS',
fpAriaLabel: "Bryan Baño's featured projects",
fpPlaceholderImage: 'No screenshots',
fpAltPrefix: 'Screenshot of ',
@ -132,7 +141,7 @@ export const EN_TRANSLATIONS: TranslationDictionary = {
prj4Result3: 'Hardened security via JWT, database-driven role permissions, and prerequisite validation.',
// Terminal & Workstation
terminalWorkstationTag: '02 // INTERACTIVE WORKSTATION',
terminalWorkstationTag: 'INTERACTIVE WORKSTATION',
terminalHeading: 'Arch Linux Terminal // Fish Shell',
terminalSectionDesc: 'Full-scale interactive console to query technical ecosystem, skills, and live database.',
terminalBanner: `bryan@archlinux
@ -251,27 +260,49 @@ Approach: Zero over-engineering (Ponytail), high throughput, and Zen aesthetic d
retroSetSound: 'Web Audio API Sound',
retroSetSoundDesc: 'Synthetic mechanical and arcade effects',
retroScoresHeader: '★ ARCADE LEADERBOARD',
retroScoresTitle: 'ZEN SNAKE // TOP 10',
retroScoresTitle: 'MINESWEEPER // TOP 10',
retroScoresRefresh: 'REFRESH',
retroScoresRank: '#',
retroScoresTag: 'TAG',
retroScoresScore: 'SCORE',
retroScoresScore: 'TIME (S)',
retroScoresDate: 'DATE',
retroScoresEmpty: 'No scores recorded',
retroGameScore: 'SCORE:',
retroGameScore: 'TIME:',
retroGameHigh: 'BEST:',
retroGameStart: '▶ Start',
retroGameRestart: 'Restart',
retroGamePause: 'Pause',
retroGameResume: 'Resume',
retroGameBanner: 'Press any Arrow key or click to start',
retroGameOver: 'GAME OVER!',
retroGameScoreLabel: 'Score:',
retroGameBanner: 'Click any tile to start playing',
retroGameOver: 'BOOM! GAME OVER',
retroGameScoreLabel: 'Time:',
retroGameSave: 'Save',
retroGameSaved: '✓ Score saved',
retroGamePlayAgain: '↺ Play Again',
retroGameUp: 'Up',
retroGameLeft: 'Left',
retroGameDown: 'Down',
retroGameRight: 'Right'
retroGameWin: 'VICTORY! MINES CLEARED',
retroGameMines: 'Mines:',
retroGameFlagMode: 'Flag Mode',
retroGameDigMode: 'Dig Mode',
// 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'
};

View File

@ -6,6 +6,15 @@ export const ES_TRANSLATIONS: TranslationDictionary = {
switchLangAria: 'Cambiar a Inglés',
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
preloading: 'PRECARGANDO',
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).',
// Featured Projects
fpHeaderTag: '01 // PROYECTOS',
fpHeaderTag: 'PROYECTOS',
fpAriaLabel: 'Proyectos reales de Bryan Baño',
fpPlaceholderImage: 'Sin capturas',
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.',
// Terminal & Workstation
terminalWorkstationTag: '02 // INTERACTIVE WORKSTATION',
terminalWorkstationTag: 'INTERACTIVE WORKSTATION',
terminalHeading: 'Terminal Arch Linux // Fish Shell',
terminalSectionDesc: 'Consola interactiva a escala completa para consultar el ecosistema técnico, habilidades y base de datos.',
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.",
// Contact Section
contactEyebrow: '03 // CONTACTO',
contactEyebrow: 'CONTACTO',
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.',
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',
retroSetSound: 'Sonido Web Audio API',
retroSetSoundDesc: 'Efectos mecánicos y arcade sintéticos',
retroScoresHeader: '★ ARCADE LEADERBOARD',
retroScoresTitle: 'ZEN SNAKE // TOP 10',
retroScoresHeader: '★ TABLA DE RÉCORDS',
retroScoresTitle: 'BUSCAMINAS // TOP 10',
retroScoresRefresh: 'ACTUALIZAR',
retroScoresRank: '#',
retroScoresTag: 'TAG',
retroScoresScore: 'PUNTOS',
retroScoresScore: 'TIEMPO (S)',
retroScoresDate: 'FECHA',
retroScoresEmpty: 'No hay puntuaciones registradas',
retroGameScore: 'PUNTOS:',
retroGameHigh: 'RÉCORD:',
retroGameScore: 'TIEMPO:',
retroGameHigh: 'MEJOR:',
retroGameStart: '▶ Iniciar',
retroGameRestart: 'Reiniciar',
retroGamePause: 'Pausa',
retroGameResume: 'Reanudar',
retroGameBanner: 'Pulsa cualquier Flecha o haz clic para empezar',
retroGameOver: '¡FIN DE LA PARTIDA!',
retroGameScoreLabel: 'Puntuación:',
retroGameBanner: 'Haz clic en una casilla para comenzar',
retroGameOver: '¡BOOM! HAS PERDIDO',
retroGameScoreLabel: 'Tiempo:',
retroGameSave: 'Guardar',
retroGameSaved: '✓ Récord guardado',
retroGamePlayAgain: '↺ Jugar Otra Vez',
retroGameUp: 'Arriba',
retroGameLeft: 'Izquierda',
retroGameDown: 'Abajo',
retroGameRight: 'Derecha'
retroGameWin: '¡VICTORIA! MINAS DESPEJADAS',
retroGameMines: 'Minas:',
retroGameFlagMode: 'Marcar Bandera',
retroGameDigMode: 'Descubrir',
// 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'
};

View File

@ -42,23 +42,83 @@ export class RetroAudioService {
osc.stop(ctx.currentTime + 0.02);
}
playSnakeEat(): void {
playCellReveal(): void {
const ctx = this.getContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(440, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(880, ctx.currentTime + 0.08);
osc.type = 'triangle';
osc.frequency.setValueAtTime(520, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(780, ctx.currentTime + 0.03);
gain.gain.setValueAtTime(0.06, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.04, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03);
osc.connect(gain);
gain.connect(ctx.destination);
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 {

View File

@ -6,14 +6,14 @@ import { I18nService } from '../services/i18n.service';
const INITIAL_WINDOWS: Record<WindowId, RetroWindow> = {
games: {
id: 'games',
title: 'Snake - Juegos de Windows XP',
icon: 'gamepad',
title: 'Buscaminas - Juegos Clásicos',
icon: 'minesweeper',
isOpen: false,
isMinimized: false,
position: { x: 70, y: 20 },
size: { width: 360, height: 490 },
size: { width: 270, height: 360 },
zIndex: 10,
minSize: { width: 320, height: 380 }
minSize: { width: 260, height: 340 }
},
cv: {
id: 'cv',
@ -70,7 +70,7 @@ export class RetroOsStore {
readonly desktopIcons = computed<DesktopIcon[]>(() => {
const t = this.i18n.t();
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-scores', label: t.retroIconScores, windowId: 'scores', iconType: 'trophy' },
{ id: 'icon-settings', label: t.retroIconSettings, windowId: 'settings', iconType: 'gear' }

View File

@ -6,6 +6,15 @@ export interface TranslationDictionary {
readonly switchLangAria: 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
readonly preloading: string;
readonly heroAriaExperience: string;
@ -237,9 +246,31 @@ export interface TranslationDictionary {
readonly retroGameScoreLabel: string;
readonly retroGameSave: string;
readonly retroGameSaved: string;
readonly retroGamePlayAgain: string;
readonly retroGameUp: string;
readonly retroGameLeft: string;
readonly retroGameDown: string;
readonly retroGameRight: string;
readonly retroGameWin: string;
readonly retroGameMines: string;
readonly retroGameFlagMode: string;
readonly retroGameDigMode: 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;
}

View File

@ -1,17 +1,14 @@
export interface Point {
readonly x: number;
readonly y: number;
export interface MinesweeperCell {
readonly row: number;
readonly col: number;
isMine: boolean;
isRevealed: boolean;
isFlagged: boolean;
isExploded?: boolean;
adjacentMines: number;
}
export type Direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT';
export type GameDifficulty = 'ZEN' | 'STANDARD' | 'TURBO';
export interface FoodItem {
readonly position: Point;
readonly kanji: string;
readonly points: number;
}
export type MinesweeperFace = 'idle' | 'pressed' | 'dead' | 'won' | 'smile' | 'shock' | 'cool';
export interface RetroScore {
readonly id: string;
@ -21,11 +18,3 @@ export interface RetroScore {
readonly createdAtUtc: string;
}
export interface GameState {
readonly score: number;
readonly highScore: number;
readonly isGameOver: boolean;
readonly isPaused: boolean;
readonly isRunning: boolean;
readonly difficulty: GameDifficulty;
}

View File

@ -31,7 +31,7 @@ export interface DesktopIcon {
readonly id: string;
readonly label: string;
readonly windowId: WindowId;
readonly iconType: 'gamepad' | 'document' | 'terminal' | 'gear' | 'trophy';
readonly iconType: 'gamepad' | 'document' | 'terminal' | 'gear' | 'trophy' | 'minesweeper';
}
export interface ZenOsConfig {

View File

@ -9,22 +9,22 @@ const INITIAL_SEEDS: RetroScore[] = [
{
id: 'seed-1',
playerTag: 'ZEN',
gameCode: 'ZEN_SNAKE',
score: 1450,
gameCode: 'MINESWEEPER',
score: 42,
createdAtUtc: '2026-08-28T00:00:00.000Z'
},
{
id: 'seed-2',
playerTag: 'NEO',
gameCode: 'ZEN_SNAKE',
score: 1120,
gameCode: 'MINESWEEPER',
score: 68,
createdAtUtc: '2026-08-28T00:00:00.000Z'
},
{
id: 'seed-3',
playerTag: 'BRY',
gameCode: 'ZEN_SNAKE',
score: 980,
gameCode: 'MINESWEEPER',
score: 95,
createdAtUtc: '2026-08-28T00:00:00.000Z'
}
];
@ -54,9 +54,14 @@ export class LocalStorageRetroScoreAdapter implements RetroScorePort {
getTopScores(gameCode: string, limit = 10): Observable<RetroScore[]> {
const scores = this.readScores();
const isTimeBased = gameCode === 'MINESWEEPER';
const filtered = scores
.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);
return of(filtered);
}

View File

@ -5,41 +5,84 @@
/* 1. SECCION PRINCIPAL — Sumi-e Background & Ma (Espacio Negativo) */
.contact-section {
position: relative;
width: 100%;
background-color: #121517;
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 {
max-width: 72rem;
position: relative;
z-index: 2;
max-width: 1540px;
width: 100%;
margin: 0 auto;
padding: 0 clamp(1rem, 3vw, 2.5rem);
box-sizing: border-box;
}
/* 2. MICRO-HEADER — Patrón de Estado Retro */
.contact-micro-header {
/* Cabecera Normalizada de Sección */
.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;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-12);
}
.contact-pulse-dot {
width: 6px;
height: 6px;
.contact-section-header .pulse-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
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;
}
.contact-header-tag {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.14em;
.contact-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;
color: var(--zen-text-muted);
}
/* 3. GRID DE CONTENIDO — 2 Columnas Responsive */

View File

@ -1,9 +1,16 @@
<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">
<!-- Micro-Header -->
<header class="contact-micro-header">
<span class="contact-pulse-dot" aria-hidden="true"></span>
<span class="contact-header-tag">{{ i18n.t().contactEyebrow }}</span>
<!-- Cabecera Normalizada de Sección -->
<header class="contact-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().contactEyebrow }}</h2>
</div>
</div>
</header>
<div class="contact-grid">

View File

@ -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 {
ReactiveFormsModule,
FormGroup,
@ -7,6 +17,20 @@ import {
} from '@angular/forms';
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({
selector: 'app-contact-section',
standalone: true,
@ -15,7 +39,9 @@ import { I18nService } from '../../../application/services/i18n.service';
styleUrl: './contact-section.component.css',
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);
// 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 {
if (this.contactForm.invalid) {
this.contactForm.markAllAsTouched();
@ -55,7 +266,6 @@ export class ContactSectionComponent {
this.errorMessage.set(null);
this.isSubmitting.set(true);
// Simulación de envío con confirmación visual inmediata
setTimeout(() => {
this.isSubmitting.set(false);
this.isSuccess.set(true);

View File

@ -2,7 +2,6 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.featured-projects-wrapper {
@ -13,94 +12,96 @@
user-select: text;
}
.section-micro-header {
/* Cabecera limpia y abierta de la sección de Proyectos */
.projects-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
background: rgba(18, 22, 28, 0.6);
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-xs);
width: 100%;
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--zen-border-subtle);
margin-bottom: var(--space-1);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-2);
gap: var(--space-3);
}
.pulse-indicator {
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 {
.title-with-badge {
display: flex;
align-items: center;
gap: var(--space-3);
}
.carousel-counter {
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 700;
color: var(--zen-text-secondary);
letter-spacing: 0.1em;
.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;
}
.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;
align-items: center;
gap: 6px;
gap: var(--space-2);
}
.carousel-nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--zen-border-subtle);
background: rgba(255, 255, 255, 0.04);
color: var(--zen-text-primary);
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
transition: all var(--transition-fast);
}
.carousel-nav-btn:hover:not(:disabled) {
.carousel-nav-btn:hover {
background: rgba(255, 255, 255, 0.12);
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 {
opacity: 0.28;
cursor: not-allowed;
.carousel-nav-btn:active {
transform: scale(0.95);
}
/* Cuadrícula de Proyectos (Grid limpio sin scroll horizontal) */
/* Cuadrícula de Proyectos en Carrusel (Ocupa 100% de ancho) */
.projects-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
gap: var(--space-5);
width: 100%;
}
@media (min-width: 768px) {
.projects-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1200px) {
.projects-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@ -112,13 +113,14 @@
align-items: stretch;
gap: var(--space-3);
padding: var(--space-4);
width: 100%;
box-sizing: border-box;
background:
linear-gradient(180deg, rgba(16, 20, 25, 0.92), rgba(12, 16, 20, 0.88)),
rgba(11, 15, 18, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 3px solid var(--prj-accent, var(--zen-accent-kintsugi));
border-radius: 1.15rem;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
linear-gradient(180deg, rgba(16, 20, 26, 0.94), rgba(12, 16, 22, 0.9)),
rgba(11, 15, 18, 0.88);
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-md);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
cursor: pointer;
text-align: left;
transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
@ -138,23 +140,23 @@
.project-card:hover,
.project-card:focus-visible {
transform: translateY(-5px);
border-color: var(--prj-accent, var(--zen-accent-kintsugi));
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.24), 0 0 18px rgba(125, 207, 255, 0.1);
transform: translateY(-4px);
border-color: var(--zen-accent-kintsugi);
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.28), 0 0 16px rgba(224, 175, 104, 0.12);
outline: none;
}
.project-card.is-selected {
border-color: var(--prj-accent, var(--zen-accent-kintsugi));
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28), 0 0 22px rgba(125, 207, 255, 0.12);
border-color: var(--zen-accent-kintsugi);
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.32), 0 0 20px rgba(224, 175, 104, 0.15);
}
.project-image-slot {
width: 100%;
height: 180px;
background-color: #090b0e;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.9rem;
aspect-ratio: 16 / 9;
background-color: #080b0f;
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-sm);
overflow: hidden;
position: relative;
display: flex;
@ -163,8 +165,7 @@
}
.project-image-slot.no-image {
background: #080c12;
border-color: rgba(125, 207, 255, 0.18);
background: #080c14;
}
.tech-blueprint-slot {
@ -176,7 +177,7 @@
height: 100%;
background:
radial-gradient(circle at center, rgba(125, 207, 255, 0.08) 0%, transparent 70%),
#0a0e16;
#080b12;
user-select: none;
}
@ -203,7 +204,7 @@
.blueprint-icon {
font-size: 1.8rem;
color: var(--prj-accent, var(--zen-accent-kintsugi));
color: var(--zen-accent-kintsugi);
line-height: 1;
}
@ -212,7 +213,7 @@
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--prj-accent, var(--zen-accent-kintsugi));
color: var(--zen-accent-kintsugi);
}
.blueprint-title {
@ -269,7 +270,6 @@
height: 100%;
object-fit: cover;
display: block;
filter: saturate(1.08) contrast(1.04);
}
.project-content {
@ -277,7 +277,7 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.8rem;
gap: 0.75rem;
}
.project-meta-row {
@ -296,7 +296,7 @@
.meta-bullet {
font-size: 0.5rem;
color: var(--prj-accent, var(--zen-accent-kintsugi));
color: var(--zen-accent-kintsugi);
}
.meta-institution {
@ -318,17 +318,17 @@
.project-title {
font-family: var(--font-sans);
font-size: 1.45rem;
font-size: 1.35rem;
font-weight: 800;
color: var(--zen-text-primary);
margin: 0;
line-height: 1.2;
line-height: 1.25;
letter-spacing: -0.01em;
}
.project-impact {
font-family: var(--font-sans);
font-size: 0.82rem;
font-size: 0.8125rem;
color: var(--zen-text-secondary);
line-height: 1.6;
margin: 0;
@ -343,6 +343,7 @@
flex-wrap: wrap;
gap: 0.45rem;
margin-top: auto;
padding-top: 0.25rem;
}
.tech-chip {
@ -352,11 +353,11 @@
font-family: var(--font-sans);
font-size: 0.67rem;
font-weight: 700;
letter-spacing: 0.04em;
letter-spacing: 0.03em;
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;
color: var(--zen-text-muted);
color: var(--zen-text-secondary);
}
.project-action {
@ -364,32 +365,39 @@
align-items: center;
justify-content: flex-start;
margin-top: 0.25rem;
font-size: 0.72rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
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 {
position: fixed;
inset: 0;
z-index: 100;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(1rem, 2vw, 2.5rem);
background: rgba(4, 7, 10, 0.82);
backdrop-filter: blur(10px);
background: rgba(5, 7, 10, 0.85);
backdrop-filter: blur(12px);
}
.project-detail-shell {
width: min(1200px, 100%);
max-height: 92vh;
width: min(1300px, 100%);
max-height: 90vh;
overflow: auto;
border-radius: 1.4rem;
border-radius: var(--zen-radius-md);
}
/* Dots Indicadores */
.carousel-dots-row {
display: flex;
align-items: center;
@ -427,19 +435,15 @@
.carousel-dot.is-active .dot-inner {
width: 24px;
border-radius: 999px;
background: var(--prj-accent, var(--zen-accent-kintsugi));
box-shadow: 0 0 10px var(--prj-accent, var(--zen-accent-kintsugi));
background: var(--zen-accent-kintsugi);
box-shadow: 0 0 10px var(--zen-accent-kintsugi);
}
@media (max-width: 540px) {
@media (max-width: 640px) {
.project-card {
padding: var(--space-3);
}
.project-image-slot {
height: 150px;
}
.project-meta-row {
flex-direction: column;
align-items: flex-start;

View File

@ -1,44 +1,46 @@
<div class="featured-projects-wrapper" role="region" [attr.aria-label]="i18n.t().fpAriaLabel">
<!-- Cabecera de la sección con controles del carrusel -->
<header class="section-micro-header">
<!-- Cabecera limpia y prominente de la sección con controles del carrusel -->
<header class="projects-section-header">
<div class="header-left">
<span class="pulse-indicator" aria-hidden="true"></span>
<span class="header-tag">{{ i18n.t().fpHeaderTag }}</span>
<div class="title-with-badge">
<span class="pulse-indicator" aria-hidden="true"></span>
<h2 class="section-title">{{ i18n.t().fpHeaderTag }}</h2>
</div>
</div>
<div class="carousel-nav-controls">
<span class="carousel-counter" aria-live="polite">
0{{ currentIndex() + 1 }} / 0{{ totalPages() }}
</span>
<div class="carousel-buttons">
<button
type="button"
class="carousel-nav-btn"
(click)="prev()"
[attr.aria-label]="i18n.t().fpCarouselPrev"
>
‹
</button>
<button
type="button"
class="carousel-nav-btn"
(click)="next()"
[attr.aria-label]="i18n.t().fpCarouselNext"
>
›
</button>
</div>
<button
type="button"
class="carousel-nav-btn"
(click)="prev()"
[attr.aria-label]="i18n.t().fpCarouselPrev"
title="Anterior"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<button
type="button"
class="carousel-nav-btn"
(click)="next()"
[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">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</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">
@for (prj of visibleProjects(); track prj.id) {
<button
type="button"
class="project-card"
[class.is-selected]="selectedProjectId() === prj.id"
[style.--prj-accent]="prj.accentColor"
[attr.aria-pressed]="selectedProjectId() === prj.id"
(click)="selectProject(prj.id)"
>

View File

@ -38,17 +38,25 @@ export interface ProjectCardData {
export class FeaturedProjectsComponent {
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 isMobile = signal<boolean>(
typeof window !== 'undefined' ? window.innerWidth < 768 : false
protected readonly screenCols = signal<number>(
typeof window !== 'undefined'
? window.innerWidth >= 1200
? 3
: window.innerWidth >= 768
? 2
: 1
: 3
);
@HostListener('window:resize')
onResize(): void {
const mobile = window.innerWidth < 768;
if (this.isMobile() !== mobile) {
this.isMobile.set(mobile);
if (typeof window === 'undefined') return;
const w = window.innerWidth;
const cols = w >= 1200 ? 3 : w >= 768 ? 2 : 1;
if (this.screenCols() !== cols) {
this.screenCols.set(cols);
if (this.currentIndex() > 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)
protected readonly cardsPerPage = computed(() => (this.isMobile() ? 1 : 3));
// 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.screenCols());
// Índice máximo permitido para desplazamiento
protected readonly maxIndex = computed(() => {

View File

@ -8,14 +8,14 @@
flex-direction: column;
width: 100%;
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid rgba(255, 255, 255, 0.1);
border-top: 3px solid var(--prj-accent, var(--zen-accent-kintsugi));
border-radius: 1.25rem;
padding: clamp(1.25rem, 2.5vw, 2.25rem);
box-sizing: border-box;
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-md);
background:
linear-gradient(135deg, rgba(12, 16, 22, 0.96), rgba(17, 22, 28, 0.9)),
rgba(11, 14, 18, 0.9);
box-shadow: 0 24px 50px rgba(0, 0, 0, 0.32);
linear-gradient(135deg, rgba(14, 18, 24, 0.98), rgba(18, 22, 30, 0.94)),
#0c1015;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
}
.project-detail-header {
@ -23,6 +23,8 @@
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--zen-border-subtle);
}
.detail-kicker {
@ -30,10 +32,11 @@
align-items: center;
gap: 8px;
margin-bottom: 0.35rem;
font-size: 0.7rem;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--prj-accent, var(--zen-accent-kintsugi));
letter-spacing: 0.12em;
color: var(--zen-accent-kintsugi);
text-transform: uppercase;
}
@ -44,6 +47,7 @@
background: rgba(187, 154, 247, 0.12);
border: 1px solid rgba(187, 154, 247, 0.3);
border-radius: 999px;
font-family: var(--font-sans);
font-size: 0.625rem;
color: #bb9af7;
letter-spacing: 0.05em;
@ -51,8 +55,10 @@
.project-detail-header h3 {
margin: 0;
font-size: clamp(1.5rem, 2.2vw, 2.2rem);
line-height: 1.15;
font-family: var(--font-sans);
font-size: clamp(1.4rem, 2.2vw, 2rem);
font-weight: 800;
line-height: 1.2;
color: var(--zen-text-primary);
}
@ -62,47 +68,58 @@
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--zen-border-subtle);
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
color: var(--zen-text-primary);
font-size: 1.5rem;
line-height: 1;
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 {
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 {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(0, 1.4fr);
gap: var(--space-5);
align-items: stretch;
grid-template-columns: 1.15fr 1fr;
gap: var(--space-6);
align-items: start;
}
.project-detail-visual {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 260px;
width: 100%;
}
.main-image-viewport {
flex: 1;
min-height: 220px;
border-radius: 1rem;
width: 100%;
aspect-ratio: 16 / 10;
max-height: 460px;
border-radius: var(--zen-radius-sm);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #0a0d12;
border: 1px solid var(--zen-border-subtle);
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 {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
object-position: center;
background-color: #070a0e;
transition: opacity var(--transition-fast);
}
@ -110,29 +127,31 @@
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
width: 100%;
}
.thumb-btn {
height: 58px;
aspect-ratio: 16 / 10;
padding: 0;
border-radius: 0.5rem;
border-radius: var(--zen-radius-xs);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #0d1117;
border: 1px solid var(--zen-border-subtle);
background: #080b0f;
cursor: pointer;
opacity: 0.55;
opacity: 0.6;
transition: border-color var(--transition-fast), transform var(--transition-fast), opacity var(--transition-fast);
}
.thumb-btn:hover {
opacity: 0.85;
opacity: 0.9;
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.thumb-btn.is-active {
opacity: 1;
border-color: var(--prj-accent, var(--zen-accent-kintsugi));
box-shadow: 0 0 0 1px var(--prj-accent, var(--zen-accent-kintsugi));
border: 2px solid var(--zen-accent-kintsugi);
box-shadow: 0 0 12px rgba(224, 175, 104, 0.25);
}
.thumb-btn img {
@ -162,12 +181,10 @@
gap: 0.5rem;
width: 100%;
height: 100%;
min-height: 220px;
font-size: 1.15rem;
font-weight: 800;
color: var(--zen-text-secondary);
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) */
@ -177,14 +194,14 @@
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 280px;
border-radius: 1rem;
min-height: 320px;
border-radius: var(--zen-radius-sm);
overflow: hidden;
border: 1px solid rgba(125, 207, 255, 0.2);
border: 1px solid var(--zen-border-subtle);
background:
radial-gradient(circle at center, rgba(125, 207, 255, 0.08) 0%, transparent 70%),
#080c14;
padding: var(--space-4);
radial-gradient(circle at center, rgba(125, 207, 255, 0.06) 0%, transparent 70%),
#080b12;
padding: var(--space-5);
text-align: center;
}
@ -192,8 +209,8 @@
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(125, 207, 255, 0.07) 1px, transparent 1px),
linear-gradient(90deg, 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.06) 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
}
@ -205,12 +222,12 @@
flex-direction: column;
align-items: center;
gap: var(--space-2);
max-width: 320px;
max-width: 360px;
}
.blueprint-panel-icon {
font-size: 2.4rem;
color: var(--prj-accent, var(--zen-accent-kintsugi));
color: var(--zen-accent-kintsugi);
line-height: 1;
}
@ -219,7 +236,7 @@
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--prj-accent, var(--zen-accent-kintsugi));
color: var(--zen-accent-kintsugi);
}
.blueprint-panel-title {
@ -233,7 +250,7 @@
.blueprint-panel-subtitle {
margin: 0;
font-family: var(--font-sans);
font-size: 0.75rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--zen-text-secondary);
}
@ -241,26 +258,28 @@
.blueprint-specs-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: var(--space-2);
gap: 8px;
margin-top: var(--space-3);
width: 100%;
text-align: left;
}
.blueprint-specs-list .spec-item {
padding: 0.35rem 0.65rem;
border-radius: 0.45rem;
padding: 0.4rem 0.75rem;
border-radius: var(--zen-radius-xs);
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-size: 0.7rem;
font-size: 0.72rem;
color: var(--zen-text-secondary);
}
/* Cuerpo de Detalle del Proyecto */
.project-detail-body {
display: flex;
flex-direction: column;
gap: var(--space-3);
width: 100%;
}
.project-detail-meta {
@ -275,8 +294,9 @@
padding: 0.35rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.72rem;
border: 1px solid var(--zen-border-subtle);
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 700;
color: var(--zen-text-primary);
}
@ -287,67 +307,74 @@
.project-detail-summary {
margin: 0;
font-size: 0.96rem;
line-height: 1.7;
font-family: var(--font-sans);
font-size: 0.95rem;
line-height: 1.65;
color: var(--zen-text-secondary);
}
.detail-block {
padding: var(--space-3);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.9rem;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-sm);
background: rgba(255, 255, 255, 0.02);
}
.detail-block h4 {
margin: 0 0 0.5rem;
font-size: 0.72rem;
letter-spacing: 0.12em;
margin: 0 0 0.4rem;
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--prj-accent, var(--zen-accent-kintsugi));
color: var(--zen-accent-kintsugi);
}
.detail-block p,
.detail-block li {
margin: 0;
font-family: var(--font-sans);
color: var(--zen-text-secondary);
line-height: 1.7;
font-size: 0.92rem;
line-height: 1.6;
font-size: 0.9rem;
}
.results-block ul {
margin: 0;
padding-left: 1.15rem;
display: grid;
gap: 0.45rem;
gap: 0.4rem;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: var(--space-1);
}
.detail-tags span {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.7rem;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: rgba(125, 207, 255, 0.08);
border: 1px solid rgba(125, 207, 255, 0.18);
color: var(--zen-text-primary);
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--zen-border-subtle);
color: var(--zen-text-secondary);
font-family: var(--font-sans);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
letter-spacing: 0.03em;
}
@media (max-width: 720px) {
@media (max-width: 900px) {
.project-detail-panel {
padding: var(--space-4);
}
.project-detail-layout {
grid-template-columns: 1fr;
gap: var(--space-4);
}
.project-detail-header {

View File

@ -210,22 +210,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 {
border-color: rgba(197, 155, 39, 0.45);
color: var(--zen-accent-kintsugi);
@ -239,6 +223,159 @@
@media (max-width: 900px) {
.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;
}
}

View File

@ -258,9 +258,10 @@ export class Hero3dComponent implements OnInit, AfterViewInit, OnDestroy {
drawH = canvas.width / imgAspect;
offsetY = (canvas.height - drawH) / 2;
} else {
drawH = canvas.height;
drawW = canvas.height * imgAspect;
offsetX = (canvas.width - drawW) / 2;
drawW = canvas.width;
drawH = canvas.width / imgAspect;
offsetX = 0;
offsetY = (canvas.height - drawH) / 2;
}
ctx.imageSmoothingEnabled = true;

View File

@ -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; }
}

View File

@ -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">&lt; {{ 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>

View File

@ -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();
}
}

View File

@ -2,7 +2,7 @@
display: block;
width: 100%;
min-height: 100vh;
background-color: var(--zen-bg-base);
background-color: #121517;
color: var(--zen-text-primary);
}
@ -11,7 +11,7 @@
flex-direction: column;
min-height: 100vh;
position: relative;
background-color: var(--zen-bg-base);
background-color: #121517;
}
/* Header Minimalista Flotante */
@ -25,28 +25,81 @@
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 clamp(1rem, 3vw, 2.5rem);
background: rgba(10, 10, 11, 0.75);
backdrop-filter: blur(12px) saturate(160%);
padding: 0 clamp(0.75rem, 2vw, 2rem);
background: rgba(10, 10, 11, 0.85);
backdrop-filter: blur(14px) saturate(160%);
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;
align-items: center;
flex-shrink: 0;
}
.brand-logo {
height: 32px;
height: 30px;
width: auto;
object-fit: contain;
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 {
display: flex;
align-items: center;
gap: var(--space-4);
gap: var(--space-3);
flex-shrink: 0;
}
.time-location-block {
@ -59,14 +112,14 @@
}
.live-clock {
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--zen-text-primary);
letter-spacing: 0.02em;
}
.location-label {
font-size: 0.75rem;
font-size: 0.6875rem;
color: var(--zen-text-muted);
}
@ -78,7 +131,7 @@
background: var(--zen-bg-surface);
border: 1px solid var(--zen-border-subtle);
border-radius: var(--zen-radius-xs);
padding: 4px 10px;
padding: 4px 8px;
cursor: pointer;
transition: var(--transition-fast);
}
@ -89,7 +142,112 @@
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)
@ -97,10 +255,9 @@
.interactive-stage {
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: linear-gradient(180deg, #000000 0%, #121517 var(--space-12));
scroll-margin-top: 40px;
}
.stage-grid {
@ -145,46 +302,82 @@
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 {
width: 100%;
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
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 {
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 {
width: 100%;
padding: var(--space-16) clamp(1rem, 3vw, 2.5rem);
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 {
max-width: 1040px;
max-width: 1540px;
margin: 0 auto;
width: 100%;
display: flex;
@ -192,62 +385,90 @@
gap: var(--space-4);
}
.terminal-header-meta {
display: flex;
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;
/* Cabecera Normalizada de Sección */
.terminal-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-6) clamp(1rem, 3vw, 2.5rem);
border-top: 1px solid var(--zen-border-subtle);
width: 100%;
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;
}
.footer-copy {
font-family: var(--font-sans);
font-size: 0.8125rem;
color: var(--zen-text-muted);
.contact-stage.isolated-view {
min-height: calc(100vh - 56px);
padding-top: 56px;
background-color: #121517;
}
.footer-version {
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 600;
color: var(--zen-accent-matcha);
background: rgba(158, 206, 106, 0.08);
padding: 2px 6px;
border-radius: var(--zen-radius-xs);
@media (max-width: 768px) {
.zen-header {
padding: 0 8px;
gap: 6px;
height: 50px;
}
.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;
}
}

View File

@ -1,9 +1,72 @@
<div class="landing-container">
<!-- Header Minimalista Flotante (Glassmorphism & Zen High-Contrast) -->
<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" />
</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="time-location-block">
@ -19,56 +82,67 @@
</header>
<!-- Hero con Laptop 3D desarmable al scroll (300 frames) -->
<main class="hero-container">
<app-hero-3d></app-hero-3d>
</main>
@if (activeSection() === 'hero') {
<main class="hero-container isolated-view">
<app-hero-3d></app-hero-3d>
</main>
}
<!-- Contenedor Base para Módulos Interactivos (50/50 Perfil Técnico y Retro Monitor) -->
<section class="interactive-stage">
<div class="stage-grid">
<!-- Slot 1: Perfil Técnico & Filosofía de Ingeniería -->
<article class="module-card profile-module-card">
<app-technical-profile [showMatrix]="false"></app-technical-profile>
</article>
@if (activeSection() === 'retro') {
<section class="interactive-stage isolated-view">
<div class="stage-grid">
<!-- Slot 1: Perfil Técnico & Filosofía de Ingeniería -->
<article class="module-card profile-module-card">
<app-technical-profile></app-technical-profile>
</article>
<!-- Slot 2: Retro OS en Monitor CRT Antiguo (50% Horizontal) -->
<article class="module-card retro-module-card">
<app-retro-os></app-retro-os>
</article>
</div>
</section>
<section class="matrix-stage" aria-label="Lluvia de codigo Matrix">
<app-technical-profile [showProfile]="false"></app-technical-profile>
</section>
<!-- Slot 2: Retro OS en Monitor CRT Antiguo (50% Horizontal) -->
<article class="module-card retro-module-card">
<app-retro-os></app-retro-os>
</article>
</div>
</section>
}
<!-- Sección de Proyectos Destacados (Casos Reales del CV a Ancho Completo) -->
<section class="projects-stage">
<div class="projects-stage-inner">
<app-featured-projects></app-featured-projects>
</div>
</section>
@if (activeSection() === 'projects') {
<section class="projects-stage isolated-view">
<div class="projects-stage-inner">
<app-featured-projects></app-featured-projects>
</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) -->
<section class="terminal-stage">
<div class="terminal-stage-inner">
<div class="terminal-header-meta">
<span class="mono-section-tag">{{ i18n.t().terminalWorkstationTag }}</span>
<h2 class="terminal-section-heading">{{ i18n.t().terminalHeading }}</h2>
<p class="terminal-section-desc">
{{ i18n.t().terminalSectionDesc }}
</p>
@if (activeSection() === 'terminal') {
<section class="terminal-stage isolated-view">
<div class="terminal-stage-inner">
<!-- Cabecera Normalizada de Sección -->
<header class="terminal-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().navTerminal }}</h2>
</div>
</div>
</header>
<app-terminal></app-terminal>
</div>
<app-terminal></app-terminal>
</div>
</section>
</section>
}
<!-- Sección de Contacto Corporativa Premium (Full Width debajo de la Terminal) -->
<app-contact-section></app-contact-section>
<!-- Footer Minimalista -->
<footer class="zen-footer">
<p class="footer-copy">© {{ currentYear() }} {{ i18n.t().footerCopy }}</p>
<span class="footer-version">v1.0.0-alpha</span>
</footer>
@if (activeSection() === 'contact') {
<section class="contact-stage isolated-view">
<app-contact-section></app-contact-section>
</section>
}
</div>

View File

@ -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 { RetroOsComponent } from '../retro-os/retro-os.component';
import { TerminalComponent } from '../terminal/terminal.component';
import { FeaturedProjectsComponent } from '../featured-projects/featured-projects/featured-projects.component';
import { ContactSectionComponent } from '../contact-section/contact-section.component';
import { TechnicalProfileComponent } from '../technical-profile/technical-profile.component';
import { IncidentSimulatorComponent } from '../incident-simulator/incident-simulator.component';
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({
selector: 'app-landing',
standalone: true,
@ -16,7 +28,8 @@ import { I18nService } from '../../../application/services/i18n.service';
TerminalComponent,
FeaturedProjectsComponent,
ContactSectionComponent,
TechnicalProfileComponent
TechnicalProfileComponent,
IncidentSimulatorComponent
],
templateUrl: './landing.component.html',
styleUrl: './landing.component.css',
@ -26,6 +39,7 @@ export class LandingComponent implements OnInit, OnDestroy {
protected readonly i18n = inject(I18nService);
protected readonly currentYear = signal(new Date().getFullYear());
protected readonly currentTime = signal<string>('');
protected readonly activeSection = signal<LandingSection>('hero');
private clockInterval: ReturnType<typeof setInterval> | null = null;
private readonly timeFormatter = new Intl.DateTimeFormat('en-GB', {
@ -36,9 +50,167 @@ export class LandingComponent implements OnInit, OnDestroy {
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 {
this.updateClock();
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 {
@ -49,5 +221,13 @@ export class LandingComponent implements OnInit, OnDestroy {
if (this.clockInterval !== null) {
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);
}
}
}

View File

@ -8,8 +8,40 @@
>
<div class="xp-icon-graphic">
@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') {
<!-- 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">
<defs>
<linearGradient id="xpJoyGrad" x1="0" y1="0" x2="0" y2="1">

View File

@ -1,139 +1,417 @@
:host {
display: block;
width: 100%;
height: 100%;
background: #ece9d8;
user-select: none;
font-family: var(--font-sans);
}
.xp-game-wrapper {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px;
gap: 6px;
justify-content: center;
width: 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-game-header {
.xp-minesweeper-window {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
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;
align-items: center;
justify-content: space-between;
background: #f0ede0;
border: 1px solid #7f9db9;
border-radius: 3px;
padding: 4px 8px;
box-shadow: inset 0 1px 0 #ffffff;
background: #c0c0c0;
border-top: 2px solid #7b7b7b;
border-left: 2px solid #7b7b7b;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
padding: 4px 6px;
}
.xp-hud-stats { display: flex; gap: 10px; }
.hud-item { display: flex; align-items: center; gap: 4px; }
.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 {
/* Contadores LED Digitales (7 Segmentos) */
.xp-led-display {
position: relative;
width: 300px;
height: 300px;
border: 2px inset #7f9db9;
border-radius: 2px;
background: #1c2833;
cursor: pointer;
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.4);
background: #000000;
border-top: 1px solid #7b7b7b;
border-left: 1px solid #7b7b7b;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 1px 4px;
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; }
/* Banner de Instrucción en la parte inferior del canvas */
.xp-start-banner {
.led-ghost {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
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);
top: 1px;
right: 4px;
color: #380000;
pointer-events: none;
z-index: 1;
}
/* Card de Game Over */
.xp-gameover-card {
position: absolute;
inset: 20px;
background: rgba(236, 233, 216, 0.96);
border: 2px solid #0055ea;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
.led-digits {
position: relative;
color: #ff0000;
text-shadow: 0 0 2px rgba(255, 0, 0, 0.8);
z-index: 2;
}
/* 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;
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; }
.gameover-pts { font-size: 0.8125rem; font-weight: 600; color: #000000; }
.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; }
.xp-ms-row {
display: flex;
}
/* Controles Direccionales D-Pad */
.xp-dpad-container { width: 100%; max-width: 300px; display: flex; justify-content: center; }
.xp-dpad-grid { display: grid; grid-template-columns: repeat(3, 36px); grid-template-rows: repeat(2, 30px); gap: 4px; }
.xp-arrow-btn {
background: linear-gradient(180deg, #ffffff 0%, #ece9d8 100%);
border: 1px solid #7f9db9;
border-radius: 3px;
color: #0055ea;
font-size: 0.8125rem;
font-weight: 900;
cursor: pointer;
/* Casilla Individual de Buscaminas */
.xp-ms-cell {
width: 24px;
height: 24px;
background: #c0c0c0;
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #7b7b7b;
border-bottom: 2px solid #7b7b7b;
padding: 0;
margin: 0;
display: flex;
align-items: 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-arrow-btn.up { grid-column: 2; grid-row: 1; }
.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; }
.xp-ms-cell:active:not(.revealed):not(.flagged) {
border: 1px solid #7b7b7b;
}
/* 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;
}

View File

@ -1,89 +1,153 @@
<div class="xp-game-wrapper">
<!-- Barra Superior Clásica de Juegos de Windows XP -->
<header class="xp-game-header">
<div class="xp-hud-stats">
<div class="hud-item">
<span class="hud-tag">{{ i18n.t().retroGameScore }}</span>
<span class="hud-num">{{ score() }}</span>
<div class="xp-minesweeper-window" (contextmenu)="$event.preventDefault()">
<!-- Marco Exterior Biselado Clásico (Windows 95/XP Style) -->
<div class="xp-minesweeper-frame">
<!-- Barra Superior de HUD (Contador Minas, Cara Sonriente, Cronómetro) -->
<header class="xp-ms-hud">
<!-- Pantalla LED 7-Segmentos: Minas Restantes -->
<div class="xp-led-display" title="Minas restantes">
<span class="led-ghost">888</span>
<span class="led-digits">{{ formattedMines() }}</span>
</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
type="button"
class="xp-classic-btn"
(click)="startGame()"
title="Iniciar o reiniciar la partida"
class="xp-smiley-btn"
(click)="onFaceClick()"
(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
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) -->
<div class="xp-canvas-box" (click)="onCanvasClick()" title="Haz clic aquí o pulsa cualquier flecha para jugar">
<canvas #gameCanvas width="300" height="300" class="xp-snake-canvas"></canvas>
<!-- Banner sutil de instrucciones (sin tapar a la serpiente) -->
@if (!isPlaying() && !isGameOver()) {
<div class="xp-start-banner">
<span>{{ i18n.t().retroGameBanner }}</span>
<!-- Pantalla LED 7-Segmentos: Tiempo Transcurrido -->
<div class="xp-led-display" title="Tiempo transcurrido (segundos)">
<span class="led-ghost">888</span>
<span class="led-digits">{{ formattedTime() }}</span>
</div>
}
</header>
<!-- Banner de Game Over -->
@if (isGameOver()) {
<div class="xp-gameover-card" (click)="$event.stopPropagation()">
<span class="gameover-text">{{ i18n.t().retroGameOver }}</span>
<span class="gameover-pts">{{ i18n.t().retroGameScoreLabel }} {{ score() }}</span>
@if (!isScoreSaved() && score() > 0) {
<div class="xp-save-row">
<input
type="text"
maxlength="4"
[value]="playerTag()"
(input)="playerTag.set($any($event.target).value.toUpperCase())"
class="xp-input-tag"
placeholder="TAG"
/>
<button type="button" class="xp-classic-btn btn-save" (click)="submitScore()">
{{ i18n.t().retroGameSave }}
</button>
<!-- Tablero Cuadrícula 9x9 con Bisel Inset -->
<div class="xp-ms-board-inset">
<div class="xp-ms-grid">
@for (row of grid(); track $index; let r = $index) {
<div class="xp-ms-row">
@for (cell of row; track cell.col; let c = $index) {
<button
type="button"
class="xp-ms-cell"
[class.revealed]="cell.isRevealed"
[class.exploded]="cell.isExploded"
[class.flagged]="cell.isFlagged"
[class.mine]="cell.isMine && cell.isRevealed"
[class.num-1]="cell.isRevealed && cell.adjacentMines === 1"
[class.num-2]="cell.isRevealed && cell.adjacentMines === 2"
[class.num-3]="cell.isRevealed && cell.adjacentMines === 3"
[class.num-4]="cell.isRevealed && cell.adjacentMines === 4"
[class.num-5]="cell.isRevealed && cell.adjacentMines === 5"
[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>
}
@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>
<!-- Controles Direccionales Táctiles / Ratón (D-Pad) -->
<footer class="xp-dpad-container" (click)="$event.stopPropagation()">
<div class="xp-dpad-grid">
<button type="button" class="xp-arrow-btn up" (click)="setDirection('UP')" [attr.aria-label]="i18n.t().retroGameUp">▲</button>
<button type="button" class="xp-arrow-btn left" (click)="setDirection('LEFT')" [attr.aria-label]="i18n.t().retroGameLeft">◀</button>
<button type="button" class="xp-arrow-btn down" (click)="setDirection('DOWN')" [attr.aria-label]="i18n.t().retroGameDown">▼</button>
<button type="button" class="xp-arrow-btn right" (click)="setDirection('RIGHT')" [attr.aria-label]="i18n.t().retroGameRight">▶</button>
</div>
<!-- Barra de Estado Clásica Inferior (Windows 95/XP Style) -->
<footer class="xp-ms-statusbar">
<button
type="button"
class="xp-status-mode-btn"
(click)="toggleFlagMode()"
[title]="isFlagMode() ? 'Modo actual: Banderas (clic para Cavar)' : 'Modo actual: Cavar (clic para Banderas)'"
>
<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>
<!-- 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>

View File

@ -1,22 +1,21 @@
import {
Component,
AfterViewInit,
OnInit,
OnDestroy,
ElementRef,
viewChild,
ChangeDetectionStrategy,
signal,
computed,
inject,
HostListener
} from '@angular/core';
import { RetroAudioService } from '../../../../../application/services/retro-audio.service';
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';
const GRID_SIZE = 15;
const CELL_SIZE = 20; // 15 * 20 = 300px
const TICK_RATE_MS = 110;
const ROWS = 9;
const COLS = 9;
const TOTAL_MINES = 10;
@Component({
selector: 'app-retro-game-canvas',
@ -25,332 +24,395 @@ const TICK_RATE_MS = 110;
styleUrl: './retro-game-canvas.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RetroGameCanvasComponent implements AfterViewInit, OnDestroy {
export class RetroGameCanvasComponent implements OnInit, OnDestroy {
private readonly audio = inject(RetroAudioService);
private readonly scorePort = inject(RetroScorePort);
protected readonly i18n = inject(I18nService);
protected readonly canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('gameCanvas');
private ctx: CanvasRenderingContext2D | null = null;
protected readonly score = signal<number>(0);
protected readonly highScore = signal<number>(0);
protected readonly isGameOver = signal<boolean>(false);
protected readonly isPaused = signal<boolean>(false);
// Estados del juego con Angular Signals
protected readonly grid = signal<MinesweeperCell[][]>([]);
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 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 direction: Direction = 'UP';
private nextDirection: Direction = 'UP';
private food: Point = { x: 7, y: 3 };
private timerInterval: ReturnType<typeof setInterval> | null = null;
private minesPlaced = false;
private animationFrameId: number | null = null;
private lastTickTime = 0;
ngAfterViewInit(): void {
const canvas = this.canvasRef()?.nativeElement;
if (!canvas) return;
this.ctx = canvas.getContext('2d');
if (this.ctx) {
this.ctx.imageSmoothingEnabled = false;
// Contador de minas restantes (Minas totales - banderas colocadas)
protected readonly remainingMines = computed<number>(() => {
let flags = 0;
const currentGrid = this.grid();
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (currentGrid[r]?.[c]?.isFlagged) {
flags++;
}
}
}
this.loadHighScore();
// Dibujar inmediatamente el tablero con la serpiente y la manzana 100% visibles
this.resetState();
this.render();
return TOTAL_MINES - flags;
});
// 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 {
this.snake = [{ x: 7, y: 7 }, { x: 7, y: 8 }, { x: 7, y: 9 }];
this.direction = 'UP';
this.nextDirection = 'UP';
this.food = { x: 7, y: 3 };
this.score.set(0);
this.isGameOver.set(false);
this.isPaused.set(false);
resetGame(): void {
this.stopTimer();
this.minesPlaced = 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);
}
startGame(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
const newGrid: MinesweeperCell[][] = [];
for (let r = 0; r < ROWS; r++) {
const row: MinesweeperCell[] = [];
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.spawnFood();
this.isPlaying.set(true);
this.audio.playClick();
this.lastTickTime = performance.now();
this.loop(performance.now());
this.grid.set(newGrid);
}
onCanvasClick(): void {
if (!this.isPlaying()) {
this.startGame();
} else if (this.isGameOver()) {
this.startGame();
onFaceClick(): void {
this.audio.playClick();
this.resetGame();
}
onFaceMouseDown(): void {
if (!this.isGameOver() && !this.isWon()) {
this.face.set('pressed');
}
}
togglePause(): void {
if (!this.isPlaying() || this.isGameOver()) return;
this.isPaused.update(p => !p);
this.audio.playClick();
onFaceMouseUp(): void {
if (!this.isGameOver() && !this.isWon()) {
this.face.set('idle');
}
}
@HostListener('window:keydown', ['$event'])
handleKeyDown(event: KeyboardEvent): void {
const validKeys = [
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'w', 's', 'a', 'd', 'W', 'S', 'A', 'D', ' ', 'Enter'
];
toggleFlagMode(): void {
this.audio.playClick();
this.isFlagMode.update(v => !v);
}
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();
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
if (!this.isPlaying() || this.isGameOver()) {
this.startGame();
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;
}
onCellMouseDown(cell: MinesweeperCell): void {
if (this.isGameOver() || this.isWon() || cell.isRevealed || cell.isFlagged) return;
this.face.set('pressed');
}
switch (event.key) {
case 'ArrowUp':
case 'w':
case 'W':
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;
@HostListener('window:mouseup')
onGlobalMouseUp(): void {
if (!this.isGameOver() && !this.isWon()) {
this.face.set('idle');
}
}
setDirection(newDir: Direction): void {
if (!this.isPlaying() || this.isGameOver()) {
this.startGame();
this.nextDirection = newDir;
return;
// Doble clic o clic en número descubierto (Chording clásico)
onCellDblClick(row: number, col: number): void {
if (this.isGameOver() || this.isWon()) return;
const currentGrid = this.grid();
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 => {
if (!this.isPlaying()) return;
private startTimer(): void {
this.stopTimer();
this.timerInterval = setInterval(() => {
this.elapsedSeconds.update(s => Math.min(s + 1, 999));
}, 1000);
}
if (!this.isPaused() && !this.isGameOver()) {
if (timestamp - this.lastTickTime >= TICK_RATE_MS) {
this.update();
this.lastTickTime = timestamp;
private stopTimer(): void {
if (this.timerInterval !== null) {
clearInterval(this.timerInterval);
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 {
this.direction = this.nextDirection;
const head = { ...this.snake[0] };
switch (this.direction) {
case 'UP': head.y -= 1; break;
case 'DOWN': head.y += 1; break;
case 'LEFT': head.x -= 1; break;
case 'RIGHT': head.x += 1; break;
}
// Choque con paredes
if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) {
this.triggerGameOver();
return;
}
// Choque con el propio cuerpo
if (this.snake.some(segment => segment.x === head.x && segment.y === head.y)) {
this.triggerGameOver();
return;
}
this.snake.unshift(head);
// Comer fruta
if (head.x === this.food.x && head.y === this.food.y) {
const nextScore = this.score() + 10;
this.score.set(nextScore);
if (nextScore > this.highScore()) {
this.highScore.set(nextScore);
// Calcular números adyacentes
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!currentGrid[r][c].isMine) {
let count = 0;
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 && currentGrid[nr][nc].isMine) {
count++;
}
}
}
currentGrid[r][c].adjacentMines = count;
}
}
}
this.grid.set(currentGrid);
this.minesPlaced = true;
}
private revealCell(row: number, col: number): void {
const currentGrid = this.grid().map(r => r.map(c => ({ ...c })));
const cell = currentGrid[row]?.[col];
if (!cell || cell.isRevealed || cell.isFlagged) return;
// 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.audio.playGameOver();
this.render(); // Renderizar el estado de choque final
}
this.face.set('dead');
this.audio.playMineExplosion();
private spawnFood(): void {
let valid = false;
let newX = 0;
let newY = 0;
while (!valid) {
newX = Math.floor(Math.random() * GRID_SIZE);
newY = Math.floor(Math.random() * GRID_SIZE);
valid = !this.snake.some(s => s.x === newX && s.y === newY);
// Revelar todas las demás minas
const currentGrid = this.grid().map(r => r.map(c => ({ ...c })));
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const cell = currentGrid[r][c];
if (cell.isMine && !cell.isFlagged) {
cell.isRevealed = true;
}
// Bandera incorrecta
if (!cell.isMine && cell.isFlagged) {
cell.isRevealed = true;
}
}
}
this.food = { x: newX, y: newY };
this.grid.set(currentGrid);
}
private render(): void {
if (!this.ctx) return;
const ctx = this.ctx;
const width = GRID_SIZE * CELL_SIZE; // 300
const height = GRID_SIZE * CELL_SIZE; // 300
private checkWinCondition(grid: MinesweeperCell[][]): void {
let revealedSafeCount = 0;
const totalSafeCells = ROWS * COLS - TOTAL_MINES;
// Tablero estilo Arcade clásico con cuadrícula nítida
ctx.fillStyle = '#1c2833';
ctx.fillRect(0, 0, width, height);
// 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);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!grid[r][c].isMine && grid[r][c].isRevealed) {
revealedSafeCount++;
}
}
}
// Borde exterior
ctx.strokeStyle = '#34495e';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, width - 2, height - 2);
if (revealedSafeCount === totalSafeCells) {
this.stopTimer();
this.isWon.set(true);
this.face.set('won');
this.showWinDialog.set(true);
this.audio.playVictory();
// 1. DIBUJAR FRUTA (Manzana roja con tallo y hoja)
const fx = this.food.x * CELL_SIZE + CELL_SIZE / 2;
const fy = this.food.y * CELL_SIZE + CELL_SIZE / 2;
// Auto-marcar todas las minas restantes con banderas
const finalGrid = grid.map(r => r.map(c => ({
...c,
isFlagged: c.isMine ? true : c.isFlagged
})));
this.grid.set(finalGrid);
ctx.save();
// Brillo de la manzana
ctx.fillStyle = '#e74c3c';
ctx.beginPath();
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);
const time = this.elapsedSeconds();
const currentBest = this.bestTime();
if (currentBest === null || time < currentBest) {
this.bestTime.set(time);
}
ctx.restore();
});
}
}
closeWinDialog(): void {
this.audio.playClick();
this.showWinDialog.set(false);
}
submitScoreAndClose(): void {
this.submitScore();
this.showWinDialog.set(false);
}
submitScore(): void {
const finalScore = this.score();
const finalTime = this.elapsedSeconds();
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({
playerTag: tag,
gameCode: 'ZEN_SNAKE',
score: finalScore
gameCode: 'MINESWEEPER',
score: finalTime
}).subscribe({
next: () => {
this.isScoreSaved.set(true);
this.audio.playHighScore();
this.loadHighScore();
this.loadBestTime();
}
});
}
private loadHighScore(): void {
this.scorePort.getTopScores('ZEN_SNAKE', 1).subscribe({
private loadBestTime(): void {
this.scorePort.getTopScores('MINESWEEPER', 1).subscribe({
next: (scores) => {
if (scores.length > 0 && scores[0].score > this.highScore()) {
this.highScore.set(scores[0].score);
if (scores.length > 0) {
this.bestTime.set(scores[0].score);
}
}
});
}
ngOnDestroy(): void {
this.isPlaying.set(false);
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
}
this.stopTimer();
}
}

View File

@ -25,7 +25,7 @@ export class RetroScoresComponent implements OnInit {
loadScores(): void {
this.isLoading.set(true);
this.scorePort.getTopScores('ZEN_SNAKE', 10).subscribe({
this.scorePort.getTopScores('MINESWEEPER', 10).subscribe({
next: (data) => {
this.scores.set(data);
this.isLoading.set(false);

View File

@ -29,7 +29,7 @@
<div class="start-sep"></div>
<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>
</div>
<div class="start-item" (click)="onSelect()">

View File

@ -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-mini-icon { font-size: 0.625rem; }
.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;
}
}

View File

@ -35,7 +35,6 @@
line-height: 1.65;
}
/* Eyebrow */
.editorial-eyebrow {
display: block;
margin: 0 0 var(--space-1) 0;
@ -55,7 +54,6 @@
line-height: 1.35;
}
/* Título Principal H2 */
.editorial-title {
margin: 0 0 var(--space-4) 0;
font-family: var(--font-sans);
@ -141,32 +139,6 @@
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) {
.ascii-portrait {
width: 56%;

View File

@ -1,32 +1,24 @@
<div class="profile-editorial-content">
@if (showProfile()) {
<section class="about-me" [attr.aria-label]="i18n.t().tpAboutAria">
<div class="about-copy">
<span class="editorial-eyebrow">{{ i18n.t().tpEyebrow }}</span>
<p class="about-greeting">{{ i18n.t().tpAboutGreeting }}</p>
<section class="about-me" [attr.aria-label]="i18n.t().tpAboutAria">
<div class="about-copy">
<span class="editorial-eyebrow">{{ i18n.t().tpEyebrow }}</span>
<p class="about-greeting">{{ i18n.t().tpAboutGreeting }}</p>
<figure class="ascii-portrait">
@if (asciiArt()) {
<pre aria-label="Retrato ASCII de Bryan">{{ asciiArt() }}</pre>
} @else if (asciiLoadError()) {
<figcaption class="ascii-status" role="status">No se pudo cargar el retrato.</figcaption>
} @else {
<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>
<figure class="ascii-portrait">
@if (asciiArt()) {
<pre aria-label="Retrato ASCII de Bryan">{{ asciiArt() }}</pre>
} @else if (asciiLoadError()) {
<figcaption class="ascii-status" role="status">No se pudo cargar el retrato.</figcaption>
} @else {
<figcaption class="ascii-status" role="status">Cargando retrato...</figcaption>
}
<span class="about-link">About me <span aria-hidden="true">→</span></span>
</div>
</section>
}
</figure>
@if (showMatrix()) {
<section class="hacker-screen" aria-label="Lluvia de codigo Matrix">
<canvas #rainCanvas class="hacker-rain" aria-hidden="true"></canvas>
</section>
}
<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>
</div>
</section>
</div>

View File

@ -1,32 +1,13 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
input,
OnDestroy,
OnInit,
signal,
ViewChild
signal
} from '@angular/core';
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({
selector: 'app-technical-profile',
standalone: true,
@ -34,32 +15,15 @@ const TERMINAL_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy
styleUrl: './technical-profile.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TechnicalProfileComponent implements AfterViewInit, OnDestroy, OnInit {
@ViewChild('rainCanvas') private readonly rainCanvas?: ElementRef<HTMLCanvasElement>;
export class TechnicalProfileComponent implements OnInit {
protected readonly i18n = inject(I18nService);
readonly showProfile = input(true);
readonly showMatrix = input(true);
protected readonly asciiArt = signal('');
protected readonly asciiLoadError = signal(false);
protected readonly aboutParagraphs = computed(() =>
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 {
if (!this.showProfile()) {
return;
}
this.loadTextAsset('assets/portfolio.txt')
.then((portrait) => this.asciiArt.set(portrait))
.catch(() => this.asciiLoadError.set(true));
@ -70,199 +34,7 @@ export class TechnicalProfileComponent implements AfterViewInit, OnDestroy, OnIn
if (!response.ok) {
throw new Error(`ASCII asset request failed: ${response.status}`);
}
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;
}
}

View File

@ -42,10 +42,10 @@
:root,
[data-theme='zen-dark'] {
--zen-bg-abyss: #07080b;
--zen-bg-base: #0c0e14;
--zen-bg-surface: #131720;
--zen-bg-elevated: #1a202c;
--zen-bg-overlay: rgba(12, 14, 20, 0.85);
--zen-bg-base: #121517;
--zen-bg-surface: #181d24;
--zen-bg-elevated: #1f2530;
--zen-bg-overlay: rgba(18, 21, 23, 0.85);
--zen-border-subtle: #1e2533;
--zen-border-focus: #3d4a63;