Add certificate template, shield logo, audio tracks, and enhance game UI/UX

This commit is contained in:
Vishva
2026-09-26 18:07:22 +05:30
parent db6840b870
commit 0f555fdb17
13 changed files with 2603 additions and 787 deletions
+259
View File
@@ -25,6 +25,22 @@ class SoundEngine {
this.activeGrinderNode = null;
this.activeLiquidNode = null;
// Dedicated Countdown Tick Sound Clip (User uploaded custom audio)
try {
this.countdownTickAudio = new Audio('audio/countdown_tick.mp3');
this.countdownTickAudio.preload = 'auto';
} catch (e) {
this.countdownTickAudio = null;
}
// Dedicated Top Timer Last 10-Second Warning Sound Clip (User uploaded custom audio)
try {
this.timerLast10sAudio = new Audio('audio/timer_last_10s.wav');
this.timerLast10sAudio.preload = 'auto';
} catch (e) {
this.timerLast10sAudio = null;
}
this.isInitialized = false;
}
@@ -91,6 +107,12 @@ class SoundEngine {
if (this.masterGain && this.ctx) {
this.masterGain.gain.setTargetAtTime(this.isMuted ? 0 : 1.0, this.ctx.currentTime, 0.05);
}
if (this.countdownTickAudio) {
this.countdownTickAudio.volume = this.isMuted ? 0 : this.sfxVolume;
}
if (this.timerLast10sAudio) {
this.timerLast10sAudio.volume = this.isMuted ? 0 : this.sfxVolume;
}
}
setMusicVolume(val) {
@@ -105,6 +127,94 @@ class SoundEngine {
if (this.sfxGain && this.ctx) {
this.sfxGain.gain.setTargetAtTime(this.sfxVolume, this.ctx.currentTime, 0.05);
}
if (this.countdownTickAudio) {
this.countdownTickAudio.volume = this.isMuted ? 0 : this.sfxVolume;
}
if (this.timerLast10sAudio) {
this.timerLast10sAudio.volume = this.isMuted ? 0 : this.sfxVolume;
}
}
playCountdownTickAudio() {
this.ensureContext();
if (this.isMuted) return;
if (this.countdownTickAudio) {
try {
const sound = this.countdownTickAudio.cloneNode();
sound.volume = Math.max(0, Math.min(1, this.sfxVolume));
sound.play().catch(err => {
console.warn("Countdown tick audio playback note:", err);
});
} catch (e) {
try {
this.countdownTickAudio.currentTime = 0;
this.countdownTickAudio.volume = Math.max(0, Math.min(1, this.sfxVolume));
this.countdownTickAudio.play().catch(() => {});
} catch (err) {}
}
}
}
/**
* Normal Clock Tick for the first 20 seconds (30s down to 11s)
*/
playTimerTick() {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
// Crisp high-tech clock tick matching 1st beat
osc.type = 'triangle';
osc.frequency.setValueAtTime(1100, t);
osc.frequency.exponentialRampToValueAtTime(320, t + 0.035);
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1200, t);
filter.Q.setValueAtTime(3.5, t);
gain.gain.setValueAtTime(0.22, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.045);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.sfxGain);
osc.start(t);
osc.stop(t + 0.05);
}
/**
* Dedicated Last 10 Seconds Audio Track (User uploaded custom audio)
*/
playTimerLast10sAudio() {
this.ensureContext();
if (this.isMuted) return;
if (this.timerLast10sAudio) {
try {
this.timerLast10sAudio.currentTime = 0;
this.timerLast10sAudio.volume = Math.max(0, Math.min(1, this.sfxVolume));
this.timerLast10sAudio.play().catch(err => {
console.warn("Timer last 10s audio playback note:", err);
});
} catch (e) {
console.warn("Error playing timer last 10s audio:", e);
}
}
}
stopTimerLast10sAudio() {
if (this.timerLast10sAudio) {
try {
this.timerLast10sAudio.pause();
this.timerLast10sAudio.currentTime = 0;
} catch (e) {}
}
}
setAmbientEnabled(enabled) {
@@ -520,6 +630,155 @@ class SoundEngine {
});
}
/**
* Timer Warning Tick & Urgent Countdown Beep SFX
*/
playTimerWarningTick(isUrgent = false) {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
if (isUrgent) {
// Rapid, tense dual-tone hazard emergency beep
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(880, t);
osc.frequency.setValueAtTime(700, t + 0.06);
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1400, t);
filter.Q.setValueAtTime(4.0, t);
gain.gain.setValueAtTime(0.22, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.14);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.sfxGain);
osc.start(t);
osc.stop(t + 0.15);
} else {
// High-tech warning clock tick
osc.type = 'sine';
osc.frequency.setValueAtTime(560, t);
osc.frequency.exponentialRampToValueAtTime(420, t + 0.05);
gain.gain.setValueAtTime(0.12, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.08);
osc.connect(gain);
gain.connect(this.sfxGain);
osc.start(t);
osc.stop(t + 0.09);
}
}
/**
* Industrial Emergency Hazard Alarm Sound (Time Expired / Hazard Warning)
*/
playTimerHazardAlarm() {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
// Industrial emergency hazard klaxon / buzzer burst
[0, 0.18, 0.36].forEach(offset => {
const time = t + offset;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(620, time);
osc.frequency.linearRampToValueAtTime(380, time + 0.14);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(2000, time);
gain.gain.setValueAtTime(0.26, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.15);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.sfxGain);
osc.start(time);
osc.stop(time + 0.16);
});
}
/**
* Central Screen Start Countdown Beep (3, 2, 1)
*/
playCountdownBeep(num) {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
// Pitch rises: 3 (520Hz) -> 2 (680Hz) -> 1 (880Hz)
const baseFreq = num === 3 ? 520 : (num === 2 ? 680 : 880);
osc.type = 'triangle';
osc.frequency.setValueAtTime(baseFreq, t);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(baseFreq * 2, t);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(2400, t);
gain.gain.setValueAtTime(0.24, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
osc.connect(filter);
osc2.connect(filter);
filter.connect(gain);
gain.connect(this.sfxGain);
osc.start(t);
osc2.start(t);
osc.stop(t + 0.3);
osc2.stop(t + 0.3);
}
/**
* Central Screen Countdown Launch / GO! Chime
*/
playCountdownGo() {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
const t = this.ctx.currentTime;
// Energetic ascending triad (C5 -> E5 -> G5)
[523.25, 659.25, 783.99, 1046.50].forEach((freq, idx) => {
const time = t + (idx * 0.05);
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, time);
gain.gain.setValueAtTime(0.22, time);
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.45);
osc.connect(gain);
gain.connect(this.sfxGain);
osc.start(time);
osc.stop(time + 0.5);
});
}
playCriticalStopAlert() {
this.ensureContext();
if (!this.ctx || this.isMuted) return;
+352 -107
View File
@@ -15,6 +15,10 @@ class SafetyGame {
this.avatar = null;
this.particles = null;
// 30-Second Countdown Timer State
this.timerSeconds = 30;
this.timerInterval = null;
// DOM Elements Cache
this.dom = {
screenStart: document.getElementById('screen-start'),
@@ -34,10 +38,16 @@ class SafetyGame {
// HUD
hudScore: document.getElementById('hud-score'),
hudStars: document.getElementById('hud-stars'),
hudTimerBox: document.getElementById('hud-timer-box'),
hudTimerVal: document.getElementById('hud-timer-val'),
countdownOverlay: document.getElementById('countdown-overlay'),
countdownDigit: document.getElementById('countdown-digit'),
countdownSubTip: document.getElementById('countdown-sub-tip'),
stepPill1: document.getElementById('step-pill-1'),
stepPill2: document.getElementById('step-pill-2'),
btnSoundToggle: document.getElementById('btn-sound-toggle'),
btnMusicToggle: document.getElementById('btn-music-toggle'),
btnFullscreenToggle: document.getElementById('btn-fullscreen-toggle'),
btnAudioSettings: document.getElementById('btn-audio-settings'),
// Scene Info & Hazards
@@ -104,14 +114,15 @@ class SafetyGame {
}
bindEvents() {
// Start Game from Intro (Instantaneous Transition, Zero Delay)
// Start Game from Intro (Instantaneous Transition with 3,2,1,GO Central Countdown)
if (this.dom.btnStartGame) {
this.dom.btnStartGame.addEventListener('click', () => {
window.soundEngine.ensureContext();
window.soundEngine.playStartIntroSting();
window.soundEngine.startBackgroundMusic();
this.loadScenario(0);
this.loadScenario(0, false);
this.switchScreen('game');
this.triggerStartCountdown(() => this.startTimer());
});
}
@@ -146,7 +157,8 @@ class SafetyGame {
this.particles.stopMode();
if (this.currentScenarioIndex < SCENARIO_DATA.length - 1) {
this.loadScenario(this.currentScenarioIndex + 1);
this.loadScenario(this.currentScenarioIndex + 1, false);
this.triggerStartCountdown(() => this.startTimer());
} else {
this.showFinalSummary();
}
@@ -161,17 +173,31 @@ class SafetyGame {
this.scenarioCompleted = [false, false];
this.switchScreen('game');
window.soundEngine.startBackgroundMusic();
this.loadScenario(0);
this.loadScenario(0, false);
this.triggerStartCountdown(() => this.startTimer());
});
}
// Print Certificate
// Certificate Viewer Modal & Print Handlers
if (this.dom.btnPrintCertificate) {
this.dom.btnPrintCertificate.addEventListener('click', () => {
const dateEl = document.getElementById('cert-date-val');
if (dateEl) {
dateEl.textContent = new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
}
window.soundEngine.playButtonClick();
const certModal = document.getElementById('modal-certificate');
if (certModal) certModal.classList.add('active');
});
}
const btnCloseCert = document.getElementById('btn-close-cert-modal');
if (btnCloseCert) {
btnCloseCert.addEventListener('click', () => {
const certModal = document.getElementById('modal-certificate');
if (certModal) certModal.classList.remove('active');
});
}
const btnPrintAction = document.getElementById('btn-print-cert-action');
if (btnPrintAction) {
btnPrintAction.addEventListener('click', () => {
window.print();
});
}
@@ -243,14 +269,31 @@ class SafetyGame {
});
}
if (this.dom.toggleAmbientHum) {
this.dom.toggleAmbientHum.addEventListener('change', (e) => {
window.soundEngine.setAmbientEnabled(e.target.checked);
// Fullscreen Mode Toggle
if (this.dom.btnFullscreenToggle) {
this.dom.btnFullscreenToggle.addEventListener('click', () => {
this.toggleFullscreen();
});
}
// Keyboard Shortcuts (1-9 and 0 for 10 Locker items, Space for Start Task, Esc to close modals)
const onFullscreenChange = () => {
const isFull = !!(document.fullscreenElement || document.webkitFullscreenElement);
document.body.classList.toggle('fullscreen-mode', isFull);
document.documentElement.classList.toggle('fullscreen-mode', isFull);
this.updateFullscreenIcon();
};
document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
document.addEventListener('mozfullscreenchange', onFullscreenChange);
document.addEventListener('MSFullscreenChange', onFullscreenChange);
// Keyboard Shortcuts (1-9 and 0 for 10 Locker items, Space for Start Task, F for Fullscreen, Esc to close modals)
window.addEventListener('keydown', (e) => {
// Don't trigger game shortcuts if typing in an editable field
if (e.target && (e.target.tagName === 'INPUT' || e.target.isContentEditable)) {
return;
}
const num = parseInt(e.key);
if (!isNaN(num)) {
const cards = document.querySelectorAll('.ppe-item-card');
@@ -260,6 +303,12 @@ class SafetyGame {
}
}
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
this.toggleFullscreen();
return;
}
if ((e.code === 'Space' || e.code === 'Enter') && this.dom.screenStart.classList.contains('active')) {
e.preventDefault();
if (this.dom.btnStartGame) this.dom.btnStartGame.click();
@@ -276,6 +325,8 @@ class SafetyGame {
if (e.key === 'Escape') {
if (this.dom.modalAudio && this.dom.modalAudio.classList.contains('active')) {
this.dom.modalAudio.classList.remove('active');
} else if (document.getElementById('modal-certificate')?.classList.contains('active')) {
document.getElementById('modal-certificate').classList.remove('active');
} else if (this.dom.consequenceOverlay.classList.contains('active')) {
this.dismissConsequenceModal();
}
@@ -283,17 +334,206 @@ class SafetyGame {
});
}
toggleFullscreen() {
window.soundEngine.playButtonClick();
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
const elem = document.documentElement;
if (elem.requestFullscreen) {
elem.requestFullscreen().catch(() => {});
} else if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
}
}
updateFullscreenIcon() {
const isFull = !!(document.fullscreenElement || document.webkitFullscreenElement);
const iconSpan = document.getElementById('fullscreen-icon');
if (iconSpan) {
iconSpan.textContent = isFull ? '⤡' : '⛶';
}
if (this.dom.btnFullscreenToggle) {
this.dom.btnFullscreenToggle.title = isFull ? 'Exit Fullscreen (F)' : 'Toggle Fullscreen (F)';
if (isFull) {
this.dom.btnFullscreenToggle.classList.add('fullscreen-active');
} else {
this.dom.btnFullscreenToggle.classList.remove('fullscreen-active');
}
}
}
switchScreen(screenName) {
this.dom.screenStart.classList.remove('active');
this.dom.screenGame.classList.remove('active');
this.dom.screenSummary.classList.remove('active');
if (screenName === 'start') this.dom.screenStart.classList.add('active');
if (screenName === 'start') {
this.stopTimer();
this.dom.screenStart.classList.add('active');
}
if (screenName === 'game') this.dom.screenGame.classList.add('active');
if (screenName === 'summary') this.dom.screenSummary.classList.add('active');
if (screenName === 'summary') {
this.stopTimer();
this.dom.screenSummary.classList.add('active');
}
}
loadScenario(index) {
// =========================================================================
// 30-SECOND COUNTDOWN TIMER CONTROLLER
// =========================================================================
startTimer() {
this.stopTimer();
this.timerSeconds = 30;
this.updateTimerDisplay();
this.timerInterval = setInterval(() => {
if (this.timerSeconds > 0) {
this.timerSeconds--;
this.updateTimerDisplay();
if (this.timerSeconds > 10) {
// Normal clock tick sound from 30s to 11s (first 20 seconds)
window.soundEngine.playTimerTick();
} else if (this.timerSeconds === 10) {
// Trigger dedicated audio track for the final 10 seconds
window.soundEngine.playTimerLast10sAudio();
}
} else {
this.stopTimer();
// Time expired: execute safety check
if (this.dom.screenGame.classList.contains('active') &&
!this.dom.consequenceOverlay.classList.contains('active') &&
!this.dom.successOverlay.classList.contains('active')) {
this.executeSafetyTest();
}
}
}, 1000);
}
stopTimer() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
window.soundEngine.stopTimerLast10sAudio();
}
pauseTimer() {
this.stopTimer();
}
resumeTimer() {
if (this.timerSeconds > 0 && !this.timerInterval) {
this.timerInterval = setInterval(() => {
if (this.timerSeconds > 0) {
this.timerSeconds--;
this.updateTimerDisplay();
if (this.timerSeconds > 10) {
window.soundEngine.playTimerTick();
} else if (this.timerSeconds === 10) {
window.soundEngine.playTimerLast10sAudio();
}
} else {
this.stopTimer();
if (this.dom.screenGame.classList.contains('active') &&
!this.dom.consequenceOverlay.classList.contains('active') &&
!this.dom.successOverlay.classList.contains('active')) {
this.executeSafetyTest();
}
}
}, 1000);
}
}
updateTimerDisplay() {
if (!this.dom.hudTimerVal) return;
const formatted = `00:${this.timerSeconds.toString().padStart(2, '0')}`;
this.dom.hudTimerVal.textContent = formatted;
if (this.dom.hudTimerBox) {
this.dom.hudTimerBox.classList.remove('timer-warning', 'timer-danger');
if (this.timerSeconds <= 5) {
this.dom.hudTimerBox.classList.add('timer-danger');
} else if (this.timerSeconds <= 10) {
this.dom.hudTimerBox.classList.add('timer-warning');
}
}
}
// =========================================================================
// CENTRAL SCREEN START COUNTDOWN (3, 2, 1, GO!)
// =========================================================================
triggerStartCountdown(onComplete) {
if (!this.dom.countdownOverlay) {
if (onComplete) onComplete();
return;
}
this.stopTimer();
this.timerSeconds = 30;
this.updateTimerDisplay();
this.dom.countdownOverlay.classList.add('active');
const digitEl = this.dom.countdownDigit;
const subTipEl = this.dom.countdownSubTip;
const steps = [
{ text: '3', tip: 'PREPARE YOUR PPE', isGo: false, duration: 1000 },
{ text: '2', tip: 'IDENTIFY HAZARDS', isGo: false, duration: 1000 },
{ text: '1', tip: 'EQUIP WORKER', isGo: false, duration: 1000 },
{ text: 'GO!', tip: '30s TIMER STARTED', isGo: true, duration: 750 }
];
let currentStep = 0;
const playNextStep = () => {
if (currentStep < steps.length) {
const item = steps[currentStep];
if (digitEl) {
digitEl.textContent = item.text;
digitEl.className = 'countdown-digit';
if (item.isGo) {
digitEl.classList.add('go-text');
}
// Force CSS reflow to re-trigger pop animation
void digitEl.offsetWidth;
digitEl.classList.add('pop');
}
if (subTipEl) {
subTipEl.textContent = item.tip;
}
// Play uploaded countdown tick audio for 3, 2, 1 every second
if (item.isGo) {
window.soundEngine.playCountdownGo();
} else {
window.soundEngine.playCountdownTickAudio();
}
currentStep++;
setTimeout(playNextStep, item.duration);
} else {
// Complete countdown & fade out
this.dom.countdownOverlay.classList.remove('active');
if (onComplete) onComplete();
}
};
playNextStep();
}
loadScenario(index, autoStartTimer = true) {
window.soundEngine.stopAllTaskSFX();
this.currentScenarioIndex = index;
this.currentScenario = SCENARIO_DATA[index];
@@ -303,6 +543,14 @@ class SafetyGame {
this.particles.clear();
this.particles.stopMode();
if (autoStartTimer) {
this.startTimer();
} else {
this.stopTimer();
this.timerSeconds = 30;
this.updateTimerDisplay();
}
// Update Stepper HUD
if (this.dom.stepPill1 && this.dom.stepPill2) {
this.dom.stepPill1.className = index === 0 ? 'step-pill active' : (this.scenarioCompleted[0] ? 'step-pill completed' : 'step-pill');
@@ -483,9 +731,6 @@ class SafetyGame {
return `
<div class="ppe-item-card ${isEquipped ? 'equipped' : ''}" data-item-id="${item.id}" data-slot="${item.slot}" title="${item.spec}">
<div class="item-toggle-indicator">
<span class="radio-check-mark">${isEquipped ? '✓' : ''}</span>
</div>
<div class="item-icon-box">${iconContent}</div>
<span class="item-name">${item.name}</span>
</div>
@@ -610,91 +855,63 @@ class SafetyGame {
}
handleSafeSuccess() {
// Start Task Simulation SFX & Particles
if (this.currentScenario.id === 'grinding') {
window.soundEngine.startGrinderSFX();
this.particles.startMode('sparks');
} else {
window.soundEngine.startLiquidTransferSFX();
this.particles.startMode('chemical');
this.stopTimer();
this.particles.clear();
this.particles.stopMode();
window.soundEngine.stopAllTaskSFX();
window.soundEngine.playSuccessChime();
window.soundEngine.playSafetyStarSound();
// Award points (100 on first try, 80 on retry)
const points = this.scenarioFirstAttempt ? 100 : 80;
this.score += points;
this.scenarioCompleted[this.currentScenarioIndex] = true;
this.updateHUD();
// Show Success Modal Overlay
if (this.dom.successTitle) {
if (this.currentScenario.id === 'grinding') {
this.dom.successTitle.innerHTML = `<span class="title-green">SAFE</span> <span class="title-white">TO START</span>`;
} else {
this.dom.successTitle.innerHTML = `<span class="title-green">SAFE</span> <span class="title-white">TO PROCEED</span>`;
}
}
if (this.dom.successDesc) this.dom.successDesc.textContent = this.currentScenario.successMessage.body;
if (this.dom.awardedPointsText) this.dom.awardedPointsText.textContent = `+${points} Safety Points`;
if (this.dom.btnNextScenarioText) {
if (this.currentScenarioIndex === SCENARIO_DATA.length - 1) {
this.dom.btnNextScenarioText.textContent = 'VIEW FINAL RESULTS ➔';
} else {
this.dom.btnNextScenarioText.textContent = 'NEXT SCENARIO ➔';
}
}
// Play Success Chime & Star sound after short operational run
setTimeout(() => {
window.soundEngine.stopAllTaskSFX();
this.particles.stopMode();
window.soundEngine.playSuccessChime();
window.soundEngine.playSafetyStarSound();
// Award points (100 on first try, 80 on retry)
const points = this.scenarioFirstAttempt ? 100 : 80;
this.score += points;
this.scenarioCompleted[this.currentScenarioIndex] = true;
this.updateHUD();
// Show Success Modal Overlay
if (this.dom.successTitle) this.dom.successTitle.textContent = this.currentScenario.successMessage.title;
if (this.dom.successDesc) this.dom.successDesc.textContent = this.currentScenario.successMessage.body;
if (this.dom.awardedPointsText) this.dom.awardedPointsText.textContent = `+${points} Safety Points ⭐`;
if (this.dom.btnNextScenarioText) {
if (this.currentScenarioIndex === SCENARIO_DATA.length - 1) {
this.dom.btnNextScenarioText.textContent = 'VIEW FINAL RESULTS ➔';
} else {
this.dom.btnNextScenarioText.textContent = 'NEXT SCENARIO ➔';
}
}
if (this.dom.successOverlay) this.dom.successOverlay.classList.add('active');
}, 1200);
if (this.dom.successOverlay) this.dom.successOverlay.classList.add('active');
}
handleUnsafeConsequence(allMissingChecks, allEquippedDistractors) {
this.stopTimer();
this.scenarioFirstAttempt = false;
this.particles.clear();
this.particles.stopMode();
// Start task machine momentarily
if (this.currentScenario.id === 'grinding') {
window.soundEngine.startGrinderSFX();
this.particles.startMode('sparks');
} else {
window.soundEngine.startLiquidTransferSFX();
this.particles.startMode('chemical');
}
// Determine target position for incoming projectile
const avatarCoords = this.avatar.getAvatarCenterCoordinates();
const stageWidth = this.dom.sceneStage ? this.dom.sceneStage.clientWidth : 600;
const stageHeight = this.dom.sceneStage ? this.dom.sceneStage.clientHeight : 450;
const originX = stageWidth * 0.72;
const originY = stageHeight * 0.65;
const hasEyeFaceIssue = allMissingChecks.some(chk => chk.slot === 'eyes' || chk.slot === 'head');
const targetX = hasEyeFaceIssue ? avatarCoords.eyeX : avatarCoords.handX;
const targetY = hasEyeFaceIssue ? avatarCoords.eyeY : avatarCoords.handY;
setTimeout(() => {
window.soundEngine.playWhooshImpact();
this.particles.spawnHazardProjectile(
this.currentScenario.id === 'grinding' ? 'spark' : 'chemical_drop',
originX, originY, targetX, targetY
);
}, 400);
// Store consequence context for freeze overlay
this.activeConsequenceData = { allMissingChecks, allEquippedDistractors };
this.handleFreezeFrameAlert();
}
handleFreezeFrameAlert(projectile) {
// 1. Freeze particle engine & stop machine motors
this.particles.setFrozen(true);
this.particles.clear();
this.particles.setFrozen(false);
this.particles.stopMode();
window.soundEngine.stopGrinderSFX();
window.soundEngine.stopLiquidTransferSFX();
// 2. Duck background music
// Duck background music
window.soundEngine.duckMusic(true);
// 3. Play warning tones
// Play warning tones
window.soundEngine.playWarningTone();
window.soundEngine.playCriticalStopAlert();
@@ -712,22 +929,25 @@ class SafetyGame {
// 4. Update Header & Badges
if (this.dom.hazardFlasherBadge) {
this.dom.hazardFlasherBadge.textContent = `⚠️ STOP! ${totalIssues} SAFETY ${totalIssues === 1 ? 'HAZARD' : 'HAZARDS'} DETECTED`;
this.dom.hazardFlasherBadge.innerHTML = `
<span class="flasher-icon">⚠️</span>
<span class="flasher-text">STOP! ${totalIssues} SAFETY ${totalIssues === 1 ? 'HAZARD' : 'HAZARDS'} DETECTED</span>
`;
}
if (this.dom.missingCountNum) this.dom.missingCountNum.textContent = totalIssues;
if (totalIssues === 1) {
if (allEquippedDistractors.length > 0) {
const d = allEquippedDistractors[0];
if (this.dom.consequenceTitle) this.dom.consequenceTitle.textContent = "Incorrect / Ineffective PPE Detected";
if (this.dom.consequenceTitle) this.dom.consequenceTitle.innerHTML = `<span class="title-red">INCORRECT</span> <span class="title-white">PPE DETECTED</span>`;
if (this.dom.consequenceDesc) this.dom.consequenceDesc.textContent = d.rejectReason;
} else {
const m = allMissingChecks[0];
if (this.dom.consequenceTitle) this.dom.consequenceTitle.textContent = m.consequenceTitle;
if (this.dom.consequenceTitle) this.dom.consequenceTitle.innerHTML = `<span class="title-red">CRITICAL</span> <span class="title-white">HAZARD DETECTED</span>`;
if (this.dom.consequenceDesc) this.dom.consequenceDesc.textContent = m.consequenceDesc;
}
} else {
if (this.dom.consequenceTitle) this.dom.consequenceTitle.textContent = "Critical Protection Gaps Detected";
if (this.dom.consequenceTitle) this.dom.consequenceTitle.innerHTML = `<span class="title-red">CRITICAL</span> <span class="title-white">PROTECTION GAPS DETECTED</span>`;
if (this.dom.consequenceDesc) this.dom.consequenceDesc.textContent = "Operating without complete PPE exposes multiple body zones to direct, severe workplace injuries. All required safety equipment must be equipped before starting.";
}
@@ -735,31 +955,49 @@ class SafetyGame {
let cardsHtml = '';
allMissingChecks.forEach(chk => {
const icon = slotIcons[chk.slot] || '⚠️';
const reqItem = this.currentScenario.lockerItems.find(it => it.id === chk.requiredItem);
const iconImgHtml = reqItem && reqItem.iconImg
? `<img src="${reqItem.iconImg}" alt="${reqItem.name}" class="hazard-card-ppe-img">`
: `<span class="hazard-card-fallback-icon">${slotIcons[chk.slot] || '⚠️'}</span>`;
const slotCap = chk.slot ? chk.slot.charAt(0).toUpperCase() + chk.slot.slice(1) : 'Body';
cardsHtml += `
<div class="hazard-card-item">
<div class="card-icon">${icon}</div>
<div class="card-body">
<div class="card-header-line">
<span class="card-item-name">${chk.missingLabel}</span>
<span class="card-slot-badge">${chk.slot} - Missing</span>
<div class="cyber-hazard-card">
<div class="hazard-card-icon-box">
${iconImgHtml}
</div>
<div class="hazard-card-content">
<div class="hazard-card-top-row">
<span class="hazard-card-name">${chk.missingLabel || (reqItem ? reqItem.name : 'Missing PPE')}</span>
<span class="hazard-card-badge missing-badge">
<span class="badge-exclamation">!</span> ${slotCap} - Missing
</span>
</div>
<span class="card-hazard-text">${chk.consequenceDesc}</span>
<p class="hazard-card-desc">${chk.consequenceDesc}</p>
</div>
</div>
`;
});
allEquippedDistractors.forEach(dist => {
const iconImgHtml = dist.iconImg
? `<img src="${dist.iconImg}" alt="${dist.name}" class="hazard-card-ppe-img">`
: `<span class="hazard-card-fallback-icon">🚫</span>`;
cardsHtml += `
<div class="hazard-card-item distractor-card">
<div class="card-icon">🚫</div>
<div class="card-body">
<div class="card-header-line">
<span class="card-item-name">REMOVE: ${dist.name}</span>
<span class="card-slot-badge">Unsafe Gear</span>
<div class="cyber-hazard-card distractor-card">
<div class="hazard-card-icon-box">
${iconImgHtml}
</div>
<div class="hazard-card-content">
<div class="hazard-card-top-row">
<span class="hazard-card-name">REMOVE: ${dist.name}</span>
<span class="hazard-card-badge distractor-badge">
<span class="badge-exclamation">!</span> Unsafe Gear
</span>
</div>
<span class="card-hazard-text">${dist.rejectReason}</span>
<p class="hazard-card-desc">${dist.rejectReason}</p>
</div>
</div>
`;
@@ -801,6 +1039,13 @@ class SafetyGame {
const rows = this.dom.coverageGrid.querySelectorAll('.status-row-item');
rows.forEach(row => row.classList.remove('alert-missing'));
}
if (this.timerSeconds <= 0) {
this.timerSeconds = 15; // Extra 15s to fix PPE
this.resumeTimer();
} else {
this.resumeTimer();
}
}
// =========================================================================
+4 -49
View File
@@ -52,62 +52,17 @@ class ParticleEngine {
this.clear();
}
// Spawn near-miss hazardous particle / projectile flying at avatar
// Particle Spawning (Disabled for clean direct feedback)
spawnHazardProjectile(type, startX, startY, targetX, targetY) {
const dx = targetX - startX;
const dy = targetY - startY;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = 14;
this.projectiles.push({
type: type, // 'spark' or 'chemical_drop'
x: startX,
y: startY,
vx: (dx / dist) * speed,
vy: (dy / dist) * speed,
targetX: targetX,
targetY: targetY,
size: type === 'spark' ? 7 : 12,
color: type === 'spark' ? '#ff3b30' : '#00f0ff',
trail: [],
reached: false
});
// Disabled
}
spawnSparks(originX, originY, count = 12) {
for (let i = 0; i < count; i++) {
const angle = (Math.PI * 0.8) + (Math.random() * 0.9 - 0.45); // Directed left-upward arc
const speed = Math.random() * 12 + 6;
this.particles.push({
type: 'spark',
x: originX,
y: originY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - (Math.random() * 4),
gravity: 0.35,
life: 1.0,
decay: Math.random() * 0.03 + 0.02,
size: Math.random() * 3 + 1.5,
color: Math.random() > 0.3 ? '#ffdd55' : '#ff5511'
});
}
// Disabled
}
spawnChemicalFlow(originX, originY) {
for (let i = 0; i < 4; i++) {
this.particles.push({
type: 'chemical',
x: originX + (Math.random() * 8 - 4),
y: originY,
vx: -(Math.random() * 2 + 1),
vy: Math.random() * 4 + 2,
gravity: 0.25,
life: 1.0,
decay: 0.02,
size: Math.random() * 4 + 3,
color: '#10b981'
});
}
// Disabled
}
spawnConfetti() {