feat: implement Windows XP-style boot sequence with power state management and startup audio
This commit is contained in:
parent
78497c0e31
commit
75e8d8e0b5
|
|
@ -101,4 +101,36 @@ export class RetroAudioService {
|
|||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { I18nService } from '../services/i18n.service';
|
||||
|
||||
|
|
@ -56,6 +56,7 @@ export class RetroOsStore {
|
|||
private readonly audio = inject(RetroAudioService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
private readonly _powerState = signal<OsPowerState>('off');
|
||||
private readonly _windows = signal<Record<WindowId, RetroWindow>>(INITIAL_WINDOWS);
|
||||
private readonly _activeWindowId = signal<WindowId | null>(null);
|
||||
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 _soundEnabled = signal<boolean>(this.getSavedBoolean('zenos_sound', true));
|
||||
|
||||
readonly powerState = this._powerState.asReadonly();
|
||||
|
||||
readonly desktopIcons = computed<DesktopIcon[]>(() => {
|
||||
const t = this.i18n.t();
|
||||
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 {
|
||||
if (typeof document !== 'undefined' && document.documentElement) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export type WindowId = 'games' | 'cv' | 'settings' | 'scores';
|
||||
|
||||
export type OsPowerState = 'off' | 'booting' | 'desktop';
|
||||
|
||||
export type ZenOsTheme = 'zen-dark' | 'retro-amber' | 'matrix-green';
|
||||
|
||||
export interface WindowPosition {
|
||||
|
|
|
|||
|
|
@ -106,9 +106,10 @@
|
|||
|
||||
.interactive-stage {
|
||||
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: linear-gradient(180deg, #000000 0%, #121517 var(--space-12));
|
||||
scroll-margin-top: 40px;
|
||||
}
|
||||
|
||||
.stage-grid {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,15 +90,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pie del Menú Inicio: Cerrar sesión y Apagar -->
|
||||
<!-- Pie del Menú Inicio: Cerrar sesión -->
|
||||
<footer class="start-footer">
|
||||
<button type="button" class="footer-action-btn" (click)="onSelect()">
|
||||
<span class="key-icon">🔑</span>
|
||||
<span>{{ i18n.t().retroMenuLogoff }}</span>
|
||||
</button>
|
||||
<button type="button" class="footer-action-btn power" (click)="onSelect()">
|
||||
<span class="power-icon">⏻</span>
|
||||
<span>{{ i18n.t().retroMenuShutdown }}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
|
||||
<!-- 2. Pantalla Windows XP Sobrepuesta en el Área del Tubo (100% Visible e Interactiva) -->
|
||||
<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">
|
||||
<!-- Iconos de Escritorio de Windows XP -->
|
||||
<app-retro-desktop-icons [icons]="store.desktopIcons()"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import {
|
|||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
signal,
|
||||
inject
|
||||
inject,
|
||||
ElementRef
|
||||
} from '@angular/core';
|
||||
import { RetroOsStore } from '../../../application/stores/retro-os.store';
|
||||
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 { RetroSettingsComponent } from './components/retro-settings/retro-settings.component';
|
||||
import { RetroStartMenuComponent } from './components/retro-start-menu/retro-start-menu.component';
|
||||
import { RetroPowerScreenComponent } from './components/retro-power-screen/retro-power-screen.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-retro-os',
|
||||
|
|
@ -27,20 +30,25 @@ import { RetroStartMenuComponent } from './components/retro-start-menu/retro-sta
|
|||
RetroCvComponent,
|
||||
RetroScoresComponent,
|
||||
RetroSettingsComponent,
|
||||
RetroStartMenuComponent
|
||||
RetroStartMenuComponent,
|
||||
RetroPowerScreenComponent
|
||||
],
|
||||
templateUrl: './retro-os.component.html',
|
||||
styleUrl: './retro-os.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RetroOsComponent implements OnInit, OnDestroy {
|
||||
export class RetroOsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
protected readonly store = inject(RetroOsStore);
|
||||
protected readonly i18n = inject(I18nService);
|
||||
private readonly elRef = inject(ElementRef);
|
||||
|
||||
protected readonly isStartMenuOpen = signal<boolean>(false);
|
||||
protected readonly currentTime = signal<string>('12:00');
|
||||
|
||||
private clockInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private observer: IntersectionObserver | null = null;
|
||||
private hasBootedOnce = false;
|
||||
|
||||
private readonly timeFormatter = new Intl.DateTimeFormat('es-EC', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
|
|
@ -52,6 +60,27 @@ export class RetroOsComponent implements OnInit, OnDestroy {
|
|||
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 {
|
||||
this.currentTime.set(this.timeFormatter.format(new Date()));
|
||||
}
|
||||
|
|
@ -75,5 +104,8 @@ export class RetroOsComponent implements OnInit, OnDestroy {
|
|||
if (this.clockInterval !== null) {
|
||||
clearInterval(this.clockInterval);
|
||||
}
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue