feat: implement Windows XP-style boot sequence with power state management and startup audio

This commit is contained in:
bryancito1090 2026-08-31 10:53:30 -05:00
parent 78497c0e31
commit 75e8d8e0b5
10 changed files with 364 additions and 10 deletions

View File

@ -101,4 +101,36 @@ export class RetroAudioService {
osc.stop(startTime + 0.06); osc.stop(startTime + 0.06);
}); });
} }
playStartup(): void {
const ctx = this.getContext();
if (!ctx) return;
// Acorde de inicio ascendente emblemático (Eb, Bb, Eb, G, Ab, Bb)
const startupChords = [
{ f: 155.56, t: 0.0, d: 1.2, v: 0.04 }, // Eb3
{ f: 233.08, t: 0.15, d: 1.2, v: 0.04 }, // Bb3
{ f: 311.13, t: 0.35, d: 1.4, v: 0.05 }, // Eb4
{ f: 392.00, t: 0.55, d: 1.4, v: 0.05 }, // G4
{ f: 466.16, t: 0.75, d: 1.8, v: 0.06 }, // Bb4
{ f: 622.25, t: 0.95, d: 2.2, v: 0.05 } // Eb5
];
startupChords.forEach(note => {
const startTime = ctx.currentTime + note.t;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(note.f, startTime);
gain.gain.setValueAtTime(0.001, startTime);
gain.gain.linearRampToValueAtTime(note.v, startTime + 0.08);
gain.gain.exponentialRampToValueAtTime(0.0001, startTime + note.d);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(startTime);
osc.stop(startTime + note.d);
});
}
} }

View File

