1105 lines
48 KiB
JavaScript
1105 lines
48 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'),
|
|
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 => `
|
|
<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) {
|
|
if (!this.dom.sceneBackdrop) return;
|
|
|
|
if (scenarioId === 'grinding') {
|
|
// SCENARIO 1: Vector Industrial Fabrication & Grinding Workshop (Matching Reference Image)
|
|
this.dom.sceneBackdrop.innerHTML = `
|
|
<svg viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid slice" class="env-svg-stage">
|
|
<defs>
|
|
<linearGradient id="grindSkyGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#3b6998"/>
|
|
<stop offset="60%" stop-color="#5486b8"/>
|
|
<stop offset="100%" stop-color="#72a1d2"/>
|
|
</linearGradient>
|
|
<linearGradient id="grindLampGlow" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="rgba(254, 240, 138, 0.55)"/>
|
|
<stop offset="60%" stop-color="rgba(254, 240, 138, 0.22)"/>
|
|
<stop offset="100%" stop-color="rgba(254, 240, 138, 0.0)"/>
|
|
</linearGradient>
|
|
<linearGradient id="grindBenchGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#94a3b8"/>
|
|
<stop offset="100%" stop-color="#64748b"/>
|
|
</linearGradient>
|
|
<linearGradient id="grindFloorGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#475569"/>
|
|
<stop offset="50%" stop-color="#334155"/>
|
|
<stop offset="100%" stop-color="#1e293b"/>
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- 1. BLUE FACTORY WALL & TALL MULTI-PANE WINDOWS -->
|
|
<rect width="1000" height="380" fill="url(#grindSkyGrad)"/>
|
|
|
|
<!-- Distant Sky & Structure Silhouettes Through Windows -->
|
|
<g fill="#2c527c" opacity="0.4">
|
|
<rect x="80" y="120" width="140" height="260"/>
|
|
<rect x="280" y="90" width="180" height="290"/>
|
|
<rect x="520" y="140" width="120" height="240"/>
|
|
<rect x="700" y="100" width="220" height="280"/>
|
|
</g>
|
|
|
|
<!-- Window Framework Mullions -->
|
|
<g stroke="#1e3a5f" stroke-width="4" opacity="0.8">
|
|
<line x1="0" y1="70" x2="1000" y2="70"/>
|
|
<line x1="0" y1="180" x2="1000" y2="180"/>
|
|
<line x1="0" y1="280" x2="1000" y2="280"/>
|
|
<line x1="160" y1="0" x2="160" y2="380"/>
|
|
<line x1="320" y1="0" x2="320" y2="380"/>
|
|
<line x1="610" y1="0" x2="610" y2="380"/>
|
|
<line x1="860" y1="0" x2="860" y2="380"/>
|
|
</g>
|
|
|
|
<!-- Overhead Gantry & Ceiling Beams -->
|
|
<rect x="0" y="24" width="1000" height="30" fill="#1e293b"/>
|
|
<rect x="0" y="54" width="1000" height="8" fill="#0f172a"/>
|
|
|
|
<!-- 2. STRUCTURAL PILLARS WITH HAZARD CHEVRONS & PPE SIGN -->
|
|
<!-- Left Column with Hazard Stripes -->
|
|
<rect x="240" y="30" width="56" height="350" fill="#334155"/>
|
|
<g transform="translate(240, 260)">
|
|
<rect width="56" height="120" fill="#18181b"/>
|
|
<!-- Yellow/Black Chevrons -->
|
|
<polygon points="0,0 28,0 56,28 56,56 0,0" fill="#facc15"/>
|
|
<polygon points="0,35 56,91 56,119 0,63" fill="#facc15"/>
|
|
<polygon points="0,95 25,120 0,120" fill="#facc15"/>
|
|
</g>
|
|
|
|
<!-- Green "WEAR PPE" Sign on Pillar (From Reference) -->
|
|
<g transform="translate(155, 170)">
|
|
<rect width="90" height="90" rx="8" fill="#059669" stroke="#ffffff" stroke-width="3"/>
|
|
<!-- PPE Helmet/Goggles Symbol -->
|
|
<circle cx="45" cy="38" r="22" fill="#ffffff" opacity="0.2"/>
|
|
<path d="M 28 36 C 28 22 62 22 62 36 Z" fill="#ffffff"/>
|
|
<rect x="25" y="36" width="40" height="4" rx="2" fill="#ffffff"/>
|
|
<ellipse cx="37" cy="45" rx="6" ry="4" fill="none" stroke="#ffffff" stroke-width="2"/>
|
|
<ellipse cx="53" cy="45" rx="6" ry="4" fill="none" stroke="#ffffff" stroke-width="2"/>
|
|
<text x="45" y="66" fill="#ffffff" font-size="11" font-weight="900" font-family="sans-serif" text-anchor="middle">WEAR</text>
|
|
<text x="45" y="78" fill="#ffffff" font-size="11" font-weight="900" font-family="sans-serif" text-anchor="middle">PPE</text>
|
|
</g>
|
|
|
|
<!-- Right Background Storage Racks with Hazard Stripes -->
|
|
<g transform="translate(875, 140)">
|
|
<rect width="110" height="240" fill="#1e293b" stroke="#334155" stroke-width="3"/>
|
|
<line x1="0" y1="60" x2="110" y2="60" stroke="#475569" stroke-width="4"/>
|
|
<line x1="0" y1="120" x2="110" y2="120" stroke="#475569" stroke-width="4"/>
|
|
<line x1="0" y1="180" x2="110" y2="180" stroke="#475569" stroke-width="4"/>
|
|
<!-- Column hazard chevrons -->
|
|
<g fill="#facc15">
|
|
<polygon points="0,0 20,0 0,20"/>
|
|
<polygon points="0,40 20,20 20,40 0,60"/>
|
|
<polygon points="0,80 20,60 20,80 0,100"/>
|
|
<polygon points="0,120 20,100 20,120 0,140"/>
|
|
</g>
|
|
</g>
|
|
|
|
<!-- Red Tool Cabinet (Left) -->
|
|
<g transform="translate(0, 255)">
|
|
<rect width="150" height="125" rx="4" fill="#dc2626" stroke="#991b1b" stroke-width="3"/>
|
|
<!-- Drawers with Silver Handles -->
|
|
<rect x="10" y="15" width="130" height="22" rx="2" fill="#b91c1c"/>
|
|
<rect x="45" y="24" width="60" height="4" rx="2" fill="#e2e8f0"/>
|
|
<rect x="10" y="44" width="130" height="22" rx="2" fill="#b91c1c"/>
|
|
<rect x="45" y="53" width="60" height="4" rx="2" fill="#e2e8f0"/>
|
|
<rect x="10" y="73" width="130" height="22" rx="2" fill="#b91c1c"/>
|
|
<rect x="45" y="82" width="60" height="4" rx="2" fill="#e2e8f0"/>
|
|
<!-- Top Tool Box -->
|
|
<rect x="15" y="-22" width="55" height="22" rx="3" fill="#eab308" stroke="#ca8a04" stroke-width="2"/>
|
|
<rect x="80" y="-26" width="40" height="26" rx="3" fill="#0284c7" stroke="#0369a1" stroke-width="2"/>
|
|
</g>
|
|
|
|
<!-- 3. PENDANT CONICAL LAMPS & BRIGHT LIGHT CONES (From Reference) -->
|
|
<!-- Left Lamp & Glow Cone -->
|
|
<g transform="translate(190, 50)">
|
|
<line x1="0" y1="0" x2="0" y2="40" stroke="#0f172a" stroke-width="3"/>
|
|
<polygon points="0,35 -32,60 32,60" fill="#3b82f6" stroke="#1d4ed8" stroke-width="1.5"/>
|
|
<!-- Spotlight Cone -->
|
|
<polygon points="-32,60 32,60 100,380 -100,380" fill="url(#grindLampGlow)"/>
|
|
<ellipse cx="0" cy="60" rx="30" ry="7" fill="#ffffff"/>
|
|
</g>
|
|
|
|
<!-- Right Lamp & Glow Cone -->
|
|
<g transform="translate(790, 50)">
|
|
<line x1="0" y1="0" x2="0" y2="40" stroke="#0f172a" stroke-width="3"/>
|
|
<polygon points="0,35 -32,60 32,60" fill="#3b82f6" stroke="#1d4ed8" stroke-width="1.5"/>
|
|
<!-- Spotlight Cone -->
|
|
<polygon points="-32,60 32,60 100,380 -100,380" fill="url(#grindLampGlow)"/>
|
|
<ellipse cx="0" cy="60" rx="30" ry="7" fill="#ffffff"/>
|
|
</g>
|
|
|
|
<!-- 4. WORKSHOP FLOOR & YELLOW HAZARD BOUNDARY LINES -->
|
|
<rect x="0" y="380" width="1000" height="220" fill="url(#grindFloorGrad)"/>
|
|
<line x1="0" y1="380" x2="1000" y2="380" stroke="#334155" stroke-width="3"/>
|
|
|
|
<!-- Yellow Floor Safety Line Markings (From Reference) -->
|
|
<g fill="none" stroke="#facc15" stroke-width="12" opacity="0.95">
|
|
<line x1="0" y1="560" x2="300" y2="430"/>
|
|
<line x1="280" y1="430" x2="550" y2="600"/>
|
|
<line x1="550" y1="600" x2="550" y2="380"/>
|
|
</g>
|
|
|
|
<!-- Stacks of Rectangular Steel Hollow Tubing on Left Floor -->
|
|
<g transform="translate(0, 420)">
|
|
<rect x="0" y="0" width="110" height="40" fill="#94a3b8" stroke="#475569" stroke-width="3"/>
|
|
<rect x="10" y="8" width="90" height="24" fill="#334155"/>
|
|
<rect x="90" y="42" width="55" height="40" fill="#94a3b8" stroke="#475569" stroke-width="3"/>
|
|
<rect x="98" y="50" width="39" height="24" fill="#334155"/>
|
|
</g>
|
|
|
|
<!-- 5. HEAVY INDUSTRIAL WORKBENCH, BLUE VISE & ANGLE GRINDER (Right) -->
|
|
<!-- Anti-slip Textured Floor Mat under Table -->
|
|
<rect x="490" y="430" width="450" height="85" rx="4" fill="#1e293b" stroke="#334155" stroke-width="2"/>
|
|
<g fill="#0f172a" opacity="0.7">
|
|
<circle cx="530" cy="450" r="4"/><circle cx="570" cy="450" r="4"/><circle cx="610" cy="450" r="4"/>
|
|
<circle cx="650" cy="450" r="4"/><circle cx="690" cy="450" r="4"/><circle cx="730" cy="450" r="4"/>
|
|
<circle cx="770" cy="450" r="4"/><circle cx="810" cy="450" r="4"/><circle cx="850" cy="450" r="4"/>
|
|
<circle cx="530" cy="480" r="4"/><circle cx="570" cy="480" r="4"/><circle cx="610" cy="480" r="4"/>
|
|
<circle cx="650" cy="480" r="4"/><circle cx="690" cy="480" r="4"/><circle cx="730" cy="480" r="4"/>
|
|
<circle cx="770" cy="480" r="4"/><circle cx="810" cy="480" r="4"/><circle cx="850" cy="480" r="4"/>
|
|
</g>
|
|
|
|
<!-- Workbench Legs & Frame -->
|
|
<g transform="translate(560, 310)">
|
|
<!-- Heavy Steel Frame -->
|
|
<rect x="20" y="60" width="400" height="200" fill="none" stroke="#1e293b" stroke-width="16"/>
|
|
<!-- Metal Bench Top -->
|
|
<rect x="0" y="52" width="440" height="20" rx="3" fill="url(#grindBenchGrad)" stroke="#334155" stroke-width="2"/>
|
|
|
|
<!-- Steel I-Beam Clamped for Grinding (From Reference) -->
|
|
<g transform="translate(90, -10)">
|
|
<rect x="0" y="0" width="350" height="62" fill="#cbd5e1" stroke="#475569" stroke-width="2"/>
|
|
<rect x="0" y="0" width="350" height="12" fill="#e2e8f0"/>
|
|
<rect x="0" y="50" width="350" height="12" fill="#94a3b8"/>
|
|
</g>
|
|
|
|
<!-- Blue Industrial Bench Vise Clamp -->
|
|
<g transform="translate(5, 0)">
|
|
<!-- Base clamped to bench -->
|
|
<rect x="55" y="50" width="40" height="45" rx="3" fill="#1d4ed8" stroke="#1e40af" stroke-width="2"/>
|
|
<!-- Vise Body -->
|
|
<path d="M 25 15 L 85 15 L 85 55 L 45 55 Z" fill="#2563eb" stroke="#1d4ed8" stroke-width="2"/>
|
|
<!-- Clamp Jaws holding I-beam -->
|
|
<rect x="85" y="8" width="18" height="42" fill="#94a3b8" stroke="#475569" stroke-width="1.5"/>
|
|
<rect x="10" y="24" width="20" height="12" rx="2" fill="#cbd5e1"/>
|
|
</g>
|
|
|
|
<!-- Yellow Angle Grinder on Tabletop (From Reference) -->
|
|
<g transform="translate(190, 18)">
|
|
<!-- Black Grinding Disc -->
|
|
<ellipse cx="40" cy="22" rx="24" ry="24" fill="#27272a" stroke="#09090b" stroke-width="2"/>
|
|
<ellipse cx="40" cy="22" rx="8" ry="8" fill="#94a3b8"/>
|
|
<!-- Metal Spindle Flange -->
|
|
<circle cx="40" cy="22" r="3" fill="#ffffff"/>
|
|
<!-- Yellow Motor Housing -->
|
|
<rect x="60" y="10" width="75" height="24" rx="6" fill="#facc15" stroke="#ca8a04" stroke-width="2"/>
|
|
<rect x="110" y="13" width="20" height="18" fill="#eab308"/>
|
|
<!-- Black Rear Handle -->
|
|
<path d="M 135 15 L 165 18 L 165 26 L 135 29 Z" fill="#18181b"/>
|
|
<!-- Black Power Cord trailing down -->
|
|
<path d="M 165 22 Q 210 30 200 90 Q 190 150 240 240" fill="none" stroke="#09090b" stroke-width="4"/>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
`;
|
|
} else {
|
|
// SCENARIO 2: Vector Industrial Chemical Transfer Station
|
|
this.dom.sceneBackdrop.innerHTML = `
|
|
<svg viewBox="0 0 1000 600" preserveAspectRatio="xMidYMid slice" class="env-svg-stage">
|
|
<defs>
|
|
<linearGradient id="chemSkyGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#bae6fd"/>
|
|
<stop offset="60%" stop-color="#e0f2fe"/>
|
|
<stop offset="100%" stop-color="#f0fdf4"/>
|
|
</linearGradient>
|
|
<linearGradient id="chemTableGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#ffffff"/>
|
|
<stop offset="50%" stop-color="#f1f5f9"/>
|
|
<stop offset="100%" stop-color="#cbd5e1"/>
|
|
</linearGradient>
|
|
<linearGradient id="chemFloorGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#334155"/>
|
|
<stop offset="100%" stop-color="#1e293b"/>
|
|
</linearGradient>
|
|
<linearGradient id="liquidCyanGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#38bdf8"/>
|
|
<stop offset="100%" stop-color="#0284c7"/>
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- Clean Laboratory Tile Wall -->
|
|
<rect width="1000" height="380" fill="url(#chemSkyGrad)"/>
|
|
<g stroke="#cbd5e1" stroke-width="1.5" opacity="0.6">
|
|
<line x1="0" y1="80" x2="1000" y2="80"/>
|
|
<line x1="0" y1="160" x2="1000" y2="160"/>
|
|
<line x1="0" y1="240" x2="1000" y2="240"/>
|
|
<line x1="0" y1="320" x2="1000" y2="320"/>
|
|
<line x1="120" y1="0" x2="120" y2="380"/>
|
|
<line x1="240" y1="0" x2="240" y2="380"/>
|
|
<line x1="360" y1="0" x2="360" y2="380"/>
|
|
<line x1="480" y1="0" x2="480" y2="380"/>
|
|
<line x1="600" y1="0" x2="600" y2="380"/>
|
|
<line x1="720" y1="0" x2="720" y2="380"/>
|
|
<line x1="840" y1="0" x2="840" y2="380"/>
|
|
</g>
|
|
|
|
<!-- Fume Extraction Hood Canopy -->
|
|
<rect x="180" y="0" width="640" height="40" fill="#475569"/>
|
|
<polygon points="180,40 240,110 760,110 820,40" fill="#64748b"/>
|
|
<rect x="240" y="110" width="520" height="8" fill="#facc15"/>
|
|
|
|
<!-- Emergency Eye Wash Station (Left Wall) -->
|
|
<g transform="translate(45, 180)">
|
|
<rect width="40" height="60" rx="4" fill="#059669"/>
|
|
<text x="20" y="24" fill="#fff" font-size="9" font-weight="bold" text-anchor="middle">EYE</text>
|
|
<text x="20" y="36" fill="#fff" font-size="9" font-weight="bold" text-anchor="middle">WASH</text>
|
|
<circle cx="20" cy="48" r="6" fill="#38bdf8"/>
|
|
<line x1="20" y1="60" x2="20" y2="120" stroke="#94a3b8" stroke-width="4"/>
|
|
</g>
|
|
|
|
<!-- Industrial Lab Chemical Floor -->
|
|
<rect x="0" y="380" width="1000" height="220" fill="url(#chemFloorGrad)"/>
|
|
|
|
<!-- Heavy Chemical Dispensing Workbench -->
|
|
<g transform="translate(380, 320)">
|
|
<rect x="0" y="60" width="540" height="150" fill="#e2e8f0" stroke="#94a3b8" stroke-width="2"/>
|
|
<rect x="0" y="60" width="540" height="22" rx="3" fill="url(#chemTableGrad)" stroke="#64748b" stroke-width="1.5"/>
|
|
|
|
<!-- Chemical Glassware on Bench -->
|
|
<!-- Round Bottom Flask -->
|
|
<g transform="translate(70, -10)">
|
|
<circle cx="30" cy="46" r="26" fill="rgba(255,255,255,0.2)" stroke="#fff" stroke-width="2"/>
|
|
<circle cx="30" cy="48" r="22" fill="url(#liquidCyanGrad)"/>
|
|
<rect x="26" y="6" width="8" height="20" fill="none" stroke="#fff" stroke-width="2"/>
|
|
</g>
|
|
|
|
<!-- Erlenmeyer Conical Flask -->
|
|
<g transform="translate(140, 10)">
|
|
<polygon points="14,0 22,0 34,48 2,48" fill="rgba(255,255,255,0.2)" stroke="#fff" stroke-width="2"/>
|
|
<polygon points="5,48 31,48 26,24 10,24" fill="url(#liquidCyanGrad)"/>
|
|
</g>
|
|
|
|
<!-- 50L Chemical Drum -->
|
|
<g transform="translate(230, -30)">
|
|
<rect width="65" height="90" rx="8" fill="#0284c7" stroke="#0369a1" stroke-width="2"/>
|
|
<line x1="0" y1="30" x2="65" y2="30" stroke="#38bdf8" stroke-width="3"/>
|
|
<line x1="0" y1="60" x2="65" y2="60" stroke="#38bdf8" stroke-width="3"/>
|
|
<!-- Hazard Diamond -->
|
|
<g transform="translate(24, 38) rotate(45)">
|
|
<rect width="18" height="18" fill="#fff" stroke="#ef4444" stroke-width="2"/>
|
|
<text x="9" y="13" font-size="9" text-anchor="middle" transform="rotate(-45 9 9)">🧪</text>
|
|
</g>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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-toggle-indicator">
|
|
<span class="radio-check-mark">${isEquipped ? '✓' : ''}</span>
|
|
</div>
|
|
<div class="item-icon-box">${iconContent}</div>
|
|
<span class="item-name">${item.name}</span>
|
|
</div>
|
|
`;
|
|
}).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 += `
|
|
<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>
|
|
`;
|
|
});
|
|
|
|
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>
|
|
`;
|
|
});
|
|
|
|
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();
|
|
});
|