1047 lines
46 KiB
JavaScript
1047 lines
46 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;
|
|
|
|
// 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 => `
|
|
<li class="hazard-list-item">
|
|
<span>${h.label}</span>
|
|
</li>
|
|
`).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 = `
|
|
<svg viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid slice" class="env-svg-stage">
|
|
<defs>
|
|
<!-- Sky & Window Gradients -->
|
|
<linearGradient id="grindSkyGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#93c5fd"/>
|
|
<stop offset="60%" stop-color="#bae6fd"/>
|
|
<stop offset="100%" stop-color="#e0f2fe"/>
|
|
</linearGradient>
|
|
<!-- Industrial Lamp Glow -->
|
|
<linearGradient id="grindLampGlow" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="rgba(254, 240, 138, 0.4)"/>
|
|
<stop offset="100%" stop-color="rgba(254, 240, 138, 0.0)"/>
|
|
</linearGradient>
|
|
<!-- Metal Bench Gradient -->
|
|
<linearGradient id="grindBenchGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#475569"/>
|
|
<stop offset="100%" stop-color="#1e293b"/>
|
|
</linearGradient>
|
|
<!-- Cabinet Blue/Slate -->
|
|
<linearGradient id="grindCabGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#3b82f6"/>
|
|
<stop offset="100%" stop-color="#1d4ed8"/>
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- 1. TALL FACTORY WINDOWS & SKYLINE BACKGROUND -->
|
|
<rect width="1000" height="380" fill="url(#grindSkyGrad)"/>
|
|
<!-- Distant Vector City & Industrial Silhouettes -->
|
|
<g fill="#93c5fd" opacity="0.6">
|
|
<rect x="60" y="160" width="80" height="220"/>
|
|
<polygon points="60,160 100,120 140,160"/>
|
|
<rect x="180" y="140" width="110" height="240"/>
|
|
<rect x="340" y="180" width="90" height="200"/>
|
|
<rect x="520" y="150" width="120" height="230"/>
|
|
<polygon points="520,150 580,110 640,150"/>
|
|
<rect x="700" y="170" width="95" height="210"/>
|
|
<rect x="830" y="130" width="120" height="250"/>
|
|
</g>
|
|
|
|
<!-- Window Framework & Structural Steel Mullions -->
|
|
<g stroke="#ffffff" stroke-width="4" opacity="0.85">
|
|
<line x1="0" y1="90" x2="1000" y2="90"/>
|
|
<line x1="0" y1="190" x2="1000" y2="190"/>
|
|
<line x1="0" y1="290" x2="1000" y2="290"/>
|
|
<line x1="120" y1="0" x2="120" y2="380"/>
|
|
<line x1="280" y1="0" x2="280" y2="380"/>
|
|
<line x1="440" y1="0" x2="440" y2="380"/>
|
|
<line x1="600" y1="0" x2="600" y2="380"/>
|
|
<line x1="760" y1="0" x2="760" y2="380"/>
|
|
<line x1="920" y1="0" x2="920" y2="380"/>
|
|
</g>
|
|
|
|
<!-- Overhead Gantry Crane Rail & Yellow Chevrons -->
|
|
<rect x="0" y="20" width="1000" height="26" fill="#334155"/>
|
|
<rect x="0" y="46" width="1000" height="6" fill="#0f172a"/>
|
|
<g fill="#facc15" opacity="0.8">
|
|
<polygon points="20,20 40,20 20,46 0,46"/>
|
|
<polygon points="80,20 100,20 80,46 60,46"/>
|
|
<polygon points="140,20 160,20 140,46 120,46"/>
|
|
<polygon points="860,20 880,20 860,46 840,46"/>
|
|
<polygon points="920,20 940,20 920,46 900,46"/>
|
|
<polygon points="980,20 1000,20 980,46 960,46"/>
|
|
</g>
|
|
|
|
<!-- 2. VECTOR INDUSTRIAL PENDANT CEILING LAMPS -->
|
|
<g transform="translate(240, 0)">
|
|
<line x1="0" y1="0" x2="0" y2="65" stroke="#334155" stroke-width="3"/>
|
|
<polygon points="0,55 -38,80 38,80" fill="#64748b"/>
|
|
<polygon points="-38,80 38,80 90,340 -90,340" fill="url(#grindLampGlow)"/>
|
|
<ellipse cx="0" cy="80" rx="38" ry="7" fill="#fef08a"/>
|
|
</g>
|
|
<g transform="translate(740, 0)">
|
|
<line x1="0" y1="0" x2="0" y2="65" stroke="#334155" stroke-width="3"/>
|
|
<polygon points="0,55 -38,80 38,80" fill="#64748b"/>
|
|
<polygon points="-38,80 38,80 90,340 -90,340" fill="url(#grindLampGlow)"/>
|
|
<ellipse cx="0" cy="80" rx="38" ry="7" fill="#fef08a"/>
|
|
</g>
|
|
|
|
<!-- 3. MIDGROUND WORKSHOP WALL EQUIPMENT & SHELVES -->
|
|
<!-- Left Tool Rack & Gas Cylinders -->
|
|
<g transform="translate(40, 150)">
|
|
<!-- Gas Cylinder 1 (Green Oxygen) -->
|
|
<rect x="0" y="70" width="28" height="150" rx="14" fill="#059669" stroke="#047857" stroke-width="2"/>
|
|
<ellipse cx="14" cy="70" rx="14" ry="7" fill="#10b981"/>
|
|
<rect x="10" y="55" width="8" height="15" fill="#d1d5db"/>
|
|
<circle cx="14" cy="50" r="5" fill="#facc15"/>
|
|
<!-- Gas Cylinder 2 (Red Acetylene) -->
|
|
<rect x="34" y="95" width="30" height="125" rx="15" fill="#dc2626" stroke="#991b1b" stroke-width="2"/>
|
|
<ellipse cx="49" cy="95" rx="15" ry="7" fill="#ef4444"/>
|
|
<rect x="45" y="80" width="8" height="15" fill="#d1d5db"/>
|
|
<circle cx="49" cy="75" r="5" fill="#facc15"/>
|
|
</g>
|
|
|
|
<!-- Tool Wall Rack Shelf -->
|
|
<g transform="translate(130, 175)">
|
|
<rect width="110" height="6" fill="#1e293b"/>
|
|
<rect y="40" width="110" height="6" fill="#1e293b"/>
|
|
<!-- Hanging Tools -->
|
|
<rect x="15" y="6" width="6" height="30" fill="#94a3b8"/>
|
|
<rect x="35" y="6" width="8" height="26" fill="#94a3b8"/>
|
|
<rect x="60" y="6" width="10" height="32" fill="#94a3b8"/>
|
|
<rect x="85" y="6" width="7" height="25" fill="#94a3b8"/>
|
|
</g>
|
|
|
|
<!-- OSHA Warning Sign on Wall -->
|
|
<g transform="translate(270, 160)">
|
|
<rect width="110" height="55" rx="4" fill="#facc15" stroke="#ca8a04" stroke-width="2"/>
|
|
<rect x="3" y="3" width="104" height="18" rx="2" fill="#dc2626"/>
|
|
<text x="55" y="15" font-family="'Chakra Petch', sans-serif" font-size="9" font-weight="900" fill="#ffffff" text-anchor="middle">⚡ CAUTION</text>
|
|
<text x="55" y="32" font-family="'Inter', sans-serif" font-size="7" font-weight="800" fill="#000000" text-anchor="middle">GRINDING & WELDING</text>
|
|
<text x="55" y="44" font-family="'Inter', sans-serif" font-size="6.5" font-weight="800" fill="#b91c1c" text-anchor="middle">PPE MANDATORY</text>
|
|
</g>
|
|
|
|
<!-- 4. WORKSHOP CONCRETE FLOOR & CAUTION PERIMETER -->
|
|
<rect x="0" y="380" width="1000" height="220" fill="#1e2634"/>
|
|
<g transform="translate(0, 376)">
|
|
<rect width="1000" height="10" fill="#facc15"/>
|
|
<!-- Chevron pattern -->
|
|
<path d="M 0 0 L 12 10 M 24 0 L 36 10 M 48 0 L 60 10 M 72 0 L 84 10 M 96 0 L 108 10 M 120 0 L 132 10 M 144 0 L 156 10 M 168 0 L 180 10 M 192 0 L 204 10 M 216 0 L 228 10 M 240 0 L 252 10 M 264 0 L 276 10 M 288 0 L 300 10 M 312 0 L 324 10 M 336 0 L 348 10 M 360 0 L 372 10 M 384 0 L 396 10 M 408 0 L 420 10 M 432 0 L 444 10 M 456 0 L 468 10 M 480 0 L 492 10 M 504 0 L 516 10 M 528 0 L 540 10 M 552 0 L 564 10 M 576 0 L 588 10 M 600 0 L 612 10 M 624 0 L 636 10 M 648 0 L 660 10 M 672 0 L 684 10 M 696 0 L 708 10 M 720 0 L 732 10 M 744 0 L 756 10 M 768 0 L 780 10 M 792 0 L 804 10 M 816 0 L 828 10 M 840 0 L 852 10 M 864 0 L 876 10 M 888 0 L 900 10 M 912 0 L 924 10 M 936 0 L 948 10 M 960 0 L 972 10 M 984 0 L 996 10" stroke="#000" stroke-width="5"/>
|
|
</g>
|
|
|
|
<!-- 5. MAIN FOREGROUND WORKBENCH & ANGLE GRINDER SETUP -->
|
|
<g transform="translate(160, 310)">
|
|
<!-- Table Legs & Framework -->
|
|
<rect x="20" y="80" width="22" height="180" fill="#1e293b"/>
|
|
<rect x="760" y="80" width="22" height="180" fill="#1e293b"/>
|
|
<rect x="20" y="240" width="762" height="18" fill="#334155"/>
|
|
|
|
<!-- Lower Storage Cabinet & Shelves -->
|
|
<g transform="translate(480, 80)">
|
|
<rect width="280" height="160" fill="#1e293b" stroke="#334155" stroke-width="2"/>
|
|
<!-- Cabinet Doors -->
|
|
<rect x="10" y="10" width="125" height="140" fill="url(#grindCabGrad)" stroke="#1e3a8a" stroke-width="1.5"/>
|
|
<rect x="145" y="10" width="125" height="140" fill="url(#grindCabGrad)" stroke="#1e3a8a" stroke-width="1.5"/>
|
|
<!-- Chrome Door Handles -->
|
|
<rect x="120" y="65" width="8" height="30" rx="3" fill="#e2e8f0"/>
|
|
<rect x="152" y="65" width="8" height="30" rx="3" fill="#e2e8f0"/>
|
|
</g>
|
|
|
|
<!-- Rolling Stool -->
|
|
<g transform="translate(390, 110)">
|
|
<ellipse cx="30" cy="10" rx="30" ry="10" fill="#475569"/>
|
|
<rect x="27" y="15" width="6" height="100" fill="#94a3b8"/>
|
|
<polygon points="30,115 10,135 50,135" fill="#334155"/>
|
|
</g>
|
|
|
|
<!-- Thick Stainless Steel & Heavy Plate Tabletop -->
|
|
<rect x="0" y="60" width="800" height="28" rx="4" fill="url(#grindBenchGrad)" stroke="#94a3b8" stroke-width="2"/>
|
|
<rect x="0" y="60" width="800" height="6" fill="#64748b"/>
|
|
|
|
<!-- Heavy Bench Vise on Table -->
|
|
<g transform="translate(540, 10)">
|
|
<rect x="0" y="25" width="55" height="40" rx="3" fill="#475569" stroke="#1e293b" stroke-width="2"/>
|
|
<rect x="-10" y="15" width="75" height="14" rx="2" fill="#334155"/>
|
|
<circle cx="27" cy="45" r="8" fill="#1e293b"/>
|
|
|
|
<!-- Clamped Heavy Steel Workpiece with Welded Joint -->
|
|
<rect x="35" y="-15" width="150" height="55" rx="3" fill="#64748b" stroke="#cbd5e1" stroke-width="2"/>
|
|
<line x1="35" y1="12" x2="185" y2="12" stroke="#475569" stroke-width="3"/>
|
|
<!-- Fiery Weld Joint Beads -->
|
|
<path d="M 85 -15 Q 90 12 85 40" stroke="#f59e0b" stroke-width="4.5" stroke-dasharray="4,3"/>
|
|
<path d="M 135 -15 Q 140 12 135 40" stroke="#f59e0b" stroke-width="4.5" stroke-dasharray="4,3"/>
|
|
</g>
|
|
|
|
<!-- Professional Angle Grinder on Table -->
|
|
<g transform="translate(680, 5) rotate(-10)">
|
|
<!-- Heavy Rubber Power Cable -->
|
|
<path d="M 120 25 C 160 40 140 85 190 95" stroke="#0f172a" stroke-width="4.5" fill="none"/>
|
|
<!-- Red Motor Body -->
|
|
<rect x="35" y="10" width="85" height="26" rx="6" fill="#dc2626" stroke="#991b1b" stroke-width="2"/>
|
|
<!-- Side Grip Handle -->
|
|
<rect x="18" y="-14" width="14" height="28" rx="3" fill="#0f172a"/>
|
|
<!-- Aluminum Gearbox Head -->
|
|
<polygon points="12,5 38,5 35,38 12,38" fill="#cbd5e1" stroke="#64748b" stroke-width="2"/>
|
|
<!-- Wheel Safety Spark Guard -->
|
|
<path d="M 22 -6 C -14 -6 -14 48 22 48 Z" fill="#1e293b" stroke="#0f172a" stroke-width="1.5"/>
|
|
<!-- Abrasive Grinding Disc -->
|
|
<ellipse cx="8" cy="21" rx="7" ry="28" fill="#475569" stroke="#f8fafc" stroke-width="2"/>
|
|
<ellipse cx="8" cy="21" rx="3" ry="9" fill="#ef4444"/>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
`;
|
|
} else if (scenarioId === 'chemical') {
|
|
// SCENARIO 2: Vector Chemical Laboratory & Dispensing Station (Matching Reference Image)
|
|
this.dom.sceneBackdrop.innerHTML = `
|
|
<svg viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid slice" class="env-svg-stage">
|
|
<defs>
|
|
<!-- Sky Gradient Outside Windows -->
|
|
<linearGradient id="chemSkyGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#7dd3fc"/>
|
|
<stop offset="60%" stop-color="#bae6fd"/>
|
|
<stop offset="100%" stop-color="#e0f2fe"/>
|
|
</linearGradient>
|
|
<!-- Lamp Glow Cone -->
|
|
<linearGradient id="chemLampGlow" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="rgba(254, 240, 138, 0.45)"/>
|
|
<stop offset="100%" stop-color="rgba(254, 240, 138, 0.0)"/>
|
|
</linearGradient>
|
|
<!-- Lab Tabletop Gradient -->
|
|
<linearGradient id="chemTableGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#f8fafc"/>
|
|
<stop offset="100%" stop-color="#cbd5e1"/>
|
|
</linearGradient>
|
|
<!-- Cyan Chemical Liquid Gradient -->
|
|
<linearGradient id="liquidCyanGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#38bdf8"/>
|
|
<stop offset="100%" stop-color="#0284c7"/>
|
|
</linearGradient>
|
|
<!-- Amber Chemical Liquid Gradient -->
|
|
<linearGradient id="liquidAmberGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#fbbf24"/>
|
|
<stop offset="100%" stop-color="#d97706"/>
|
|
</linearGradient>
|
|
<!-- Green Chemical Liquid Gradient -->
|
|
<linearGradient id="liquidGreenGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#34d399"/>
|
|
<stop offset="100%" stop-color="#059669"/>
|
|
</linearGradient>
|
|
<!-- Cabinet Gradient -->
|
|
<linearGradient id="chemCabGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#60a5fa"/>
|
|
<stop offset="100%" stop-color="#2563eb"/>
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- 1. FULL-WIDTH LAB WINDOWS & CITY SKYLINE -->
|
|
<rect width="1000" height="380" fill="url(#chemSkyGrad)"/>
|
|
|
|
<!-- City Skyline Silhouettes -->
|
|
<g fill="#93c5fd" opacity="0.65">
|
|
<rect x="40" y="170" width="70" height="210"/>
|
|
<rect x="130" y="140" width="100" height="240"/>
|
|
<polygon points="130,140 180,95 230,140"/>
|
|
<rect x="250" y="180" width="85" height="200"/>
|
|
<rect x="360" y="150" width="115" height="230"/>
|
|
<rect x="500" y="125" width="130" height="255"/>
|
|
<polygon points="500,125 565,85 630,125"/>
|
|
<rect x="650" y="165" width="90" height="215"/>
|
|
<rect x="760" y="140" width="110" height="240"/>
|
|
<rect x="890" y="170" width="90" height="210"/>
|
|
</g>
|
|
|
|
<!-- Window Framework Grid Lines -->
|
|
<g stroke="#ffffff" stroke-width="4" opacity="0.9">
|
|
<line x1="0" y1="75" x2="1000" y2="75"/>
|
|
<line x1="0" y1="175" x2="1000" y2="175"/>
|
|
<line x1="0" y1="275" x2="1000" y2="275"/>
|
|
<line x1="60" y1="0" x2="60" y2="380"/>
|
|
<line x1="190" y1="0" x2="190" y2="380"/>
|
|
<line x1="320" y1="0" x2="320" y2="380"/>
|
|
<line x1="450" y1="0" x2="450" y2="380"/>
|
|
<line x1="580" y1="0" x2="580" y2="380"/>
|
|
<line x1="710" y1="0" x2="710" y2="380"/>
|
|
<line x1="840" y1="0" x2="840" y2="380"/>
|
|
<line x1="970" y1="0" x2="970" y2="380"/>
|
|
</g>
|
|
|
|
<!-- 2. MODERN PENDANT LAMPS (From Reference) -->
|
|
<g transform="translate(260, 0)">
|
|
<line x1="0" y1="0" x2="0" y2="70" stroke="#475569" stroke-width="3"/>
|
|
<polygon points="0,55 -40,82 40,82" fill="#64748b"/>
|
|
<polygon points="-40,82 40,82 95,350 -95,350" fill="url(#chemLampGlow)"/>
|
|
<ellipse cx="0" cy="82" rx="40" ry="8" fill="#fef08a"/>
|
|
</g>
|
|
<g transform="translate(690, 0)">
|
|
<line x1="0" y1="0" x2="0" y2="70" stroke="#475569" stroke-width="3"/>
|
|
<polygon points="0,55 -40,82 40,82" fill="#64748b"/>
|
|
<polygon points="-40,82 40,82 95,350 -95,350" fill="url(#chemLampGlow)"/>
|
|
<ellipse cx="0" cy="82" rx="40" ry="8" fill="#fef08a"/>
|
|
</g>
|
|
|
|
<!-- 3. MIDGROUND CHEMICAL FUME HOOD & REAGENT SHELVES -->
|
|
<!-- Center Fume Extraction Hood (From Reference) -->
|
|
<g transform="translate(390, 110)">
|
|
<rect width="210" height="170" rx="4" fill="#334155" stroke="#475569" stroke-width="3"/>
|
|
<rect x="12" y="15" width="186" height="140" fill="#0284c7" opacity="0.35"/>
|
|
<!-- Glass Reflections inside hood -->
|
|
<polygon points="20,20 180,20 130,150 20,150" fill="#ffffff" opacity="0.12"/>
|
|
<line x1="12" y1="125" x2="198" y2="125" stroke="#94a3b8" stroke-width="4"/>
|
|
</g>
|
|
|
|
<!-- Left Wall Storage Rack with Reagents -->
|
|
<g transform="translate(30, 140)">
|
|
<rect width="80" height="6" fill="#1e293b"/>
|
|
<rect y="40" width="80" height="6" fill="#1e293b"/>
|
|
<!-- Chemical Bottles on Shelf -->
|
|
<rect x="10" y="-25" width="14" height="25" rx="2" fill="url(#liquidCyanGrad)"/>
|
|
<rect x="30" y="-20" width="12" height="20" rx="2" fill="url(#liquidAmberGrad)"/>
|
|
<rect x="50" y="-30" width="16" height="30" rx="2" fill="url(#liquidGreenGrad)"/>
|
|
</g>
|
|
|
|
<!-- Right Tiered Lab Glassware Shelf Unit (From Reference) -->
|
|
<g transform="translate(720, 130)">
|
|
<!-- Frame -->
|
|
<rect x="0" y="0" width="105" height="175" fill="none" stroke="#1e293b" stroke-width="3.5"/>
|
|
<line x1="0" y1="55" x2="105" y2="55" stroke="#1e293b" stroke-width="3"/>
|
|
<line x1="0" y1="115" x2="105" y2="115" stroke="#1e293b" stroke-width="3"/>
|
|
<!-- Flasks on Shelf Top -->
|
|
<rect x="15" y="20" width="12" height="35" rx="2" fill="url(#liquidCyanGrad)"/>
|
|
<polygon points="65,15 75,15 85,55 55,55" fill="url(#liquidAmberGrad)"/>
|
|
<!-- Middle Shelf Test Tubes -->
|
|
<rect x="12" y="70" width="8" height="45" rx="3" fill="url(#liquidCyanGrad)"/>
|
|
<rect x="25" y="70" width="8" height="45" rx="3" fill="url(#liquidGreenGrad)"/>
|
|
<rect x="38" y="70" width="8" height="45" rx="3" fill="url(#liquidAmberGrad)"/>
|
|
<!-- Bottom Shelf Chemical Jars -->
|
|
<rect x="10" y="130" width="22" height="32" rx="3" fill="#ea580c"/>
|
|
<rect x="38" y="125" width="26" height="37" rx="3" fill="#0284c7"/>
|
|
<rect x="70" y="132" width="20" height="30" rx="3" fill="#16a34a"/>
|
|
</g>
|
|
|
|
<!-- 4. CLEANROOM EPOXY FLOOR -->
|
|
<rect x="0" y="380" width="1000" height="220" fill="#1e2c3e"/>
|
|
<g stroke="rgba(255,255,255,0.06)" stroke-width="1.5">
|
|
<line x1="0" y1="440" x2="1000" y2="440"/>
|
|
<line x1="0" y1="510" x2="1000" y2="510"/>
|
|
<line x1="250" y1="380" x2="200" y2="600"/>
|
|
<line x1="500" y1="380" x2="480" y2="600"/>
|
|
<line x1="750" y1="380" x2="770" y2="600"/>
|
|
</g>
|
|
<!-- Green Safety Containment Strip -->
|
|
<rect x="0" y="376" width="1000" height="8" fill="#10b981"/>
|
|
|
|
<!-- 5. MAIN FOREGROUND LABORATORY WORKBENCH (From Reference) -->
|
|
<g transform="translate(130, 310)">
|
|
<!-- Frame & Legs -->
|
|
<rect x="20" y="80" width="24" height="180" fill="#1e293b"/>
|
|
<rect x="760" y="80" width="24" height="180" fill="#1e293b"/>
|
|
<rect x="20" y="240" width="764" height="18" fill="#334155"/>
|
|
|
|
<!-- Center/Right Under-Desk Storage Cabinets (From Reference) -->
|
|
<g transform="translate(420, 80)">
|
|
<rect width="360" height="160" fill="#1e293b" stroke="#334155" stroke-width="2"/>
|
|
<!-- Cabinet Doors -->
|
|
<rect x="12" y="10" width="160" height="140" fill="url(#chemCabGrad)" stroke="#1d4ed8" stroke-width="1.5"/>
|
|
<rect x="188" y="10" width="160" height="140" fill="url(#chemCabGrad)" stroke="#1d4ed8" stroke-width="1.5"/>
|
|
<!-- Chrome Handles -->
|
|
<rect x="155" y="65" width="8" height="30" rx="3" fill="#ffffff"/>
|
|
<rect x="198" y="65" width="8" height="30" rx="3" fill="#ffffff"/>
|
|
</g>
|
|
|
|
<!-- Rolling Lab Stool (From Reference) -->
|
|
<g transform="translate(310, 110)">
|
|
<ellipse cx="30" cy="10" rx="30" ry="10" fill="#334155"/>
|
|
<rect x="27" y="15" width="6" height="100" fill="#cbd5e1"/>
|
|
<!-- Wheels -->
|
|
<polygon points="30,115 10,135 50,135" fill="#1e293b"/>
|
|
</g>
|
|
|
|
<!-- Heavy White Polypropylene / Ceramic Lab Tabletop (From Reference) -->
|
|
<rect x="0" y="60" width="810" height="28" rx="4" fill="url(#chemTableGrad)" stroke="#94a3b8" stroke-width="2"/>
|
|
<rect x="0" y="60" width="810" height="6" fill="#f8fafc"/>
|
|
|
|
<!-- GLASSWARE & CHEMICAL CONTAINERS ON WORKBENCH (Matching Reference Image) -->
|
|
<!-- Large Boiling Round-Bottom Flask with Cyan Liquid -->
|
|
<g transform="translate(420, -25)">
|
|
<circle cx="35" cy="50" r="32" fill="none" stroke="#ffffff" stroke-width="2.5"/>
|
|
<circle cx="35" cy="50" r="32" fill="rgba(255,255,255,0.15)"/>
|
|
<circle cx="35" cy="54" r="28" fill="url(#liquidCyanGrad)"/>
|
|
<rect x="30" y="0" width="10" height="28" fill="none" stroke="#ffffff" stroke-width="2"/>
|
|
<!-- Stand -->
|
|
<rect x="20" y="78" width="30" height="8" rx="2" fill="#1e293b"/>
|
|
</g>
|
|
|
|
<!-- Conical Erlenmeyer Flask 1 -->
|
|
<g transform="translate(490, 5)">
|
|
<polygon points="18,0 26,0 40,55 4,55" fill="none" stroke="#ffffff" stroke-width="2.5"/>
|
|
<polygon points="18,0 26,0 40,55 4,55" fill="rgba(255,255,255,0.15)"/>
|
|
<polygon points="7,55 37,55 33,25 11,25" fill="url(#liquidCyanGrad)"/>
|
|
</g>
|
|
|
|
<!-- Amber Chemical Bottle with Dropper -->
|
|
<g transform="translate(545, 15)">
|
|
<rect width="22" height="45" rx="3" fill="url(#liquidAmberGrad)" stroke="#78350f" stroke-width="1.5"/>
|
|
<rect x="7" y="-8" width="8" height="10" fill="#ffffff"/>
|
|
</g>
|
|
|
|
<!-- Large Conical Flask 2 -->
|
|
<g transform="translate(580, -10)">
|
|
<polygon points="22,0 32,0 52,70 2,70" fill="none" stroke="#ffffff" stroke-width="2.5"/>
|
|
<polygon points="22,0 32,0 52,70 2,70" fill="rgba(255,255,255,0.15)"/>
|
|
<polygon points="5,70 49,70 43,30 11,30" fill="url(#liquidCyanGrad)"/>
|
|
</g>
|
|
|
|
<!-- Transfer Beakers & Pipette -->
|
|
<g transform="translate(650, 20)">
|
|
<rect width="32" height="40" rx="3" fill="rgba(255,255,255,0.2)" stroke="#ffffff" stroke-width="2"/>
|
|
<rect x="2" y="15" width="28" height="23" fill="url(#liquidCyanGrad)"/>
|
|
</g>
|
|
|
|
<!-- Chemical Dispensing Drum in Background of Bench -->
|
|
<g transform="translate(695, -15)">
|
|
<rect width="55" height="75" rx="6" fill="#0284c7" stroke="#075985" stroke-width="2"/>
|
|
<line x1="0" y1="24" x2="55" y2="24" stroke="#38bdf8" stroke-width="2.5"/>
|
|
<line x1="0" y1="52" x2="55" y2="52" stroke="#38bdf8" stroke-width="2.5"/>
|
|
<!-- GHS Diamond -->
|
|
<g transform="translate(19, 32) rotate(45)">
|
|
<rect width="14" height="14" fill="#ffffff" stroke="#ef4444" stroke-width="1.5"/>
|
|
<text x="7" y="10" font-size="7" text-anchor="middle" transform="rotate(-45 7 7)">🧪</text>
|
|
</g>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
`;
|
|
}
|
|
}
|
|
|
|
renderLockerItems() {
|
|
this.dom.lockerGrid.innerHTML = this.currentScenario.lockerItems.map((item, idx) => {
|
|
const isEquipped = this.equippedItems.has(item.id);
|
|
return `
|
|
<div class="ppe-item-card ${isEquipped ? 'equipped' : ''}" data-item-id="${item.id}" data-slot="${item.slot}" title="${item.spec}">
|
|
<span class="item-key-badge">${idx + 1}</span>
|
|
<div class="item-toggle-indicator">${isEquipped ? '✓' : ''}</div>
|
|
<div class="item-icon-box">${item.icon}</div>
|
|
<span class="item-name">${item.name}</span>
|
|
</div>
|
|
`;
|
|
}).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 += `
|
|
<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>
|
|
<span class="card-hazard-text">${chk.consequenceDesc}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
// Render all equipped distractors that need to be removed
|
|
allEquippedDistractors.forEach(dist => {
|
|
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>
|
|
<span class="card-hazard-text">${dist.rejectReason}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
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();
|
|
});
|