@ -1,5 +1,5 @@
import { Injectable, signal, computed, inject, effect } from '@angular/core'; import { Injectable, signal, computed, inject, effect } from '@angular/core';
import { WindowId, RetroWindow, DesktopIcon, ZenOsTheme, ZenOsConfig, WindowPosition, WindowSize } from '../../domain/models/retro-os.model'; import { WindowId, RetroWindow, DesktopIcon, ZenOsTheme, ZenOsConfig, WindowPosition, WindowSize, OsPowerState } from '../../domain/models/retro-os.model';
import { RetroAudioService } from '../services/retro-audio.service'; import { RetroAudioService } from '../services/retro-audio.service';
import { I18nService } from '../services/i18n.service'; import { I18nService } from '../services/i18n.service';
@ -56,6 +56,7 @@ export class RetroOsStore {
private readonly audio = inject(RetroAudioService); private readonly audio = inject(RetroAudioService);
private readonly i18n = inject(I18nService); private readonly i18n = inject(I18nService);
private readonly _powerState = signal<OsPowerState>('off');
private readonly _windows = signal<Record<WindowId, RetroWindow>>(INITIAL_WINDOWS); private readonly _windows = signal<Record<WindowId, RetroWindow>>(INITIAL_WINDOWS);
private readonly _activeWindowId = signal<WindowId | null>(null); private readonly _activeWindowId = signal<WindowId | null>(null);
private readonly _topZIndex = signal<number>(20); private readonly _topZIndex = signal<number>(20);
@ -64,6 +65,8 @@ export class RetroOsStore {
private readonly _scanlines = signal<boolean>(this.getSavedBoolean('zenos_scanlines', true)); private readonly _scanlines = signal<boolean>(this.getSavedBoolean('zenos_scanlines', true));
private readonly _soundEnabled = signal<boolean>(this.getSavedBoolean('zenos_sound', true)); private readonly _soundEnabled = signal<boolean>(this.getSavedBoolean('zenos_sound', true));
readonly powerState = this._powerState.asReadonly();
readonly desktopIcons = computed<DesktopIcon[]>(() => { readonly desktopIcons = computed<DesktopIcon[]>(() => {
const t = this.i18n.t(); const t = this.i18n.t();
return [ return [
@ -219,6 +222,29 @@ export class RetroOsStore {
}); });
} }
startBootSequence(): void {
if (this._powerState() !== 'off') return;
this._powerState.set('booting');
// Transición directa de barra de carga XP a escritorio
setTimeout(() => {
if (this._powerState() === 'booting') {
this.audio.playStartup();
this._powerState.set('desktop');
}
}, 2400);
}
forceDesktop(): void {
this._powerState.set('desktop');
}
resetToOff(): void {
this._powerState.set('off');
this._windows.set(INITIAL_WINDOWS);
this._activeWindowId.set(null);
}
private applyThemeToDocument(theme: ZenOsTheme): void { private applyThemeToDocument(theme: ZenOsTheme): void {
if (typeof document !== 'undefined' && document.documentElement) { if (typeof document !== 'undefined' && document.documentElement) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);

View File

@ -1,5 +1,7 @@
export type WindowId = 'games' | 'cv' | 'settings' | 'scores'; export type WindowId = 'games' | 'cv' | 'settings' | 'scores';
export type OsPowerState = 'off' | 'booting' | 'desktop';
export type ZenOsTheme = 'zen-dark' | 'retro-amber' | 'matrix-green'; export type ZenOsTheme = 'zen-dark' | 'retro-amber' | 'matrix-green';
export interface WindowPosition { export interface WindowPosition {

View File

@ -106,9 +106,10 @@
.interactive-stage { .interactive-stage {
width: 100%; width: 100%;
padding: var(--space-12) clamp(1rem, 3vw, 2.5rem) var(--space-16) clamp(1rem, 3vw, 2.5rem); padding: var(--space-16) clamp(1rem, 3vw, 2.5rem) var(--space-16) clamp(1rem, 3vw, 2.5rem);
background-color: #121517; background-color: #121517;
background: linear-gradient(180deg, #000000 0%, #121517 var(--space-12)); background: linear-gradient(180deg, #000000 0%, #121517 var(--space-12));
scroll-margin-top: 40px;
} }
.stage-grid { .stage-grid {

View File

@ -0,0 +1,179 @@
:host {
display: block;
position: absolute;
inset: 0;
z-index: 50;
pointer-events: auto;
}
.power-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
user-select: none;
}
/* ==============================================================================
1. PANTALLA APAGADA (CRT STANDBY)
============================================================================== */
.crt-off-screen {
position: absolute;
inset: 0;
background: #000000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.standby-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(18, 21, 27, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.standby-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e69d45;
box-shadow: 0 0 8px #e69d45;
animation: crt-standby-blink 1.8s infinite ease-in-out;
}
.standby-text {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.1em;
color: #a0aab8;
}
@keyframes crt-standby-blink {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
/* ==============================================================================
2. PANTALLA DE INICIO WINDOWS XP (BOOT SCREEN)
============================================================================== */
.xp-boot-screen {
position: absolute;
inset: 0;
background: #000000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.xp-boot-logo {
margin-bottom: 28px;
}
.logo-row {
display: flex;
align-items: center;
gap: 12px;
}
.boot-flag-svg {
width: 44px;
height: 44px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8));
}
.boot-text-brand {
display: flex;
flex-direction: column;
line-height: 1;
}
.brand-ms {
font-family: var(--font-sans);
font-size: 0.6875rem;
color: #ffffff;
letter-spacing: 0.05em;
font-weight: 500;
margin-bottom: 2px;
}
.brand-xp {
display: flex;
align-items: baseline;
gap: 4px;
}
.brand-windows {
font-family: var(--font-sans);
font-size: 1.45rem;
font-weight: 800;
color: #ffffff;
letter-spacing: -0.02em;
}
.brand-xp-badge {
font-family: var(--font-sans);
font-size: 0.875rem;
font-weight: 900;
color: #EB3C00;
letter-spacing: 0.02em;
}
/* Barra de Carga de XP */
.xp-loader-container {
width: 140px;
height: 14px;
background: #000000;
border: 1px solid #5a5a5a;
border-radius: 3px;
padding: 2px;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.9);
overflow: hidden;
position: relative;
}
.xp-loader-track {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.xp-loader-slider {
display: flex;
gap: 2px;
position: absolute;
top: 0;
left: -40px;
height: 100%;
animation: xp-progress-slide 1.4s linear infinite;
}
.xp-loader-slider .block {
width: 9px;
height: 100%;
background: linear-gradient(180deg, #3870e8 0%, #204db5 50%, #153282 100%);
border-radius: 1px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
@keyframes xp-progress-slide {
0% { left: -36px; }
100% { left: 140px; }
}
.boot-footer-note {
position: absolute;
bottom: 12px;
font-family: var(--font-sans);
font-size: 0.5625rem;
color: #666666;
}

View File

@ -0,0 +1,52 @@
<div class="power-overlay" [class.state-off]="powerState() === 'off'"
[class.state-booting]="powerState() === 'booting'" (click)="onScreenClick()">
<!-- 1. Pantalla Apagada (CRT Standby) -->
@if (powerState() === 'off') {
<div class="crt-off-screen">
<div class="standby-indicator">
<span class="standby-dot"></span>
<span class="standby-text">STANDBY // CLICK TO POWER ON</span>
</div>
</div>
}
<!-- 2. Pantalla de Inicio Windows XP (Microsoft Windows XP + Barra de carga) -->
@if (powerState() === 'booting') {
<div class="xp-boot-screen">
<!-- Logo Oficial Minimalista de Windows XP en SVG Nítido -->
<div class="xp-boot-logo">
<div class="logo-row">
<svg viewBox="0 0 88 88" class="boot-flag-svg" aria-hidden="true">
<path d="M4 14C12 10 24 11 32 17C35 20 35 30 35 39C27 34 16 32 4 36V14Z" fill="#EB3C00" />
<path d="M43 19C52 13 65 11 75 13V36C65 33 53 35 43 40V19Z" fill="#46A200" />
<path d="M4 44C16 40 27 42 35 47V70C27 64 16 66 4 71V44Z" fill="#0080FF" />
<path d="M43 48C53 43 65 41 75 44V68C65 65 52 67 43 72V48Z" fill="#FFB700" />
</svg>
<div class="boot-text-brand">
<span class="brand-ms">Microsoft</span>
<div class="brand-xp">
<span class="brand-windows">Windows</span>
<span class="brand-xp-badge">XP</span>
</div>
</div>
</div>
</div>
<!-- Mítica Barra de Progreso XP con los 3 bloques azules deslizantes -->
<div class="xp-loader-container">
<div class="xp-loader-track">
<div class="xp-loader-slider">
<span class="block"></span>
<span class="block"></span>
<span class="block"></span>
</div>
</div>
</div>
<div class="boot-footer-note">
<span>Copyright © Microsoft Corporation</span>
</div>
</div>
}
</div>

View File

@ -0,0 +1,29 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
inject
} from '@angular/core';
import { OsPowerState } from '../../../../../domain/models/retro-os.model';
import { I18nService } from '../../../../../application/services/i18n.service';
@Component({
selector: 'app-retro-power-screen',
standalone: true,
templateUrl: './retro-power-screen.component.html',
styleUrl: './retro-power-screen.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RetroPowerScreenComponent {
readonly powerState = input.required<OsPowerState>();
readonly powerOn = output<void>();
protected readonly i18n = inject(I18nService);
onScreenClick(): void {
if (this.powerState() === 'off') {
this.powerOn.emit();
}
}
}

View File

@ -90,15 +90,11 @@
</div> </div>
</div> </div>
<!-- Pie del Menú Inicio: Cerrar sesión y Apagar --> <!-- Pie del Menú Inicio: Cerrar sesión -->
<footer class="start-footer"> <footer class="start-footer">
<button type="button" class="footer-action-btn" (click)="onSelect()"> <button type="button" class="footer-action-btn" (click)="onSelect()">
<span class="key-icon">🔑</span> <span class="key-icon">🔑</span>
<span>{{ i18n.t().retroMenuLogoff }}</span> <span>{{ i18n.t().retroMenuLogoff }}</span>
</button> </button>
<button type="button" class="footer-action-btn power" (click)="onSelect()">
<span class="power-icon">⏻</span>
<span>{{ i18n.t().retroMenuShutdown }}</span>
</button>
</footer> </footer>
</div> </div>

View File

@ -4,6 +4,11 @@
<!-- 2. Pantalla Windows XP Sobrepuesta en el Área del Tubo (100% Visible e Interactiva) --> <!-- 2. Pantalla Windows XP Sobrepuesta en el Área del Tubo (100% Visible e Interactiva) -->
<div class="monitor-screen-glass"> <div class="monitor-screen-glass">
<!-- Pantalla de Estado de Energía (Apagado / Booting / Welcome / Shutdown) -->
@if (store.powerState() !== 'desktop') {
<app-retro-power-screen [powerState]="store.powerState()" (powerOn)="store.startBootSequence()" />
}
<div class="xp-desktop-canvas"> <div class="xp-desktop-canvas">
<!-- Iconos de Escritorio de Windows XP --> <!-- Iconos de Escritorio de Windows XP -->
<app-retro-desktop-icons [icons]="store.desktopIcons()" <app-retro-desktop-icons [icons]="store.desktopIcons()"

View File

@ -2,9 +2,11 @@ import {
Component, Component,
ChangeDetectionStrategy, ChangeDetectionStrategy,
OnInit, OnInit,
AfterViewInit,
OnDestroy, OnDestroy,
signal, signal,
inject inject,
ElementRef
} from '@angular/core'; } from '@angular/core';
import { RetroOsStore } from '../../../application/stores/retro-os.store'; import { RetroOsStore } from '../../../application/stores/retro-os.store';
import { I18nService } from '../../../application/services/i18n.service'; import { I18nService } from '../../../application/services/i18n.service';
@ -16,6 +18,7 @@ import { RetroCvComponent } from './components/retro-cv/retro-cv.component';
import { RetroScoresComponent } from './components/retro-scores/retro-scores.component'; import { RetroScoresComponent } from './components/retro-scores/retro-scores.component';
import { RetroSettingsComponent } from './components/retro-settings/retro-settings.component'; import { RetroSettingsComponent } from './components/retro-settings/retro-settings.component';
import { RetroStartMenuComponent } from './components/retro-start-menu/retro-start-menu.component'; import { RetroStartMenuComponent } from './components/retro-start-menu/retro-start-menu.component';
import { RetroPowerScreenComponent } from './components/retro-power-screen/retro-power-screen.component';
@Component({ @Component({
selector: 'app-retro-os', selector: 'app-retro-os',
@ -27,20 +30,25 @@ import { RetroStartMenuComponent } from './components/retro-start-menu/retro-sta
RetroCvComponent, RetroCvComponent,
RetroScoresComponent, RetroScoresComponent,
RetroSettingsComponent, RetroSettingsComponent,
RetroStartMenuComponent RetroStartMenuComponent,
RetroPowerScreenComponent
], ],
templateUrl: './retro-os.component.html', templateUrl: './retro-os.component.html',
styleUrl: './retro-os.component.css', styleUrl: './retro-os.component.css',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RetroOsComponent implements OnInit, OnDestroy { export class RetroOsComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly store = inject(RetroOsStore); protected readonly store = inject(RetroOsStore);
protected readonly i18n = inject(I18nService); protected readonly i18n = inject(I18nService);
private readonly elRef = inject(ElementRef);
protected readonly isStartMenuOpen = signal<boolean>(false); protected readonly isStartMenuOpen = signal<boolean>(false);
protected readonly currentTime = signal<string>('12:00'); protected readonly currentTime = signal<string>('12:00');
private clockInterval: ReturnType<typeof setInterval> | null = null; private clockInterval: ReturnType<typeof setInterval> | null = null;
private observer: IntersectionObserver | null = null;
private hasBootedOnce = false;
private readonly timeFormatter = new Intl.DateTimeFormat('es-EC', { private readonly timeFormatter = new Intl.DateTimeFormat('es-EC', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
@ -52,6 +60,27 @@ export class RetroOsComponent implements OnInit, OnDestroy {
this.clockInterval = setInterval(() => this.updateClock(), 1000); this.clockInterval = setInterval(() => this.updateClock(), 1000);
} }
ngAfterViewInit(): void {
if (typeof window !== 'undefined' && 'IntersectionObserver' in window) {
this.observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
// Al entrar visible en pantalla tras bajar del Hero 3D
if (entry.isIntersecting && entry.intersectionRatio >= 0.25) {
if (this.store.powerState() === 'off' && !this.hasBootedOnce) {
this.hasBootedOnce = true;
this.store.startBootSequence();
// Desconectar observer: una vez encendido se queda siempre cargado
this.observer?.disconnect();
}
}
},
{ threshold: [0.25] }
);
this.observer.observe(this.elRef.nativeElement);
}
}
private updateClock(): void { private updateClock(): void {
this.currentTime.set(this.timeFormatter.format(new Date())); this.currentTime.set(this.timeFormatter.format(new Date()));
} }
@ -75,5 +104,8 @@ export class RetroOsComponent implements OnInit, OnDestroy {
if (this.clockInterval !== null) { if (this.clockInterval !== null) {
clearInterval(this.clockInterval); clearInterval(this.clockInterval);
} }
if (this.observer) {
this.observer.disconnect();
}
} }
} }