/** * 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'), 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'), stepPill1: document.getElementById('step-pill-1'), stepPill2: document.getElementById('step-pill-2'), btnSoundToggle: document.getElementById('btn-sound-toggle'), btnMusicToggle: document.getElementById('btn-music-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(); // Listen for freeze frame event from particle collision window.addEventListener('hazard-freeze-triggered', (e) => { this.handleFreezeFrameAlert(e.detail.projectile); }); } bindEvents() { // Start Game from Intro if (this.dom.btnStartGame) { this.dom.btnStartGame.addEventListener('click', () => { window.soundEngine.ensureContext(); window.soundEngine.playStartIntroSting(); this.switchScreen('game'); setTimeout(() => { window.soundEngine.startBackgroundMusic(); this.loadScenario(0); }, 800); }); } // 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(); 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 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); }); } // Print Certificate 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.print(); }); } // Audio & Settings Header Controls if (this.dom.btnSoundToggle) { this.dom.btnSoundToggle.addEventListener('click', () => { window.soundEngine.ensureContext(); const isMuted = !window.soundEngine.isMasterMuted; window.soundEngine.setMasterMute(isMuted); this.dom.btnSoundToggle.querySelector('span').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); this.dom.btnMusicToggle.querySelector('span').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); }); } if (this.dom.toggleAmbientHum) { this.dom.toggleAmbientHum.addEventListener('change', (e) => { window.soundEngine.setAmbientEnabled(e.target.checked); }); } // Keyboard Shortcuts (1-9 for Locker, Space for Start Task, Esc to close modals) window.addEventListener('keydown', (e) => { 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(); } } 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 (this.dom.consequenceOverlay.classList.contains('active')) { this.dismissConsequenceModal(); } } }); } 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 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; // Render Scenario Background this.renderScenarioEnvironment(this.currentScenario.id); // Render Hazards List if (this.dom.hazardList) { this.dom.hazardList.innerHTML = this.currentScenario.hazards.map(h => `
  • ${h.label}
  • `).join(''); } // Populate 3x3 Equipment Locker Grid this.renderLockerItems(); this.updateCoverageIndicators(); // Start Ambient Sound window.soundEngine.setAmbientScenario(this.currentScenario.id); } renderScenarioEnvironment(scenarioId) { if (!this.dom.sceneBackdrop) return; if (scenarioId === 'grinding') { // SCENARIO 1: Vector Industrial Fabrication & Grinding Workshop (Matching Reference Image) this.dom.sceneBackdrop.innerHTML = ` WEAR PPE `; } else { // SCENARIO 2: Vector Industrial Chemical Transfer Station this.dom.sceneBackdrop.innerHTML = ` EYE WASH ๐Ÿงช `; } } // ========================================================================= // 3x3 PPE SAFETY LOCKER RENDERING // ========================================================================= getEquipmentIconSvg(iconType) { switch (iconType) { case 'helmet': return ` `; case 'face_shield': return ` `; case 'splash_goggles': return ` `; case 'sunglasses': return ` `; case 'earmuffs': return ` `; case 'leather_gloves': return ` `; case 'chem_gloves': return ` `; case 'work_gloves': return ` `; case 'latex_gloves': return ` `; case 'leather_jacket': return ` `; case 'chem_apron': return ` `; case 'cotton_coat': return ` `; case 'safety_boots': return ` `; case 'sneakers': return ` `; default: return `๐Ÿฆบ`; } } 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 ? `${item.name}` : this.getEquipmentIconSvg(item.iconType); return `
    ${isEquipped ? 'โœ“' : ''}
    ${iconContent}
    ${item.name}
    `; }).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() { // 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'); } // 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 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); } handleUnsafeConsequence(allMissingChecks, allEquippedDistractors) { this.scenarioFirstAttempt = false; // 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 }; } 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; const slotIcons = { head: '๐Ÿงข', eyes: '๐Ÿ‘“', ears: '๐ŸŽง', hands: '๐Ÿงค', body: '๐Ÿฆบ', feet: '๐Ÿฅพ' }; // 4. Update Header & Badges if (this.dom.hazardFlasherBadge) { this.dom.hazardFlasherBadge.textContent = `โš ๏ธ STOP! ${totalIssues} SAFETY ${totalIssues === 1 ? 'HAZARD' : 'HAZARDS'} DETECTED`; } 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.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.consequenceDesc) this.dom.consequenceDesc.textContent = m.consequenceDesc; } } else { if (this.dom.consequenceTitle) this.dom.consequenceTitle.textContent = "Critical Protection Gaps Detected"; 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 icon = slotIcons[chk.slot] || 'โš ๏ธ'; cardsHtml += `
    ${icon}
    ${chk.missingLabel} ${chk.slot} - Missing
    ${chk.consequenceDesc}
    `; }); allEquippedDistractors.forEach(dist => { cardsHtml += `
    ๐Ÿšซ
    REMOVE: ${dist.name} Unsafe Gear
    ${dist.rejectReason}
    `; }); 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() { 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')); } } // ========================================================================= // SUMMARY SCREEN // ========================================================================= showFinalSummary() { 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(); });