Files
game_development_workshop/js/game.js
T

1088 lines
41 KiB
JavaScript

/**
* PPE SAFETY CHALLENGE - CORE GAME ENGINE & STATE CONTROLLER
* "Choose Right. Work Safe."
*/
class SafetyGame {
constructor() {
this.currentScenarioIndex = 0; // 0 for Grinding, 1 for Chemical
this.currentScenario = null;
this.score = 0;
this.scenarioFirstAttempt = true;
this.scenarioCompleted = [false, false];
this.equippedItems = new Set();
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'),
screenGame: document.getElementById('screen-game'),
screenSummary: document.getElementById('screen-summary'),
btnStartGame: document.getElementById('btn-start-game'),
btnTestSafety: document.getElementById('btn-test-safety'),
btnTestSafetyText: document.getElementById('btn-test-safety-text'),
btnCheckPpe: document.getElementById('btn-check-ppe'),
btnFixPpe: document.getElementById('btn-fix-ppe'),
btnNextScenario: document.getElementById('btn-next-scenario'),
btnNextScenarioText: document.getElementById('btn-next-scenario-text'),
btnReplay: document.getElementById('btn-replay'),
btnPrintCertificate: document.getElementById('btn-print-certificate'),
// 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
sceneBadge: document.getElementById('scene-badge'),
sceneTitle: document.getElementById('scene-title'),
sceneDesc: document.getElementById('scene-desc'),
hazardList: document.getElementById('hazard-list'),
coverageGrid: document.getElementById('coverage-grid'),
sceneStage: document.getElementById('scene-stage'),
sceneBackdrop: document.getElementById('scene-backdrop'),
// Locker
lockerGrid: document.getElementById('ppe-locker-grid'),
lockerGuideText: document.getElementById('locker-guide-text'),
// Overlays
consequenceOverlay: document.getElementById('consequence-overlay'),
hazardFlasherBadge: document.getElementById('hazard-flasher-badge'),
consequenceTitle: document.getElementById('consequence-title'),
consequenceDesc: document.getElementById('consequence-desc'),
missingCountNum: document.getElementById('missing-count-num'),
consequenceHazardList: document.getElementById('consequence-hazard-list'),
successOverlay: document.getElementById('success-overlay'),
successTitle: document.getElementById('success-title'),
successDesc: document.getElementById('success-desc'),
awardedPointsText: document.getElementById('awarded-points-text'),
// Summary Screen
summaryScore: document.getElementById('summary-score'),
summaryPercentage: document.getElementById('summary-percentage'),
summaryStars: document.getElementById('summary-stars'),
summaryRank: document.getElementById('summary-rank'),
// Audio Modal
modalAudio: document.getElementById('modal-audio'),
btnCloseAudioModal: document.getElementById('btn-close-audio-modal'),
btnSaveAudio: document.getElementById('btn-save-audio'),
toggleMasterMute: document.getElementById('toggle-master-mute'),
sliderMusicVol: document.getElementById('slider-music-vol'),
sliderSfxVol: document.getElementById('slider-sfx-vol'),
toggleAmbientHum: document.getElementById('toggle-ambient-hum'),
musicVolNum: document.getElementById('music-vol-num'),
sfxVolNum: document.getElementById('sfx-vol-num')
};
this.init();
}
init() {
this.avatar = new WorkerAvatar('avatar-container');
this.particles = new ParticleEngine('stage-fx-canvas');
this.bindEvents();
this.updateHUD();
// Enable background music starting from home page on first gesture
window.soundEngine.enableAudioOnFirstGesture();
// Listen for freeze frame event from particle collision
window.addEventListener('hazard-freeze-triggered', (e) => {
this.handleFreezeFrameAlert(e.detail.projectile);
});
}
bindEvents() {
// 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, false);
this.switchScreen('game');
this.triggerStartCountdown(() => this.startTimer());
});
}
// Check My PPE Button (Safe Preview & Validation Feedback)
if (this.dom.btnCheckPpe) {
this.dom.btnCheckPpe.addEventListener('click', () => {
this.checkPpeStatus();
});
}
// Start Task Safety Test Button
if (this.dom.btnTestSafety) {
this.dom.btnTestSafety.addEventListener('click', () => {
this.executeSafetyTest();
});
}
// Fix PPE (Dismiss Consequence Modal & Return to Edit)
if (this.dom.btnFixPpe) {
this.dom.btnFixPpe.addEventListener('click', () => {
window.soundEngine.playButtonClick();
this.dismissConsequenceModal();
});
}
// Next Scenario / View Summary Button
if (this.dom.btnNextScenario) {
this.dom.btnNextScenario.addEventListener('click', () => {
window.soundEngine.playButtonClick();
window.soundEngine.stopAllTaskSFX();
this.dom.successOverlay.classList.remove('active');
this.particles.stopMode();
if (this.currentScenarioIndex < SCENARIO_DATA.length - 1) {
this.loadScenario(this.currentScenarioIndex + 1, false);
this.triggerStartCountdown(() => this.startTimer());
} else {
this.showFinalSummary();
}
});
}
// Replay Challenge
if (this.dom.btnReplay) {
this.dom.btnReplay.addEventListener('click', () => {
window.soundEngine.playButtonClick();
this.score = 0;
this.scenarioCompleted = [false, false];
this.switchScreen('game');
window.soundEngine.startBackgroundMusic();
this.loadScenario(0, false);
this.triggerStartCountdown(() => this.startTimer());
});
}
// Certificate Viewer Modal & Print Handlers
if (this.dom.btnPrintCertificate) {
this.dom.btnPrintCertificate.addEventListener('click', () => {
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();
});
}
// Audio & Settings Header Controls (Toggle Mute / Unmute continuously)
if (this.dom.btnSoundToggle) {
this.dom.btnSoundToggle.addEventListener('click', () => {
window.soundEngine.ensureContext();
const isMuted = window.soundEngine.toggleMasterMute();
const iconSpan = this.dom.btnSoundToggle.querySelector('span') || this.dom.btnSoundToggle;
iconSpan.textContent = isMuted ? '🔇' : '🔊';
if (this.dom.toggleMasterMute) this.dom.toggleMasterMute.checked = !isMuted;
});
}
if (this.dom.btnMusicToggle) {
this.dom.btnMusicToggle.addEventListener('click', () => {
window.soundEngine.ensureContext();
const currentVol = window.soundEngine.musicVolume;
const newVol = currentVol > 0.01 ? 0 : 0.25;
window.soundEngine.setMusicVolume(newVol);
const iconSpan = this.dom.btnMusicToggle.querySelector('span') || this.dom.btnMusicToggle;
iconSpan.textContent = newVol > 0 ? '🎵' : '🔇';
if (this.dom.sliderMusicVol) this.dom.sliderMusicVol.value = Math.round(newVol * 100);
if (this.dom.musicVolNum) this.dom.musicVolNum.textContent = `${Math.round(newVol * 100)}%`;
});
}
if (this.dom.btnAudioSettings) {
this.dom.btnAudioSettings.addEventListener('click', () => {
if (this.dom.modalAudio) this.dom.modalAudio.classList.add('active');
});
}
if (this.dom.btnCloseAudioModal) {
this.dom.btnCloseAudioModal.addEventListener('click', () => {
if (this.dom.modalAudio) this.dom.modalAudio.classList.remove('active');
});
}
if (this.dom.btnSaveAudio) {
this.dom.btnSaveAudio.addEventListener('click', () => {
if (this.dom.modalAudio) this.dom.modalAudio.classList.remove('active');
});
}
if (this.dom.toggleMasterMute) {
this.dom.toggleMasterMute.addEventListener('change', (e) => {
window.soundEngine.setMasterMute(!e.target.checked);
if (this.dom.btnSoundToggle) {
this.dom.btnSoundToggle.querySelector('span').textContent = e.target.checked ? '🔊' : '🔇';
}
});
}
if (this.dom.sliderMusicVol) {
this.dom.sliderMusicVol.addEventListener('input', (e) => {
const val = parseInt(e.target.value) / 100;
if (this.dom.musicVolNum) this.dom.musicVolNum.textContent = `${e.target.value}%`;
window.soundEngine.setMusicVolume(val);
});
}
if (this.dom.sliderSfxVol) {
this.dom.sliderSfxVol.addEventListener('input', (e) => {
const val = parseInt(e.target.value) / 100;
if (this.dom.sfxVolNum) this.dom.sfxVolNum.textContent = `${e.target.value}%`;
window.soundEngine.setSfxVolume(val);
});
}
// Fullscreen Mode Toggle
if (this.dom.btnFullscreenToggle) {
this.dom.btnFullscreenToggle.addEventListener('click', () => {
this.toggleFullscreen();
});
}
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');
const cardIndex = num === 0 ? 9 : num - 1;
if (cards[cardIndex]) {
cards[cardIndex].click();
}
}
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();
return;
}
if (e.code === 'Space' && this.dom.screenGame.classList.contains('active')) {
if (!this.dom.consequenceOverlay.classList.contains('active') && !this.dom.successOverlay.classList.contains('active')) {
e.preventDefault();
this.executeSafetyTest();
}
}
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();
}
}
});
}
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.stopTimer();
this.dom.screenStart.classList.add('active');
}
if (screenName === 'game') this.dom.screenGame.classList.add('active');
if (screenName === 'summary') {
this.stopTimer();
this.dom.screenSummary.classList.add('active');
}
}
// =========================================================================
// 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];
this.scenarioFirstAttempt = true;
this.equippedItems.clear();
this.avatar.setEquipped([]);
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');
this.dom.stepPill2.className = index === 1 ? 'step-pill active' : (this.scenarioCompleted[1] ? 'step-pill completed' : 'step-pill');
}
// Scenario Details
if (this.dom.sceneBadge) this.dom.sceneBadge.textContent = `SCENARIO ${index + 1} OF ${SCENARIO_DATA.length}`;
if (this.dom.sceneTitle) this.dom.sceneTitle.textContent = this.currentScenario.title;
if (this.dom.sceneDesc) this.dom.sceneDesc.textContent = this.currentScenario.environmentDesc;
if (this.dom.lockerGuideText) this.dom.lockerGuideText.textContent = this.currentScenario.guideTip;
// Apply stage scene class for background framing
if (this.dom.sceneStage) {
this.dom.sceneStage.className = 'scene-environment-container ' + (this.currentScenario.sceneClass || '');
}
// Render Scenario Background
this.renderScenarioEnvironment(this.currentScenario.id);
// Render Hazards List
if (this.dom.hazardList) {
this.dom.hazardList.innerHTML = this.currentScenario.hazards.map(h => `
<li class="hazard-list-item">
<span>${h.label}</span>
</li>
`).join('');
}
// Populate 3x3 Equipment Locker Grid
this.renderLockerItems();
this.updateCoverageIndicators();
// Start Ambient Sound
window.soundEngine.setAmbientScenario(this.currentScenario.id);
}
renderScenarioEnvironment(scenarioId) {
const bgGrind = document.getElementById('backdrop-grinding');
const bgChem = document.getElementById('backdrop-chemical');
if (bgGrind && bgChem) {
if (scenarioId === 'grinding') {
bgGrind.style.display = 'block';
bgChem.style.display = 'none';
} else {
bgGrind.style.display = 'none';
bgChem.style.display = 'block';
}
}
}
// =========================================================================
// 3x3 PPE SAFETY LOCKER RENDERING
// =========================================================================
getEquipmentIconSvg(iconType) {
switch (iconType) {
case 'helmet':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 12 38 C 12 20 52 20 52 38 L 58 42 C 58 45 6 45 6 42 Z" fill="#facc15" stroke="#ca8a04" stroke-width="2"/>
<path d="M 26 19 L 38 19 L 36 39 L 28 39 Z" fill="#fef08a"/>
<rect x="8" y="40" width="48" height="4" rx="2" fill="#ca8a04"/>
</svg>
`;
case 'face_shield':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 14 18 L 50 18 L 48 24 L 16 24 Z" fill="#0284c7"/>
<path d="M 16 24 L 48 24 L 44 54 C 44 58 20 58 20 54 Z" fill="rgba(56, 189, 248, 0.45)" stroke="#38bdf8" stroke-width="2"/>
<line x1="18" y1="32" x2="26" y2="48" stroke="#ffffff" stroke-width="2" opacity="0.6"/>
</svg>
`;
case 'splash_goggles':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<rect x="8" y="22" width="48" height="22" rx="8" fill="rgba(14, 165, 233, 0.35)" stroke="#0284c7" stroke-width="2.5"/>
<circle cx="22" cy="33" r="8" fill="none" stroke="#38bdf8" stroke-width="2"/>
<circle cx="42" cy="33" r="8" fill="none" stroke="#38bdf8" stroke-width="2"/>
<line x1="4" y1="33" x2="8" y2="33" stroke="#0369a1" stroke-width="3"/>
<line x1="56" y1="33" x2="60" y2="33" stroke="#0369a1" stroke-width="3"/>
</svg>
`;
case 'sunglasses':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 8 28 L 56 28" stroke="#1e293b" stroke-width="3"/>
<rect x="12" y="29" width="18" height="16" rx="4" fill="#09090b" stroke="#1e293b" stroke-width="2"/>
<rect x="34" y="29" width="18" height="16" rx="4" fill="#09090b" stroke="#1e293b" stroke-width="2"/>
</svg>
`;
case 'earmuffs':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 14 36 C 14 16 50 16 50 36" stroke="#0f172a" stroke-width="4" fill="none"/>
<rect x="10" y="32" width="10" height="24" rx="4" fill="#facc15" stroke="#ca8a04" stroke-width="2"/>
<rect x="44" y="32" width="10" height="24" rx="4" fill="#facc15" stroke="#ca8a04" stroke-width="2"/>
</svg>
`;
case 'leather_gloves':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 16 16 L 26 16 L 28 32 L 32 32 L 34 20 L 40 20 L 38 46 L 16 46 Z" fill="#92400e" stroke="#451a03" stroke-width="2"/>
<rect x="14" y="46" width="28" height="10" rx="2" fill="#713f12" stroke="#451a03" stroke-width="1.5"/>
</svg>
`;
case 'chem_gloves':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 16 14 L 26 14 L 28 28 L 32 28 L 34 18 L 42 18 L 40 48 L 14 48 Z" fill="#10b981" stroke="#047857" stroke-width="2"/>
<rect x="12" y="48" width="32" height="10" rx="3" fill="#059669" stroke="#047857" stroke-width="2"/>
</svg>
`;
case 'work_gloves':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 16 18 L 26 18 L 28 32 L 32 32 L 34 22 L 40 22 L 38 44 L 16 44 Z" fill="#e2e8f0" stroke="#94a3b8" stroke-width="2"/>
<rect x="14" y="44" width="26" height="8" rx="2" fill="#3b82f6"/>
</svg>
`;
case 'latex_gloves':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 18 18 L 26 18 L 28 30 L 32 30 L 34 22 L 40 22 L 38 46 L 18 46 Z" fill="#bae6fd" stroke="#38bdf8" stroke-width="1.5"/>
</svg>
`;
case 'leather_jacket':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 12 16 L 24 12 L 40 12 L 52 16 L 46 54 L 18 54 Z" fill="#475569" stroke="#1e293b" stroke-width="2"/>
<path d="M 24 12 L 32 30 L 40 12 Z" fill="#facc15"/>
<line x1="32" y1="30" x2="32" y2="54" stroke="#0f172a" stroke-width="2"/>
</svg>
`;
case 'chem_apron':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 22 10 L 42 10 L 42 16 L 48 26 L 46 56 L 18 56 L 16 26 L 22 16 Z" fill="#34d399" stroke="#059669" stroke-width="2"/>
<path d="M 26 10 C 26 4 38 4 38 10" stroke="#047857" stroke-width="2" fill="none"/>
</svg>
`;
case 'cotton_coat':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 14 14 L 24 10 L 40 10 L 50 14 L 46 56 L 18 56 Z" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2"/>
<line x1="32" y1="18" x2="32" y2="56" stroke="#94a3b8" stroke-width="1.5"/>
</svg>
`;
case 'safety_boots':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 16 12 L 34 12 L 34 32 L 50 36 C 54 44 54 52 48 54 L 14 54 L 14 20 Z" fill="#0f172a" stroke="#ca8a04" stroke-width="2"/>
<rect x="12" y="50" width="40" height="5" rx="2" fill="#ca8a04"/>
<path d="M 40 36 L 48 38 C 50 42 50 48 44 50 L 38 48 Z" fill="#facc15"/>
</svg>
`;
case 'sneakers':
return `
<svg viewBox="0 0 64 64" class="ppe-svg-icon">
<path d="M 14 24 L 30 20 L 32 34 L 52 38 C 54 46 54 50 48 52 L 12 52 Z" fill="#ef4444" stroke="#b91c1c" stroke-width="2"/>
<rect x="10" y="50" width="44" height="4" rx="2" fill="#ffffff" stroke="#cbd5e1" stroke-width="1"/>
<line x1="28" y1="26" x2="40" y2="40" stroke="#ffffff" stroke-width="2"/>
</svg>
`;
default:
return `<span style="font-size: 2rem;">🦺</span>`;
}
}
renderLockerItems() {
if (!this.dom.lockerGrid) return;
this.dom.lockerGrid.innerHTML = this.currentScenario.lockerItems.map((item, idx) => {
const isEquipped = this.equippedItems.has(item.id);
const iconContent = item.iconImg
? `<img src="${item.iconImg}" alt="${item.name}" class="ppe-img-icon">`
: this.getEquipmentIconSvg(item.iconType);
return `
<div class="ppe-item-card ${isEquipped ? 'equipped' : ''}" data-item-id="${item.id}" data-slot="${item.slot}" title="${item.spec}">
<div class="item-icon-box">${iconContent}</div>
<span class="item-name">${item.name}</span>
</div>
`;
}).join('');
// Bind click handlers to cards
this.dom.lockerGrid.querySelectorAll('.ppe-item-card').forEach(card => {
card.addEventListener('click', () => {
const itemId = card.dataset.itemId;
const slot = card.dataset.slot;
this.toggleItem(itemId, slot);
});
});
}
toggleItem(itemId, slot) {
if (this.equippedItems.has(itemId)) {
// Unequip
this.equippedItems.delete(itemId);
this.avatar.unequip(itemId);
window.soundEngine.playDeselectSFX();
} else {
// If equipping, replace any existing item occupying the same slot
const existingInSlot = this.currentScenario.lockerItems.find(
it => it.slot === slot && this.equippedItems.has(it.id)
);
if (existingInSlot) {
this.equippedItems.delete(existingInSlot.id);
this.avatar.unequip(existingInSlot.id);
}
this.equippedItems.add(itemId);
this.avatar.equip(itemId);
window.soundEngine.playEquipSFX();
}
this.renderLockerItems();
this.updateCoverageIndicators();
}
updateCoverageIndicators() {
if (!this.dom.coverageGrid) return;
const rows = this.dom.coverageGrid.querySelectorAll('.status-row-item');
rows.forEach(row => {
const slot = row.dataset.slot;
const badge = row.querySelector('.row-badge');
if (!badge) return;
// Check if this body slot has an equipped item
const isSlotEquipped = Array.from(this.equippedItems).some(itemId => {
const itemObj = this.currentScenario.lockerItems.find(it => it.id === itemId);
return itemObj && itemObj.slot === slot;
});
if (isSlotEquipped) {
badge.className = 'row-badge status-check';
badge.textContent = '✓';
} else {
badge.className = 'row-badge status-q';
badge.textContent = '?';
}
});
}
// =========================================================================
// CHECK MY PPE (GUIDANCE & FEEDBACK)
// =========================================================================
checkPpeStatus() {
window.soundEngine.playButtonClick();
const equippedCount = this.equippedItems.size;
if (equippedCount === 0) {
alert("No PPE selected yet! Click equipment cards in the PPE Safety Locker to protect the worker.");
return;
}
// Check how many of 6 slots are covered
const slots = ['head', 'eyes', 'ears', 'hands', 'body', 'feet'];
const coveredSlots = slots.filter(slot => {
return Array.from(this.equippedItems).some(itemId => {
const it = this.currentScenario.lockerItems.find(x => x.id === itemId);
return it && it.slot === slot;
});
});
if (coveredSlots.length < 6) {
alert(`Worker Status: ${coveredSlots.length}/6 protection zones covered. Please review the missing body areas in the Worker Protection Status checklist before starting the task.`);
} else {
alert(`All 6 protection zones covered! Click START TASK to begin the operation.`);
}
}
// =========================================================================
// SAFETY CHECK EXECUTION & CONSEQUENCE DETECTION
// =========================================================================
executeSafetyTest() {
window.soundEngine.playButtonClick();
// 1. Identify ALL equipped distractors / incorrect items
const allEquippedDistractors = this.currentScenario.lockerItems.filter(
it => this.equippedItems.has(it.id) && !it.isCorrect
);
// 2. Identify ALL missing required critical gear checks
const allMissingChecks = this.currentScenario.criticalChecks.filter(
chk => !this.equippedItems.has(chk.requiredItem)
);
const isSafe = allEquippedDistractors.length === 0 && allMissingChecks.length === 0;
if (isSafe) {
// 100% SAFE SUCCESS PATH
this.handleSafeSuccess();
} else {
// UNSAFE FAILURE / CONSEQUENCE NEAR-MISS PATH (Collect all issues)
this.handleUnsafeConsequence(allMissingChecks, allEquippedDistractors);
}
}
handleSafeSuccess() {
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 ➔';
}
}
if (this.dom.successOverlay) this.dom.successOverlay.classList.add('active');
}
handleUnsafeConsequence(allMissingChecks, allEquippedDistractors) {
this.stopTimer();
this.scenarioFirstAttempt = false;
this.particles.clear();
this.particles.stopMode();
this.activeConsequenceData = { allMissingChecks, allEquippedDistractors };
this.handleFreezeFrameAlert();
}
handleFreezeFrameAlert(projectile) {
this.particles.clear();
this.particles.setFrozen(false);
this.particles.stopMode();
window.soundEngine.stopGrinderSFX();
window.soundEngine.stopLiquidTransferSFX();
// Duck background music
window.soundEngine.duckMusic(true);
// Play warning tones
window.soundEngine.playWarningTone();
window.soundEngine.playCriticalStopAlert();
const { allMissingChecks, allEquippedDistractors } = this.activeConsequenceData;
const totalIssues = allMissingChecks.length + allEquippedDistractors.length;
const slotIcons = {
head: '🧢',
eyes: '👓',
ears: '🎧',
hands: '🧤',
body: '🦺',
feet: '🥾'
};
// 4. Update Header & Badges
if (this.dom.hazardFlasherBadge) {
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.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.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.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.";
}
// 5. Build HTML for ALL Missing & Incorrect Items Simultaneously
let cardsHtml = '';
allMissingChecks.forEach(chk => {
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="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>
<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="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>
<p class="hazard-card-desc">${dist.rejectReason}</p>
</div>
</div>
`;
});
if (this.dom.consequenceHazardList) {
this.dom.consequenceHazardList.innerHTML = cardsHtml;
}
// 6. Highlight missing slots in Coverage Grid
if (this.dom.coverageGrid) {
const rows = this.dom.coverageGrid.querySelectorAll('.status-row-item');
rows.forEach(row => {
const slot = row.dataset.slot;
const isSlotMissing = allMissingChecks.some(chk => chk.slot === slot);
if (isSlotMissing) {
row.classList.add('alert-missing');
} else {
row.classList.remove('alert-missing');
}
});
}
// 7. Display freeze overlay
if (this.dom.consequenceOverlay) {
this.dom.consequenceOverlay.classList.add('active');
}
}
dismissConsequenceModal() {
window.soundEngine.stopAllTaskSFX();
if (this.dom.consequenceOverlay) this.dom.consequenceOverlay.classList.remove('active');
this.particles.clear();
this.particles.setFrozen(false);
this.particles.stopMode();
window.soundEngine.duckMusic(false);
if (this.dom.coverageGrid) {
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();
}
}
// =========================================================================
// SUMMARY SCREEN
// =========================================================================
showFinalSummary() {
window.soundEngine.stopAllTaskSFX();
this.switchScreen('summary');
window.soundEngine.playVictorySting();
this.particles.spawnConfetti();
const totalMax = SCENARIO_DATA.length * 100;
const pct = Math.round((this.score / totalMax) * 100);
if (this.dom.summaryScore) this.dom.summaryScore.textContent = `${this.score} / ${totalMax}`;
if (this.dom.summaryPercentage) this.dom.summaryPercentage.textContent = `${pct}% Safety Compliance Rating`;
if (this.score >= 180) {
if (this.dom.summaryStars) this.dom.summaryStars.textContent = "⭐⭐";
if (this.dom.summaryRank) this.dom.summaryRank.textContent = "Master Safety Practitioner (Zero Accidents)";
} else {
if (this.dom.summaryStars) this.dom.summaryStars.textContent = "⭐";
if (this.dom.summaryRank) this.dom.summaryRank.textContent = "Safety Certified (Refresher Recommended)";
}
}
updateHUD() {
if (this.dom.hudScore) this.dom.hudScore.textContent = this.score;
const starCount = Math.floor(this.score / 100);
if (this.dom.hudStars) {
this.dom.hudStars.textContent = "★".repeat(starCount) + "☆".repeat(Math.max(0, 2 - starCount));
}
}
}
// Instantiate on load
window.addEventListener('DOMContentLoaded', () => {
window.safetyGame = new SafetyGame();
});