/** * 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; // 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'), btnClearPpe: document.getElementById('btn-clear-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'), stepPill1: document.getElementById('step-pill-1'), stepPill2: document.getElementById('step-pill-2'), // Scene 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'), statusLight: document.getElementById('status-light'), statusText: document.getElementById('status-text'), audioCaption: document.getElementById('audio-caption'), // 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 summaryScore: document.getElementById('summary-score'), summaryPercentage: document.getElementById('summary-percentage'), summaryStars: document.getElementById('summary-stars'), summaryRank: document.getElementById('summary-rank'), // Audio Modal btnAudioSettings: document.getElementById('btn-audio-settings'), btnRestart: document.getElementById('btn-restart'), 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(); // Listen for freeze frame event from particle collision window.addEventListener('hazard-freeze-triggered', (e) => { this.handleFreezeFrameAlert(e.detail.projectile); }); } bindEvents() { // Start Game this.dom.btnStartGame.addEventListener('click', () => { window.soundEngine.ensureContext(); window.soundEngine.playStartIntroSting(); this.switchScreen('game'); setTimeout(() => { window.soundEngine.startBackgroundMusic(); this.loadScenario(0); }, 900); }); // Test Worker Safety this.dom.btnTestSafety.addEventListener('click', () => { this.executeSafetyTest(); }); // Clear PPE this.dom.btnClearPpe.addEventListener('click', () => { window.soundEngine.playDeselectSFX(); this.equippedItems.clear(); this.avatar.setEquipped([]); this.renderLockerItems(); this.updateCoverageIndicators(); }); // Fix PPE (From Consequence Modal) this.dom.btnFixPpe.addEventListener('click', () => { window.soundEngine.playButtonClick(); this.dismissConsequenceModal(); }); // Next Scenario this.dom.btnNextScenario.addEventListener('click', () => { window.soundEngine.playButtonClick(); this.dom.successOverlay.classList.remove('active'); this.particles.stopMode(); if (this.currentScenarioIndex < SCENARIO_DATA.length - 1) { this.loadScenario(this.currentScenarioIndex + 1); } else { this.showFinalSummary(); } }); // Replay Challenge this.dom.btnReplay.addEventListener('click', () => { window.soundEngine.playButtonClick(); this.score = 0; this.scenarioCompleted = [false, false]; this.switchScreen('game'); window.soundEngine.startBackgroundMusic(); this.loadScenario(0); }); // Print Certificate 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.print(); }); // Restart Button (Top Bar) this.dom.btnRestart.addEventListener('click', () => { if (confirm("Restart the PPE Safety Challenge from the beginning?")) { window.soundEngine.playButtonClick(); this.score = 0; this.scenarioCompleted = [false, false]; this.switchScreen('start'); window.soundEngine.stopBackgroundMusic(); window.soundEngine.stopAmbient(); window.soundEngine.stopGrinderSFX(); window.soundEngine.stopLiquidTransferSFX(); } }); // Audio Modal Controls this.dom.btnAudioSettings.addEventListener('click', () => { this.dom.modalAudio.classList.add('active'); }); this.dom.btnCloseAudioModal.addEventListener('click', () => { this.dom.modalAudio.classList.remove('active'); }); this.dom.btnSaveAudio.addEventListener('click', () => { this.dom.modalAudio.classList.remove('active'); }); this.dom.toggleMasterMute.addEventListener('change', (e) => { window.soundEngine.setMasterMute(!e.target.checked); document.getElementById('audio-icon').textContent = e.target.checked ? '๐Ÿ”Š' : '๐Ÿ”‡'; }); this.dom.sliderMusicVol.addEventListener('input', (e) => { const val = parseInt(e.target.value) / 100; this.dom.musicVolNum.textContent = `${e.target.value}%`; window.soundEngine.setMusicVolume(val); }); this.dom.sliderSfxVol.addEventListener('input', (e) => { const val = parseInt(e.target.value) / 100; this.dom.sfxVolNum.textContent = `${e.target.value}%`; window.soundEngine.setSfxVolume(val); }); this.dom.toggleAmbientHum.addEventListener('change', (e) => { window.soundEngine.setAmbientEnabled(e.target.checked); }); // Keyboard Shortcuts window.addEventListener('keydown', (e) => { // Numbers 1-9 to toggle PPE const num = parseInt(e.key); if (!isNaN(num) && num >= 1 && num <= 9) { const cards = document.querySelectorAll('.ppe-item-card'); if (cards[num - 1]) { cards[num - 1].click(); } } // Spacebar to Test Safety 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(); } } // Esc to close overlays/modals if (e.key === 'Escape') { if (this.dom.modalAudio.classList.contains('active')) { this.dom.modalAudio.classList.remove('active'); } else if (this.dom.consequenceOverlay.classList.contains('active')) { this.dismissConsequenceModal(); } } // 'M' to toggle Audio Settings if (e.key === 'm' || e.key === 'M') { this.dom.modalAudio.classList.toggle('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 === 'game') this.dom.screenGame.classList.add('active'); if (screenName === 'summary') this.dom.screenSummary.classList.add('active'); } loadScenario(index) { this.currentScenarioIndex = index; this.currentScenario = SCENARIO_DATA[index]; this.scenarioFirstAttempt = true; this.equippedItems.clear(); this.avatar.setEquipped([]); this.particles.clear(); this.particles.stopMode(); // Update Stepper HUD 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'); // Scene Styling this.dom.sceneStage.className = `scene-environment-container ${this.currentScenario.sceneClass}`; this.dom.sceneBadge.textContent = `SCENARIO ${index + 1} OF ${SCENARIO_DATA.length}`; this.dom.sceneTitle.textContent = this.currentScenario.title; this.dom.sceneDesc.textContent = this.currentScenario.environmentDesc; this.dom.lockerGuideText.textContent = this.currentScenario.guideTip; this.dom.audioCaption.textContent = `[Audio: ${this.currentScenario.audioCaption}]`; // Status Bar this.dom.statusLight.className = 'status-indicator-light'; this.dom.statusText.textContent = `TASK IDLE - EQUIP WORKER BEFORE STARTING ${this.currentScenario.taskName}`; // Render Scenario-Specific Realistic Workplace Environment this.renderScenarioEnvironment(this.currentScenario.id); // Populate Hazard List this.dom.hazardList.innerHTML = this.currentScenario.hazards.map(h => `
  • ${h.label}
  • `).join(''); // Populate Locker Grid this.renderLockerItems(); this.updateCoverageIndicators(); // Start Ambient Sound for this scenario window.soundEngine.setAmbientScenario(this.currentScenario.id); } renderScenarioEnvironment(scenarioId) { if (!this.dom.sceneBackdrop) return; if (scenarioId === 'grinding') { // SCENARIO 1: Vector Industrial Fabrication & Welding Workshop this.dom.sceneBackdrop.innerHTML = ` โšก CAUTION GRINDING & WELDING PPE MANDATORY `; } else if (scenarioId === 'chemical') { // SCENARIO 2: Vector Chemical Laboratory & Dispensing Station (Matching Reference Image) this.dom.sceneBackdrop.innerHTML = ` ๐Ÿงช `; } } renderLockerItems() { this.dom.lockerGrid.innerHTML = this.currentScenario.lockerItems.map((item, idx) => { const isEquipped = this.equippedItems.has(item.id); return `
    ${idx + 1}
    ${isEquipped ? 'โœ“' : ''}
    ${item.icon}
    ${item.name}
    `; }).join(''); // Bind click events on 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() { const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item'); coverageItems.forEach(itemEl => { const slot = itemEl.dataset.slot; const statusEl = itemEl.querySelector('.cov-status'); // Find if any item is equipped in this slot 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) { statusEl.className = 'cov-status protected'; statusEl.textContent = 'Equipped'; } else { statusEl.className = 'cov-status missing'; statusEl.textContent = 'Missing'; } }); } // ========================================================================= // 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() { // 1. Update visual state this.dom.statusLight.className = 'status-indicator-light running'; this.dom.statusText.textContent = `OPERATION RUNNING SAFELY - ALL HAZARDS MITIGATED`; // 2. 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'); } // 3. Play Success Chime & Star sound after short operational run setTimeout(() => { 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 this.dom.successTitle.textContent = this.currentScenario.successMessage.title; this.dom.successDesc.textContent = this.currentScenario.successMessage.body; this.dom.awardedPointsText.textContent = `+${points} Safety Points โญ`; if (this.currentScenarioIndex === SCENARIO_DATA.length - 1) { this.dom.btnNextScenarioText.textContent = 'VIEW FINAL RESULTS โž”'; } else { this.dom.btnNextScenarioText.textContent = 'NEXT SCENARIO โž”'; } this.dom.successOverlay.classList.add('active'); }, 1200); } handleUnsafeConsequence(allMissingChecks, allEquippedDistractors) { this.scenarioFirstAttempt = false; const totalIssues = allMissingChecks.length + allEquippedDistractors.length; // 1. Start task machine momentarily this.dom.statusLight.className = 'status-indicator-light danger'; this.dom.statusText.textContent = `CRITICAL HAZARD DETECTED (${totalIssues} ISSUES) - FREEZING OPERATION!`; if (this.currentScenario.id === 'grinding') { window.soundEngine.startGrinderSFX(); this.particles.startMode('sparks'); } else { window.soundEngine.startLiquidTransferSFX(); this.particles.startMode('chemical'); } // 2. Determine target position for incoming projectile (prioritize eye/face if missing, otherwise hand/body) const avatarCoords = this.avatar.getAvatarCenterCoordinates(); const stageWidth = this.dom.sceneStage.clientWidth; const stageHeight = this.dom.sceneStage.clientHeight; 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 }; } handleFreezeFrameAlert(projectile) { // 1. Freeze particle engine & stop machine motors this.particles.setFrozen(true); window.soundEngine.stopGrinderSFX(); window.soundEngine.stopLiquidTransferSFX(); // 2. Duck background music window.soundEngine.duckMusic(true); // 3. Play warning tones window.soundEngine.playWarningTone(); window.soundEngine.playCriticalStopAlert(); const { allMissingChecks, allEquippedDistractors } = this.activeConsequenceData; const totalIssues = allMissingChecks.length + allEquippedDistractors.length; // Slot icons mapping const slotIcons = { head: '๐Ÿงข', eyes: '๐Ÿ‘“', ears: '๐ŸŽง', hands: '๐Ÿงค', body: '๐Ÿฆบ', feet: '๐Ÿฅพ' }; // 4. Update Header & Badges this.dom.hazardFlasherBadge.textContent = `โš ๏ธ STOP! ${totalIssues} SAFETY ${totalIssues === 1 ? 'HAZARD' : 'HAZARDS'} DETECTED`; this.dom.missingCountNum.textContent = totalIssues; if (totalIssues === 1) { if (allEquippedDistractors.length > 0) { const d = allEquippedDistractors[0]; this.dom.consequenceTitle.textContent = "Incorrect / Ineffective PPE Detected"; this.dom.consequenceDesc.textContent = d.rejectReason; } else { const m = allMissingChecks[0]; this.dom.consequenceTitle.textContent = m.consequenceTitle; this.dom.consequenceDesc.textContent = m.consequenceDesc; } } else { this.dom.consequenceTitle.textContent = "Multiple Critical Protection Gaps Detected"; 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 = ''; // Render all missing critical checks allMissingChecks.forEach(chk => { const icon = slotIcons[chk.slot] || 'โš ๏ธ'; cardsHtml += `
    ${icon}
    ${chk.missingLabel} ${chk.slot} - Missing
    ${chk.consequenceDesc}
    `; }); // Render all equipped distractors that need to be removed allEquippedDistractors.forEach(dist => { cardsHtml += `
    ๐Ÿšซ
    REMOVE: ${dist.name} Unsafe Gear
    ${dist.rejectReason}
    `; }); this.dom.consequenceHazardList.innerHTML = cardsHtml; // 6. Highlight missing slots in Coverage Grid with visual alert animation const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item'); coverageItems.forEach(itemEl => { const slot = itemEl.dataset.slot; const isSlotMissing = allMissingChecks.some(chk => chk.slot === slot); if (isSlotMissing) { itemEl.classList.add('alert-missing'); } else { itemEl.classList.remove('alert-missing'); } }); // 7. Display freeze overlay this.dom.consequenceOverlay.classList.add('active'); } dismissConsequenceModal() { // Unfreeze and restore normal background music this.dom.consequenceOverlay.classList.remove('active'); this.particles.clear(); this.particles.setFrozen(false); this.particles.stopMode(); window.soundEngine.duckMusic(false); // Clear alert classes on coverage items const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item'); coverageItems.forEach(itemEl => itemEl.classList.remove('alert-missing')); this.dom.statusLight.className = 'status-indicator-light'; this.dom.statusText.textContent = `TASK IDLE - ADJUST PPE SELECTION`; } // ========================================================================= // SUMMARY & COMPLETION SCREEN // ========================================================================= showFinalSummary() { this.switchScreen('summary'); window.soundEngine.playVictorySting(); this.particles.spawnConfetti(); // Update metrics const totalMax = SCENARIO_DATA.length * 100; const pct = Math.round((this.score / totalMax) * 100); this.dom.summaryScore.textContent = `${this.score} / ${totalMax}`; this.dom.summaryPercentage.textContent = `${pct}% Safety Compliance Rating`; if (this.score >= 180) { this.dom.summaryStars.textContent = "โญโญ"; this.dom.summaryRank.textContent = "Master Safety Practitioner (Zero Accidents)"; } else { this.dom.summaryStars.textContent = "โญ"; this.dom.summaryRank.textContent = "Safety Certified (Refresher Recommended)"; } } updateHUD() { this.dom.hudScore.textContent = this.score; const starCount = Math.floor(this.score / 100); this.dom.hudStars.textContent = "โญ".repeat(starCount) + "โ˜†".repeat(Math.max(0, 2 - starCount)); } } // Instantiate game on load window.addEventListener('DOMContentLoaded', () => { window.safetyGame = new SafetyGame(); });