From c8e68a490146ab8198b5dea8cc126df5fb933fd9 Mon Sep 17 00:00:00 2001 From: Vishva Date: Fri, 25 Sep 2026 18:18:21 +0530 Subject: [PATCH] Initial commit: Safety Game --- .gitignore | 18 + css/styles.css | 1678 +++++++++++++++++++++++++++++++++++++++++++++++ index.html | 423 ++++++++++++ js/audio.js | 744 +++++++++++++++++++++ js/avatar.js | 336 ++++++++++ js/game.js | 629 ++++++++++++++++++ js/particles.js | 267 ++++++++ js/scenarios.js | 373 +++++++++++ 8 files changed, 4468 insertions(+) create mode 100644 .gitignore create mode 100644 css/styles.css create mode 100644 index.html create mode 100644 js/audio.js create mode 100644 js/avatar.js create mode 100644 js/game.js create mode 100644 js/particles.js create mode 100644 js/scenarios.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce489d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Operating System Files +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +.DS_Store + +# Editor / IDE settings +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Temporary / Log files +*.log diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 0000000..ef2dab8 --- /dev/null +++ b/css/styles.css @@ -0,0 +1,1678 @@ +/* ========================================================================== + PPE SAFETY CHALLENGE - MODERN INDUSTRIAL SAFETY STYLESHEET + "Choose Right. Work Safe." + ========================================================================== */ + +:root { + /* Color Palette */ + --bg-dark: #0f141c; + --bg-card: #171f2c; + --bg-card-hover: #1e2838; + --bg-stage: #141b24; + --panel-border: #26354a; + + --safety-yellow: #facc15; + --safety-amber: #f59e0b; + --safety-orange: #fb923c; + --safety-red: #ef4444; + --safety-green: #10b981; + --safety-blue: #0ea5e9; + --safety-cyan: #06b6d4; + + --text-main: #f3f4f6; + --text-muted: #9ca3af; + --text-dim: #6b7280; + + --glow-yellow: rgba(250, 204, 21, 0.35); + --glow-green: rgba(16, 185, 129, 0.4); + --glow-red: rgba(239, 68, 68, 0.45); + --glow-blue: rgba(14, 165, 233, 0.35); + + --font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-display: 'Chakra Petch', 'Inter', sans-serif; + --font-mono: 'JetBrains Mono', monospace; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-full: 9999px; + + --transition-fast: 0.15s ease; + --transition-normal: 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Reset & Box Sizing */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100%; + height: 100%; + background-color: var(--bg-dark); + color: var(--text-main); + font-family: var(--font-main); + line-height: 1.5; + overflow-x: hidden; + user-select: none; + -webkit-font-smoothing: antialiased; +} + +/* App Container */ +.app-container { + display: flex; + flex-direction: column; + min-height: 100vh; + width: 100%; + max-width: 1600px; + margin: 0 auto; + padding: 0 16px 16px 16px; +} + +/* ========================================================================== + HEADER / TOP BAR + ========================================================================== */ + +.top-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px; + margin: 12px 0; + background: linear-gradient(90deg, #131a24 0%, #172233 100%); + border: 1px solid var(--panel-border); + border-radius: var(--radius-md); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); +} + +.brand-badge { + display: flex; + align-items: center; + gap: 12px; +} + +.safety-cross-icon { + width: 36px; + height: 36px; + background: var(--safety-yellow); + color: #000; + font-weight: 900; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + box-shadow: 0 0 12px var(--glow-yellow); +} + +.brand-text { + display: flex; + flex-direction: column; +} + +.game-title { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 1px; + color: #fff; +} + +.game-tagline { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 1.5px; + color: var(--safety-yellow); +} + +/* Scenario Stepper HUD */ +.scenario-stepper { + display: flex; + align-items: center; + gap: 10px; + background: rgba(0, 0, 0, 0.3); + padding: 6px 14px; + border-radius: var(--radius-full); + border: 1px solid var(--panel-border); +} + +.step-pill { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + border-radius: var(--radius-full); + font-size: 0.82rem; + font-weight: 600; + color: var(--text-dim); + background: transparent; + transition: var(--transition-normal); +} + +.step-pill .step-num { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--panel-border); + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; +} + +.step-pill.active { + background: rgba(250, 204, 21, 0.15); + color: var(--safety-yellow); + border: 1px solid var(--safety-yellow); +} + +.step-pill.active .step-num { + background: var(--safety-yellow); + color: #000; + font-weight: 800; +} + +.step-pill.completed { + color: var(--safety-green); +} + +.step-pill.completed .step-num { + background: var(--safety-green); + color: #fff; +} + +.step-connector { + width: 24px; + height: 2px; + background: var(--panel-border); +} + +/* HUD Right: Score & Audio Controls */ +.hud-right { + display: flex; + align-items: center; + gap: 12px; +} + +.score-display { + display: flex; + align-items: center; + gap: 8px; + background: rgba(0, 0, 0, 0.4); + padding: 6px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--panel-border); +} + +.score-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 1px; + color: var(--text-muted); +} + +.score-val { + font-family: var(--font-mono); + font-size: 1.15rem; + font-weight: 700; + color: var(--safety-yellow); + min-width: 40px; + text-align: right; +} + +.score-stars { + color: var(--safety-yellow); + font-size: 1rem; + letter-spacing: 2px; +} + +.btn-icon { + width: 38px; + height: 38px; + border-radius: var(--radius-md); + background: var(--bg-card); + border: 1px solid var(--panel-border); + color: var(--text-main); + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--transition-fast); +} + +.btn-icon:hover { + background: var(--bg-card-hover); + border-color: var(--safety-yellow); + transform: translateY(-1px); +} + +/* ========================================================================== + SCREEN VIEWS MANAGEMENT + ========================================================================== */ + +.main-viewport { + flex: 1; + display: flex; + position: relative; +} + +.screen-view { + display: none; + width: 100%; + animation: fadeIn 0.3s ease forwards; +} + +.screen-view.active { + display: flex; + flex-direction: column; +} + +@keyframes fadeIn { + from { opacity: 0; transform: scale(0.99); } + to { opacity: 1; transform: scale(1); } +} + +/* ========================================================================== + SCREEN 1: START / HERO VIEW + ========================================================================== */ + +.intro-card { + max-width: 820px; + margin: 30px auto; + background: linear-gradient(180deg, #16202e 0%, #101722 100%); + border: 1px solid var(--panel-border); + border-radius: var(--radius-lg); + padding: 40px; + text-align: center; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: center; +} + +.hero-graphic { + margin-bottom: 20px; + position: relative; +} + +.safety-shield-animated { + width: 100px; + height: 100px; + position: relative; +} + +.shield-svg { + width: 100%; + height: 100%; +} + +.shield-outline { + fill: #1e293b; + stroke: var(--safety-yellow); + stroke-width: 4; +} + +.shield-plus { + stroke: var(--safety-yellow); + stroke-width: 8; + stroke-linecap: round; +} + +.pulse-ring { + position: absolute; + top: -10px; + left: -10px; + right: -10px; + bottom: -10px; + border-radius: 50%; + border: 2px solid var(--safety-yellow); + opacity: 0; + animation: pulseAnim 2s infinite ease-out; +} + +@keyframes pulseAnim { + 0% { transform: scale(0.8); opacity: 0.8; } + 100% { transform: scale(1.3); opacity: 0; } +} + +.hero-title { + font-family: var(--font-display); + font-size: 2.5rem; + font-weight: 700; + letter-spacing: 2px; + color: #fff; + margin-bottom: 4px; +} + +.hero-subtitle { + font-size: 1.1rem; + font-weight: 700; + color: var(--safety-yellow); + letter-spacing: 3px; + margin-bottom: 24px; + text-transform: uppercase; +} + +.mission-briefing { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--panel-border); + border-radius: var(--radius-md); + padding: 24px; + margin-bottom: 30px; + text-align: center; + max-width: 700px; +} + +.mission-briefing h3 { + font-size: 1.1rem; + font-weight: 700; + color: #fff; + margin-bottom: 8px; +} + +.mission-briefing p { + color: var(--text-muted); + font-size: 0.95rem; + margin-bottom: 20px; +} + +.learning-flow-banner { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 10px; + background: #111827; + padding: 12px 18px; + border-radius: var(--radius-md); + border: 1px solid #374151; +} + +.flow-item { + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 700; + color: var(--safety-yellow); +} + +.flow-arrow { + color: var(--text-dim); + font-size: 0.85rem; +} + +.start-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.controls-hint { + font-size: 0.8rem; + color: var(--text-dim); +} + +/* ========================================================================== + BUTTONS & COMMON UI + ========================================================================== */ + +.btn { + font-family: var(--font-display); + font-weight: 700; + font-size: 0.95rem; + letter-spacing: 0.5px; + padding: 10px 22px; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: var(--transition-normal); + text-decoration: none; +} + +.btn-primary { + background: var(--safety-yellow); + color: #000; +} + +.btn-primary:hover { + background: #fde047; + transform: translateY(-2px); + box-shadow: 0 6px 20px var(--glow-yellow); +} + +.btn-secondary { + background: var(--bg-card); + border: 1px solid var(--panel-border); + color: var(--text-main); +} + +.btn-secondary:hover { + background: var(--bg-card-hover); + border-color: var(--safety-yellow); +} + +.btn-action { + background: linear-gradient(135deg, var(--safety-amber) 0%, var(--safety-yellow) 100%); + color: #000; + font-size: 1rem; + font-weight: 800; + padding: 14px 20px; +} + +.btn-action:hover { + filter: brightness(1.1); + transform: translateY(-2px); + box-shadow: 0 6px 20px var(--glow-yellow); +} + +.btn-success { + background: var(--safety-green); + color: #fff; + font-size: 1rem; + padding: 12px 24px; +} + +.btn-success:hover { + background: #059669; + box-shadow: 0 6px 20px var(--glow-green); +} + +.btn-warning { + background: var(--safety-orange); + color: #000; + font-weight: 800; + padding: 12px 24px; +} + +.btn-warning:hover { + background: #f97316; + box-shadow: 0 6px 20px rgba(249, 115, 22, 0.4); +} + +.btn-ghost { + background: transparent; + border: 1px dashed var(--panel-border); + color: var(--text-muted); +} + +.btn-ghost:hover { + border-color: var(--text-main); + color: var(--text-main); +} + +.btn-large { + padding: 14px 32px; + font-size: 1.1rem; +} + +.btn-small { + padding: 6px 12px; + font-size: 0.8rem; +} + +.btn-full { + width: 100%; +} + +.glow-ready { + animation: readyPulse 2s infinite ease-in-out; +} + +@keyframes readyPulse { + 0%, 100% { box-shadow: 0 0 10px rgba(250, 204, 21, 0.3); } + 50% { box-shadow: 0 0 25px rgba(250, 204, 21, 0.7); } +} + +/* Card Panels */ +.card-panel { + background: var(--bg-card); + border: 1px solid var(--panel-border); + border-radius: var(--radius-lg); + padding: 18px; + display: flex; + flex-direction: column; +} + +.panel-header { + margin-bottom: 14px; +} + +.card-subtitle { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 1.2px; + color: var(--text-dim); + margin-bottom: 6px; + text-transform: uppercase; +} + +/* ========================================================================== + SCREEN 2: GAMEPLAY 3-COLUMN GRID + ========================================================================== */ + +.gameplay-grid { + display: grid; + grid-template-columns: 300px 1fr 390px; + gap: 16px; + width: 100%; + flex: 1; +} + +/* Column 1: Hazard Panel */ +.sidebar-hazard-panel { + gap: 12px; + overflow-y: auto; +} + +.scenario-badge { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; + color: var(--safety-cyan); + background: rgba(6, 182, 212, 0.12); + padding: 3px 8px; + border-radius: var(--radius-sm); + margin-bottom: 4px; +} + +.scenario-title { + font-family: var(--font-display); + font-size: 1.15rem; + color: #fff; + line-height: 1.25; +} + +.situation-card { + background: rgba(0, 0, 0, 0.25); + padding: 12px; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.situation-desc { + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.4; +} + +.hazard-identification-card { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); + padding: 12px; + border-radius: var(--radius-md); +} + +.hazard-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.hazard-list-item { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 0.8rem; + color: #fca5a5; + line-height: 1.3; +} + +.hazard-list-item::before { + content: "⚠️"; + font-size: 0.75rem; + margin-top: 1px; +} + +.equipped-status-card { + background: rgba(0, 0, 0, 0.25); + padding: 12px; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.coverage-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.coverage-item { + display: flex; + align-items: center; + justify-content: space-between; + background: #121924; + padding: 6px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--panel-border); + font-size: 0.75rem; +} + +.coverage-item .cov-label { + color: var(--text-muted); + font-weight: 500; +} + +.coverage-item .cov-status.missing { + color: var(--safety-red); + font-weight: 700; +} + +.coverage-item .cov-status.protected { + color: var(--safety-green); + font-weight: 700; +} + +.action-dock { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Column 2: Center Stage Panel */ +.center-stage-panel { + padding: 0; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + background: var(--bg-stage); +} + +.scene-environment-container { + flex: 1; + position: relative; + min-height: 460px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.stage-fx-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 15; +} + +/* Backdrop Layer */ +.scene-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1; + background: radial-gradient(circle at 50% 30%, #1a2538 0%, #0d121a 80%); +} + +.industrial-wall { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 65%; + background: + linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px); + background-size: 40px 40px; +} + +.workbench { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 35%; + background: linear-gradient(180deg, #1b2430 0%, #10161f 100%); + border-top: 3px solid #2d3c52; +} + +.warning-tape { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 8px; + background: repeating-linear-gradient( + 45deg, + var(--safety-yellow), + var(--safety-yellow) 15px, + #000 15px, + #000 30px + ); + opacity: 0.7; +} + +.workpiece-element, .equipment-element { + position: absolute; + pointer-events: none; +} + +/* Scenario 1 Grinding Props */ +.stage-grinding .workpiece-element { + right: 18%; + bottom: 80px; + width: 160px; + height: 80px; + background: linear-gradient(135deg, #475569 0%, #334155 100%); + border: 2px solid #64748b; + border-radius: 4px; + box-shadow: 0 10px 25px rgba(0,0,0,0.6); +} + +.stage-grinding .equipment-element { + right: 22%; + bottom: 120px; + width: 120px; + height: 45px; + background: #dc2626; + border-radius: 6px; + box-shadow: 0 6px 15px rgba(0,0,0,0.5); + border: 2px solid #991b1b; + transform: rotate(-15deg); +} + +.stage-grinding .equipment-element::after { + content: ""; + position: absolute; + left: -20px; + top: 8px; + width: 32px; + height: 32px; + border-radius: 50%; + background: #94a3b8; + border: 3px solid #475569; +} + +/* Scenario 2 Chemical Props */ +.stage-chemical .workpiece-element { + right: 15%; + bottom: 60px; + width: 220px; + height: 140px; + background: rgba(14, 165, 233, 0.05); + border: 2px solid rgba(14, 165, 233, 0.3); + border-radius: 8px; +} + +.stage-chemical .equipment-element { + right: 20%; + bottom: 80px; + width: 50px; + height: 75px; + background: rgba(16, 185, 129, 0.25); + border: 2px solid #10b981; + border-radius: 4px; +} + +/* Worker Avatar Area */ +.avatar-wrapper { + position: relative; + z-index: 10; + width: 280px; + height: 420px; + display: flex; + align-items: center; + justify-content: center; +} + +.worker-svg { + width: 100%; + height: 100%; + filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.5)); + transition: transform 0.3s ease; +} + +/* Overlays: Consequence Freeze & Success */ +.consequence-freeze-overlay, .success-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 30; + display: none; + align-items: center; + justify-content: center; + padding: 24px; + backdrop-filter: blur(6px); + animation: popOverlay 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.consequence-freeze-overlay.active, .success-overlay.active { + display: flex; +} + +@keyframes popOverlay { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +.consequence-freeze-overlay { + background: rgba(18, 4, 4, 0.88); + border: 3px solid var(--safety-red); +} + +.freeze-banner { + background: #180c0c; + border: 2px solid var(--safety-red); + border-radius: var(--radius-lg); + padding: 24px; + width: 95%; + max-width: 640px; + max-height: 90vh; + display: flex; + flex-direction: column; + text-align: center; + box-shadow: 0 0 50px var(--glow-red); + overflow-y: auto; +} + +.hazard-flasher { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.85rem; + font-weight: 800; + color: #fff; + background: var(--safety-red); + padding: 5px 14px; + border-radius: var(--radius-sm); + margin-bottom: 10px; + animation: flashRed 0.8s infinite alternate; + letter-spacing: 0.5px; +} + +@keyframes flashRed { + from { opacity: 0.85; transform: scale(0.98); } + to { opacity: 1; filter: brightness(1.25); transform: scale(1.02); } +} + +.consequence-title { + font-family: var(--font-display); + font-size: 1.35rem; + color: #fff; + margin-bottom: 6px; +} + +.consequence-desc { + font-size: 0.85rem; + color: #fca5a5; + line-height: 1.35; + margin-bottom: 14px; +} + +.missing-items-box { + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(239, 68, 68, 0.35); + padding: 12px; + border-radius: var(--radius-md); + margin-bottom: 16px; + text-align: left; +} + +.missing-box-header { + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(239, 68, 68, 0.2); +} + +.missing-items-box .box-label { + font-size: 0.72rem; + font-weight: 800; + color: var(--safety-yellow); + letter-spacing: 1px; +} + +.missing-hazard-cards { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 240px; + overflow-y: auto; + padding-right: 4px; +} + +/* Custom Scrollbar for Missing Cards */ +.missing-hazard-cards::-webkit-scrollbar { + width: 6px; +} +.missing-hazard-cards::-webkit-scrollbar-thumb { + background: rgba(239, 68, 68, 0.4); + border-radius: 4px; +} + +.hazard-card-item { + display: flex; + align-items: flex-start; + gap: 10px; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.35); + border-left: 4px solid var(--safety-red); + padding: 8px 10px; + border-radius: var(--radius-sm); + transition: var(--transition-fast); +} + +.hazard-card-item:hover { + background: rgba(239, 68, 68, 0.16); + border-color: var(--safety-red); +} + +.hazard-card-item.distractor-card { + border-left-color: var(--safety-amber); + background: rgba(245, 158, 11, 0.1); + border-color: rgba(245, 158, 11, 0.4); +} + +.hazard-card-item .card-icon { + font-size: 1.25rem; + line-height: 1; + padding-top: 2px; +} + +.hazard-card-item .card-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.hazard-card-item .card-header-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.hazard-card-item .card-item-name { + font-size: 0.82rem; + font-weight: 700; + color: #fff; +} + +.hazard-card-item .card-slot-badge { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 700; + color: var(--safety-red); + background: rgba(239, 68, 68, 0.2); + padding: 1px 6px; + border-radius: 4px; + text-transform: uppercase; +} + +.hazard-card-item.distractor-card .card-slot-badge { + color: var(--safety-amber); + background: rgba(245, 158, 11, 0.2); +} + +.hazard-card-item .card-hazard-text { + font-size: 0.74rem; + color: #fca5a5; + line-height: 1.3; +} + +.hazard-card-item.distractor-card .card-hazard-text { + color: #fef08a; +} + +/* Flashing Alert for Coverage Grid on Failure */ +.coverage-item.alert-missing { + border-color: var(--safety-red); + background: rgba(239, 68, 68, 0.15); + animation: borderAlert 0.8s infinite alternate; +} + +@keyframes borderAlert { + from { box-shadow: 0 0 2px var(--safety-red); } + to { box-shadow: 0 0 8px var(--safety-red); } +} + +/* Success Overlay */ +.success-overlay { + background: rgba(5, 25, 15, 0.85); + border: 3px solid var(--safety-green); +} + +.success-banner { + background: #091a13; + border: 2px solid var(--safety-green); + border-radius: var(--radius-lg); + padding: 28px; + max-width: 480px; + text-align: center; + box-shadow: 0 0 40px var(--glow-green); +} + +.success-badge { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 10px; +} + +.badge-icon { + font-size: 1.6rem; +} + +.badge-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 700; + color: var(--safety-green); +} + +.success-desc { + font-size: 0.95rem; + color: #a7f3d0; + line-height: 1.4; + margin-bottom: 18px; +} + +.points-awarded-pill { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(250, 204, 21, 0.2); + border: 1px solid var(--safety-yellow); + color: var(--safety-yellow); + padding: 8px 18px; + border-radius: var(--radius-full); + font-family: var(--font-mono); + font-weight: 800; + font-size: 1.05rem; + margin-bottom: 20px; + animation: starBounce 0.5s ease; +} + +@keyframes starBounce { + 0% { transform: scale(0.5); opacity: 0; } + 60% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(1); } +} + +/* Stage Footer Bar */ +.stage-footer-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: #0d131c; + border-top: 1px solid var(--panel-border); +} + +.status-indicator-light { + display: flex; + align-items: center; + gap: 8px; +} + +.light-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--safety-amber); + box-shadow: 0 0 8px var(--safety-amber); +} + +.status-indicator-light.running .light-dot { + background: var(--safety-green); + box-shadow: 0 0 10px var(--safety-green); +} + +.status-indicator-light.danger .light-dot { + background: var(--safety-red); + box-shadow: 0 0 10px var(--safety-red); +} + +.light-text { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 700; + color: var(--text-muted); +} + +.audio-feedback-caption { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-dim); +} + +/* Column 3: PPE Selection Locker */ +.sidebar-locker-panel { + gap: 12px; + overflow-y: auto; +} + +.locker-title { + font-family: var(--font-display); + font-size: 1.05rem; + color: #fff; +} + +.locker-subtitle { + font-size: 0.78rem; + color: var(--text-muted); +} + +.ppe-locker-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +.ppe-item-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + background: linear-gradient(180deg, #151e2b 0%, #0f1622 100%); + border: 1.5px solid var(--panel-border); + border-radius: var(--radius-md); + padding: 14px 8px 10px 8px; + cursor: pointer; + transition: var(--transition-normal); + position: relative; + min-height: 106px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.ppe-item-card:hover { + background: linear-gradient(180deg, #1c293a 0%, #14202e 100%); + border-color: var(--safety-yellow); + transform: translateY(-2px); + box-shadow: 0 6px 18px rgba(250, 204, 21, 0.2); +} + +.ppe-item-card.equipped { + background: linear-gradient(180deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.08) 100%); + border-color: var(--safety-green); + box-shadow: 0 0 16px rgba(16, 185, 129, 0.28); +} + +.item-key-badge { + position: absolute; + top: 5px; + left: 6px; + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 800; + color: var(--text-dim); + background: rgba(0, 0, 0, 0.45); + padding: 1px 5px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.item-toggle-indicator { + position: absolute; + top: 5px; + right: 6px; + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--panel-border); + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.72rem; + font-weight: 800; + color: transparent; + transition: var(--transition-fast); +} + +.ppe-item-card.equipped .item-toggle-indicator { + background: var(--safety-green); + color: #fff; + border-color: var(--safety-green); + box-shadow: 0 0 8px var(--safety-green); +} + +.item-icon-box { + width: auto; + height: auto; + background: transparent; + font-size: 2.35rem; + line-height: 1; + margin-top: 4px; + margin-bottom: 8px; + display: flex; + align-items: center; + justify-content: center; + filter: drop-shadow(0 4px 8px rgba(0,0,0,0.5)); + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.ppe-item-card:hover .item-icon-box { + transform: scale(1.18); +} + +.item-name { + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 700; + color: #fff; + line-height: 1.25; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); +} + +.locker-guide-card { + background: rgba(14, 165, 233, 0.08); + border: 1px solid rgba(14, 165, 233, 0.2); + padding: 12px; + border-radius: var(--radius-md); + margin-top: auto; +} + +.guide-title { + font-size: 0.75rem; + font-weight: 700; + color: var(--safety-cyan); + margin-bottom: 4px; +} + +.guide-text { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.35; +} + +/* ========================================================================== + SCREEN 3: SUMMARY / COMPLETION VIEW + ========================================================================== */ + +.summary-card { + max-width: 900px; + margin: 20px auto; + background: linear-gradient(180deg, #16202e 0%, #101722 100%); + border: 1px solid var(--panel-border); + border-radius: var(--radius-lg); + padding: 36px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + gap: 24px; +} + +.summary-header { + text-align: center; +} + +.victory-trophy { + font-size: 3rem; + margin-bottom: 8px; +} + +.summary-title { + font-family: var(--font-display); + font-size: 1.8rem; + color: #fff; +} + +.summary-subtitle { + font-size: 0.95rem; + font-weight: 700; + color: var(--safety-yellow); + letter-spacing: 2px; +} + +.metrics-dashboard { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.metric-box { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--panel-border); + border-radius: var(--radius-md); + padding: 16px; + text-align: center; + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 1px; + color: var(--text-muted); +} + +.metric-val { + font-family: var(--font-mono); + font-size: 1.6rem; + font-weight: 800; + color: var(--safety-yellow); +} + +.metric-val.star-gold { + letter-spacing: 4px; +} + +.metric-sub { + font-size: 0.75rem; + color: var(--text-dim); +} + +.takeaway-banner { + background: #0d141e; + border: 2px solid var(--safety-yellow); + border-radius: var(--radius-md); + padding: 16px 20px; + text-align: center; +} + +.takeaway-heading { + font-family: var(--font-display); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 1.5px; + color: var(--safety-yellow); + margin-bottom: 12px; +} + +.mantra-chain { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 8px; +} + +.mantra-step { + background: #192333; + padding: 6px 12px; + border-radius: var(--radius-sm); + font-size: 0.8rem; + color: #fff; + border: 1px solid var(--panel-border); +} + +.mantra-arrow { + color: var(--safety-yellow); + font-weight: 900; +} + +.scenarios-review-box { + background: rgba(0, 0, 0, 0.25); + border-radius: var(--radius-md); + padding: 16px; +} + +.scenarios-review-box h4 { + font-size: 0.9rem; + color: #fff; + margin-bottom: 12px; +} + +.review-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--panel-border); +} + +.review-row:last-child { + border-bottom: none; +} + +.rev-icon { + font-size: 1.4rem; +} + +.rev-info { + flex: 1; +} + +.rev-info strong { + font-size: 0.85rem; + color: #fff; + display: block; +} + +.rev-info p { + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.3; +} + +.rev-status { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 700; + color: var(--safety-green); + background: rgba(16, 185, 129, 0.15); + padding: 4px 8px; + border-radius: var(--radius-sm); +} + +.summary-actions { + display: flex; + justify-content: center; + gap: 16px; +} + +/* ========================================================================== + AUDIO SETTINGS MODAL + ========================================================================== */ + +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 100; + display: none; + align-items: center; + justify-content: center; +} + +.modal-backdrop.active { + display: flex; +} + +.modal-card { + background: var(--bg-card); + border: 1px solid var(--panel-border); + border-radius: var(--radius-lg); + width: 90%; + max-width: 440px; + padding: 24px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + gap: 18px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h3 { + font-size: 1.1rem; + color: #fff; +} + +.btn-close { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1.2rem; + cursor: pointer; +} + +.btn-close:hover { + color: #fff; +} + +.audio-control-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.audio-control-row label { + font-size: 0.85rem; + color: var(--text-main); +} + +.audio-control-row input[type="range"] { + width: 140px; + accent-color: var(--safety-yellow); +} + +.audio-control-row input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--safety-yellow); + cursor: pointer; +} + +.audio-info-note { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.35; + background: rgba(0, 0, 0, 0.3); + padding: 10px; + border-radius: var(--radius-sm); +} + +.modal-footer { + display: flex; + justify-content: flex-end; +} + +/* ========================================================================== + PRINTABLE CERTIFICATE + ========================================================================== */ + +.printable-certificate { + display: none; +} + +@media print { + body * { + visibility: hidden; + } + .printable-certificate, .printable-certificate * { + visibility: visible; + } + .printable-certificate { + display: block !important; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: #fff; + color: #000; + padding: 40px; + } + .cert-border { + border: 10px double #1e293b; + padding: 40px; + text-align: center; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + } + .cert-logo { + font-size: 1.4rem; + font-weight: 800; + color: #b45309; + margin-bottom: 20px; + } + .cert-header h1 { + font-size: 2.2rem; + letter-spacing: 2px; + margin-bottom: 10px; + } + .cert-name { + font-size: 1.8rem; + color: #1e3a8a; + text-decoration: underline; + margin: 15px 0; + } + .cert-desc { + font-size: 1.1rem; + max-width: 600px; + margin: 0 auto; + line-height: 1.5; + } + .cert-footer { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-top: 50px; + } + .sig-line { + width: 200px; + border-bottom: 2px solid #000; + margin-bottom: 6px; + height: 30px; + } + .cert-seal { + border: 3px solid #b45309; + padding: 10px 20px; + border-radius: 50px; + font-weight: 800; + color: #b45309; + } +} + +/* ========================================================================== + RESPONSIVE ADJUSTMENTS + ========================================================================== */ + +@media (max-width: 1100px) { + .gameplay-grid { + grid-template-columns: 280px 1fr 280px; + } +} + +@media (max-width: 900px) { + .gameplay-grid { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + } + .sidebar-hazard-panel, .sidebar-locker-panel { + max-height: 380px; + } + .scene-environment-container { + min-height: 380px; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..9a76738 --- /dev/null +++ b/index.html @@ -0,0 +1,423 @@ + + + + + + PPE Safety Challenge - Choose Right. Work Safe. + + + + + + + +
+ + +
+
+
✚
+
+ PPE SAFETY CHALLENGE + CHOOSE RIGHT. WORK SAFE. +
+
+ +
+
+
+ 1 + Grinding Ops +
+
+
+ 2 + Chemical Handling +
+
+
+ +
+
+ SAFETY SCORE + 0 + ☆☆ +
+ + + + +
+
+ + +
+ + +
+
+
+
+ + + + +
+
+
+ +

PPE SAFETY CHALLENGE

+

“Choose Right. Work Safe.”

+ +
+

Interactive Workplace Safety Mission

+

Select the correct Personal Protective Equipment (PPE) for critical industrial tasks. Learn how proper gear prevents severe workplace injuries before starting dangerous operations.

+ +
+ 🔍 SEE SITUATION + ➔ + ⚠️ IDENTIFY HAZARD + ➔ + 🦺 SELECT PPE + ➔ + ⚙️ TEST SAFETY +
+
+ +
+ +
+ ⌨️ Mouse Click or Number Keys (1-8) to equip • Spacebar to Test Task +
+
+
+
+ + +
+
+ + + + + +
+
+ + + + +
+
+
+
+
+
+
+
+ + +
+ +
+ + +
+
+
+ ⚠️ STOP! CRITICAL HAZARDS DETECTED +
+

Critical Protection Gaps Detected

+

+ Multiple required protective gear items are missing or improper for this task. Unprotected body zones are exposed to severe workplace trauma. +

+ +
+
+ ⚠️ ALL REQUIRED CORRECTIONS (0 ISSUES): +
+
+ +
+
+ + +
+
+ + +
+
+
+ ✅ + SAFE TO START +
+

+ Correct PPE selected. Your eyes, face, hands, hearing and feet are fully protected against all task hazards. +

+
+ ⭐ + +100 Safety Points +
+ +
+
+
+ + + +
+ + + + +
+
+ + +
+
+
+
🏆
+

PPE SAFETY CHALLENGE COMPLETE

+

“Choose Right. Work Safe.”

+
+ +
+
+ FINAL SAFETY SCORE + 200 / 200 + 100% Safety Compliance +
+
+ SCENARIOS COMPLETED + 2 / 2 + Zero Workplace Accidents +
+
+ SAFETY STARS + ⭐⭐ + Master Safety Practitioner +
+
+ + +
+
CORE SAFETY WORKFLOW
+
+
🛑 STOP
+
➔
+
👁️ LOOK
+
➔
+
⚠️ IDENTIFY HAZARDS
+
➔
+
🦺 SELECT PPE
+
➔
+
🛡️ WORK SAFELY
+
+
+ + +
+

Safety Assessment Summary

+
+
⚡
+
+ Scenario 1: Angle Grinding Operation +

Protected against flying hot sparks, high decibel noise (95+ dB), and sharp metal burrs using Full Face Shield + Glasses, Leather Grinding Gloves, Hearing Protection & Safety Boots.

+
+
✅ PASSED
+
+
+
🧪
+
+ Scenario 2: Industrial Chemical Transfer +

Prevented chemical burn & splash injury using Chemical Splash Goggles, Heavy Nitrile Gauntlet Gloves, Chemical Resistant Apron & Rubber Safety Boots.

+
+
✅ PASSED
+
+
+ +
+ + +
+
+
+ +
+ + + + + +
+
+
+ +

CERTIFICATE OF SAFETY EXCELLENCE

+

This certifies that

+

QUALIFIED INDUSTRIAL EMPLOYEE

+

has successfully demonstrated 100% competency in Hazard Identification & Personal Protective Equipment (PPE) Selection across all high-risk industrial scenarios.

+
+ +
+
+ +
+ + + + + + + + + diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..1cba43f --- /dev/null +++ b/js/audio.js @@ -0,0 +1,744 @@ +/** + * PPE SAFETY CHALLENGE - WEB AUDIO SYNTHESIZER & SOUND ENGINE + * Professional corporate-industrial sound design, procedural synthesis, and adaptive ducking. + * Fully self-contained with no external media dependencies. + */ + +class SoundEngine { + constructor() { + this.ctx = null; + this.masterGain = null; + this.musicGain = null; + this.sfxGain = null; + this.ambientGain = null; + + this.isMuted = false; + this.musicVolume = 0.25; // 25% default as requested + this.sfxVolume = 0.55; // 55% default as requested + this.isAmbientEnabled = true; + + this.isMusicPlaying = false; + this.musicInterval = null; + this.currentMusicChord = 0; + + this.activeAmbientNodes = []; + this.activeGrinderNode = null; + this.activeLiquidNode = null; + + this.isInitialized = false; + } + + /** + * Initializes the Web Audio context on user gesture. + */ + init() { + if (this.isInitialized) return; + + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioContext(); + + // Master Gain + this.masterGain = this.ctx.createGain(); + this.masterGain.gain.setValueAtTime(1.0, this.ctx.currentTime); + this.masterGain.connect(this.ctx.destination); + + // Music Channel + this.musicGain = this.ctx.createGain(); + this.musicGain.gain.setValueAtTime(this.musicVolume, this.ctx.currentTime); + this.musicGain.connect(this.masterGain); + + // SFX Channel + this.sfxGain = this.ctx.createGain(); + this.sfxGain.gain.setValueAtTime(this.sfxVolume, this.ctx.currentTime); + this.sfxGain.connect(this.masterGain); + + // Ambient Channel + this.ambientGain = this.ctx.createGain(); + this.ambientGain.gain.setValueAtTime(0.18, this.ctx.currentTime); + this.ambientGain.connect(this.masterGain); + + this.isInitialized = true; + } catch (e) { + console.warn("Web Audio API not supported or blocked:", e); + } + } + + ensureContext() { + if (!this.isInitialized) { + this.init(); + } + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume(); + } + } + + // ========================================== + // VOLUME & MUTE CONTROLS + // ========================================== + + setMasterMute(muted) { + this.isMuted = muted; + if (this.masterGain && this.ctx) { + this.masterGain.gain.setTargetAtTime(muted ? 0 : 1.0, this.ctx.currentTime, 0.05); + } + } + + setMusicVolume(val) { + this.musicVolume = Math.max(0, Math.min(1, val)); + if (this.musicGain && this.ctx) { + this.musicGain.gain.setTargetAtTime(this.musicVolume, this.ctx.currentTime, 0.05); + } + } + + setSfxVolume(val) { + this.sfxVolume = Math.max(0, Math.min(1, val)); + if (this.sfxGain && this.ctx) { + this.sfxGain.gain.setTargetAtTime(this.sfxVolume, this.ctx.currentTime, 0.05); + } + } + + setAmbientEnabled(enabled) { + this.isAmbientEnabled = enabled; + if (this.ambientGain && this.ctx) { + this.ambientGain.gain.setTargetAtTime(enabled ? 0.18 : 0, this.ctx.currentTime, 0.1); + } + } + + /** + * Duck music volume during critical warnings / consequences + */ + duckMusic(ducked = true) { + if (!this.musicGain || !this.ctx) return; + const target = ducked ? (this.musicVolume * 0.15) : this.musicVolume; + this.musicGain.gain.setTargetAtTime(target, this.ctx.currentTime, 0.1); + } + + // ========================================== + // BACKGROUND MUSIC (Corporate-Industrial Synth Loop) + // ========================================== + + playStartIntroSting() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + + // Confident corporate chords: F -> C -> Dm -> Bb + const chords = [ + [349.23, 440.00, 523.25], // F major + [261.63, 329.63, 392.00], // C major + [293.66, 349.23, 440.00], // D minor + [466.16, 587.33, 698.46] // Bb major (high shimmer) + ]; + + chords.forEach((chord, i) => { + const time = t + (i * 0.28); + chord.forEach(freq => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + const filter = this.ctx.createBiquadFilter(); + + osc.type = 'triangle'; + osc.frequency.setValueAtTime(freq, time); + + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(2200, time); + + gain.gain.setValueAtTime(0.001, time); + gain.gain.exponentialRampToValueAtTime(0.12, time + 0.05); + gain.gain.exponentialRampToValueAtTime(0.001, time + 0.6); + + osc.connect(filter); + filter.connect(gain); + gain.connect(this.musicGain); + + osc.start(time); + osc.stop(time + 0.65); + }); + }); + } + + startBackgroundMusic() { + this.ensureContext(); + if (this.isMusicPlaying || !this.ctx) return; + this.isMusicPlaying = true; + + // Corporate modern progression: Dm -> Bb -> F -> C + const progression = [ + { bass: 146.83, chord: [293.66, 349.23, 440.00] }, // Dm + { bass: 116.54, chord: [233.08, 293.66, 349.23] }, // Bb + { bass: 174.61, chord: [261.63, 349.23, 440.00] }, // F + { bass: 130.81, chord: [261.63, 329.63, 392.00] } // C + ]; + + let step = 0; + this.musicInterval = setInterval(() => { + if (!this.isMusicPlaying || !this.ctx || this.isMuted) return; + + const current = progression[step % progression.length]; + const now = this.ctx.currentTime; + + // 1. Warm Bass Pulse + const bassOsc = this.ctx.createOscillator(); + const bassGain = this.ctx.createGain(); + const bassFilter = this.ctx.createBiquadFilter(); + + bassOsc.type = 'sawtooth'; + bassOsc.frequency.setValueAtTime(current.bass, now); + + bassFilter.type = 'lowpass'; + bassFilter.frequency.setValueAtTime(350, now); + + bassGain.gain.setValueAtTime(0.001, now); + bassGain.gain.linearRampToValueAtTime(0.08, now + 0.08); + bassGain.gain.exponentialRampToValueAtTime(0.001, now + 1.8); + + bassOsc.connect(bassFilter); + bassFilter.connect(bassGain); + bassGain.connect(this.musicGain); + + bassOsc.start(now); + bassOsc.stop(now + 1.9); + + // 2. Soft Pad Chords (Calm Corporate Ambiance) + current.chord.forEach((freq, idx) => { + const padOsc = this.ctx.createOscillator(); + const padGain = this.ctx.createGain(); + const padFilter = this.ctx.createBiquadFilter(); + + padOsc.type = 'sine'; + padOsc.frequency.setValueAtTime(freq, now); + + padFilter.type = 'lowpass'; + padFilter.frequency.setValueAtTime(800 + (idx * 200), now); + + padGain.gain.setValueAtTime(0.001, now); + padGain.gain.linearRampToValueAtTime(0.04, now + 0.3); + padGain.gain.exponentialRampToValueAtTime(0.001, now + 1.9); + + padOsc.connect(padFilter); + padFilter.connect(padGain); + padGain.connect(this.musicGain); + + padOsc.start(now); + padOsc.stop(now + 2.0); + }); + + // 3. Subtle Energetic Arpeggio Tick + [0, 0.45, 0.9, 1.35].forEach((offset, i) => { + const arpTime = now + offset; + const arpOsc = this.ctx.createOscillator(); + const arpGain = this.ctx.createGain(); + + arpOsc.type = 'sine'; + const arpFreq = current.chord[i % current.chord.length] * 2; + arpOsc.frequency.setValueAtTime(arpFreq, arpTime); + + arpGain.gain.setValueAtTime(0.001, arpTime); + arpGain.gain.linearRampToValueAtTime(0.015, arpTime + 0.02); + arpGain.gain.exponentialRampToValueAtTime(0.0001, arpTime + 0.2); + + arpOsc.connect(arpGain); + arpGain.connect(this.musicGain); + + arpOsc.start(arpTime); + arpOsc.stop(arpTime + 0.22); + }); + + step++; + }, 1800); + } + + stopBackgroundMusic() { + this.isMusicPlaying = false; + if (this.musicInterval) { + clearInterval(this.musicInterval); + this.musicInterval = null; + } + } + + // ========================================== + // AMBIENT SOUNDS + // ========================================== + + setAmbientScenario(scenarioId) { + this.ensureContext(); + this.stopAmbient(); + if (!this.ctx || !this.isAmbientEnabled) return; + + if (scenarioId === 'grinding') { + // Workshop machinery hum & distant resonance + const osc1 = this.ctx.createOscillator(); + const osc2 = this.ctx.createOscillator(); + const filter = this.ctx.createBiquadFilter(); + const gain = this.ctx.createGain(); + + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(65, this.ctx.currentTime); // 60Hz grid hum + + osc2.type = 'triangle'; + osc2.frequency.setValueAtTime(120, this.ctx.currentTime); + + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(250, this.ctx.currentTime); + + gain.gain.setValueAtTime(0.06, this.ctx.currentTime); + + osc1.connect(filter); + osc2.connect(filter); + filter.connect(gain); + gain.connect(this.ambientGain); + + osc1.start(); + osc2.start(); + + this.activeAmbientNodes = [osc1, osc2, gain]; + } else if (scenarioId === 'chemical') { + // Chemical fume hood ventilation air flow (filtered white noise) + const bufferSize = this.ctx.sampleRate * 2; + const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * 0.15; + } + + const noiseSource = this.ctx.createBufferSource(); + noiseSource.buffer = buffer; + noiseSource.loop = true; + + const bandpass = this.ctx.createBiquadFilter(); + bandpass.type = 'bandpass'; + bandpass.frequency.setValueAtTime(450, this.ctx.currentTime); + bandpass.Q.setValueAtTime(1.5, this.ctx.currentTime); + + const gain = this.ctx.createGain(); + gain.gain.setValueAtTime(0.08, this.ctx.currentTime); + + noiseSource.connect(bandpass); + bandpass.connect(gain); + gain.connect(this.ambientGain); + + noiseSource.start(); + this.activeAmbientNodes = [noiseSource, gain]; + } + } + + stopAmbient() { + this.activeAmbientNodes.forEach(node => { + try { + if (node.stop) node.stop(); + if (node.disconnect) node.disconnect(); + } catch (e) {} + }); + this.activeAmbientNodes = []; + } + + // ========================================== + // UI INTERACTION SOUND EFFECTS + // ========================================== + + playEquipSFX() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + // Crisp mechanical gear latch sound + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + const filter = this.ctx.createBiquadFilter(); + + osc.type = 'square'; + osc.frequency.setValueAtTime(320, t); + osc.frequency.exponentialRampToValueAtTime(120, t + 0.06); + + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(1200, t); + + gain.gain.setValueAtTime(0.12, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.07); + + osc.connect(filter); + filter.connect(gain); + gain.connect(this.sfxGain); + + osc.start(t); + osc.stop(t + 0.08); + } + + playDeselectSFX() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.type = 'triangle'; + osc.frequency.setValueAtTime(180, t); + osc.frequency.exponentialRampToValueAtTime(260, t + 0.05); + + gain.gain.setValueAtTime(0.08, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.06); + + osc.connect(gain); + gain.connect(this.sfxGain); + + osc.start(t); + osc.stop(t + 0.07); + } + + playButtonClick() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.type = 'sine'; + osc.frequency.setValueAtTime(600, t); + osc.frequency.exponentialRampToValueAtTime(200, t + 0.03); + + gain.gain.setValueAtTime(0.1, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.04); + + osc.connect(gain); + gain.connect(this.sfxGain); + + osc.start(t); + osc.stop(t + 0.05); + } + + playSuccessChime() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + // Harmonic positive triad: C5 (523.25) -> E5 (659.25) -> G5 (783.99) -> C6 (1046.50) + const notes = [523.25, 659.25, 783.99, 1046.50]; + notes.forEach((freq, i) => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, t + (i * 0.09)); + + gain.gain.setValueAtTime(0.001, t + (i * 0.09)); + gain.gain.linearRampToValueAtTime(0.16, t + (i * 0.09) + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, t + (i * 0.09) + 0.5); + + osc.connect(gain); + gain.connect(this.sfxGain); + + osc.start(t + (i * 0.09)); + osc.stop(t + (i * 0.09) + 0.55); + }); + } + + playSafetyStarSound() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + // Shimmer sparkle sweep + for (let i = 0; i < 5; i++) { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + const time = t + (i * 0.06); + + osc.type = 'triangle'; + osc.frequency.setValueAtTime(900 + (i * 240), time); + + gain.gain.setValueAtTime(0.09, time); + gain.gain.exponentialRampToValueAtTime(0.001, time + 0.25); + + osc.connect(gain); + gain.connect(this.sfxGain); + + osc.start(time); + osc.stop(time + 0.26); + } + } + + playWarningTone() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + // Measured industrial safety double-beep (calibrated, not jarring) + [0, 0.16].forEach(offset => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(480, t + offset); + + gain.gain.setValueAtTime(0.14, t + offset); + gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.1); + + const filter = this.ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(1000, t + offset); + + osc.connect(filter); + filter.connect(gain); + gain.connect(this.sfxGain); + + osc.start(t + offset); + osc.stop(t + offset + 0.12); + }); + } + + playCriticalStopAlert() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + // Firm low "STOP" tone pulse + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.type = 'triangle'; + osc.frequency.setValueAtTime(220, t); + osc.frequency.linearRampToValueAtTime(180, t + 0.25); + + gain.gain.setValueAtTime(0.2, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); + + osc.connect(gain); + gain.connect(this.sfxGain); + + osc.start(t); + osc.stop(t + 0.36); + } + + playWhooshImpact() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + // Quick swoosh particle sound for near-miss freeze frame + const bufferSize = this.ctx.sampleRate * 0.3; + const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1); + } + + const source = this.ctx.createBufferSource(); + source.buffer = buffer; + + const filter = this.ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(600, t); + filter.frequency.exponentialRampToValueAtTime(2200, t + 0.18); + filter.Q.setValueAtTime(3.0, t); + + const gain = this.ctx.createGain(); + gain.gain.setValueAtTime(0.01, t); + gain.gain.linearRampToValueAtTime(0.18, t + 0.15); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28); + + source.connect(filter); + filter.connect(gain); + gain.connect(this.sfxGain); + + source.start(t); + source.stop(t + 0.3); + } + + // ========================================== + // TASK SPECIFIC AUDIO (Grinder & Chemical) + // ========================================== + + startGrinderSFX() { + this.ensureContext(); + this.stopGrinderSFX(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + // Grinder motor spin-up (11000 RPM whine) + spark friction sizzle + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + const filter = this.ctx.createBiquadFilter(); + + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(160, t); + osc.frequency.exponentialRampToValueAtTime(420, t + 0.5); + + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(1400, t); + filter.Q.setValueAtTime(2.0, t); + + gain.gain.setValueAtTime(0.01, t); + gain.gain.linearRampToValueAtTime(0.15, t + 0.3); + + // Spark friction noise + const bufferSize = this.ctx.sampleRate * 2; + const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * 0.3; + } + const noise = this.ctx.createBufferSource(); + noise.buffer = buffer; + noise.loop = true; + + const noiseFilter = this.ctx.createBiquadFilter(); + noiseFilter.type = 'highpass'; + noiseFilter.frequency.setValueAtTime(3000, t); + + const noiseGain = this.ctx.createGain(); + noiseGain.gain.setValueAtTime(0.01, t); + noiseGain.gain.linearRampToValueAtTime(0.08, t + 0.3); + + noise.connect(noiseFilter); + noiseFilter.connect(noiseGain); + noiseGain.connect(this.sfxGain); + + osc.connect(filter); + filter.connect(gain); + gain.connect(this.sfxGain); + + osc.start(t); + noise.start(t); + + this.activeGrinderNode = { osc, gain, noise, noiseGain }; + } + + stopGrinderSFX() { + if (this.activeGrinderNode && this.ctx) { + const t = this.ctx.currentTime; + try { + this.activeGrinderNode.gain.gain.linearRampToValueAtTime(0.001, t + 0.1); + this.activeGrinderNode.noiseGain.gain.linearRampToValueAtTime(0.001, t + 0.1); + setTimeout(() => { + try { + this.activeGrinderNode.osc.stop(); + this.activeGrinderNode.noise.stop(); + } catch (e) {} + this.activeGrinderNode = null; + }, 120); + } catch (e) { + this.activeGrinderNode = null; + } + } + } + + startLiquidTransferSFX() { + this.ensureContext(); + this.stopLiquidTransferSFX(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + // Gentle liquid pour / laminar stream sound + const bufferSize = this.ctx.sampleRate * 2; + const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * 0.2; + } + const noise = this.ctx.createBufferSource(); + noise.buffer = buffer; + noise.loop = true; + + const filter = this.ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(850, t); + filter.Q.setValueAtTime(3.5, t); + + const gain = this.ctx.createGain(); + gain.gain.setValueAtTime(0.01, t); + gain.gain.linearRampToValueAtTime(0.12, t + 0.2); + + noise.connect(filter); + filter.connect(gain); + gain.connect(this.sfxGain); + + noise.start(t); + this.activeLiquidNode = { noise, gain }; + } + + stopLiquidTransferSFX() { + if (this.activeLiquidNode && this.ctx) { + const t = this.ctx.currentTime; + try { + this.activeLiquidNode.gain.gain.linearRampToValueAtTime(0.001, t + 0.1); + setTimeout(() => { + try { + this.activeLiquidNode.noise.stop(); + } catch (e) {} + this.activeLiquidNode = null; + }, 120); + } catch (e) { + this.activeLiquidNode = null; + } + } + } + + playChemicalSplashSFX() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + const t = this.ctx.currentTime; + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.type = 'triangle'; + osc.frequency.setValueAtTime(350, t); + osc.frequency.exponentialRampToValueAtTime(140, t + 0.12); + + gain.gain.setValueAtTime(0.18, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15); + + osc.connect(gain); + gain.connect(this.sfxGain); + + osc.start(t); + osc.stop(t + 0.16); + } + + playVictorySting() { + this.ensureContext(); + if (!this.ctx || this.isMuted) return; + + this.stopBackgroundMusic(); + const t = this.ctx.currentTime; + + // Professional triumphant fanfare: C4 -> E4 -> G4 -> C5 (sustained) + const melody = [ + { freq: 523.25, duration: 0.18, delay: 0 }, + { freq: 659.25, duration: 0.18, delay: 0.18 }, + { freq: 783.99, duration: 0.22, delay: 0.36 }, + { freq: 1046.50, duration: 1.2, delay: 0.58 } + ]; + + melody.forEach(note => { + const time = t + note.delay; + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + const filter = this.ctx.createBiquadFilter(); + + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(note.freq, time); + + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(2400, time); + + gain.gain.setValueAtTime(0.001, time); + gain.gain.linearRampToValueAtTime(0.18, time + 0.04); + gain.gain.exponentialRampToValueAtTime(0.001, time + note.duration); + + osc.connect(filter); + filter.connect(gain); + gain.connect(this.musicGain); + + osc.start(time); + osc.stop(time + note.duration + 0.05); + }); + } +} + +// Global Sound Engine Instance +window.soundEngine = new SoundEngine(); diff --git a/js/avatar.js b/js/avatar.js new file mode 100644 index 0000000..54a28cd --- /dev/null +++ b/js/avatar.js @@ -0,0 +1,336 @@ +/** + * PPE SAFETY CHALLENGE - DYNAMIC WORKER AVATAR SYSTEM + * Generates an SVG industrial worker avatar with modular layered PPE gear. + */ + +class WorkerAvatar { + constructor(containerId) { + this.container = document.getElementById(containerId); + this.equippedItems = new Set(); + this.render(); + } + + setEquipped(itemIds) { + this.equippedItems = new Set(itemIds); + this.updateLayers(); + } + + equip(itemId) { + this.equippedItems.add(itemId); + this.updateLayers(); + } + + unequip(itemId) { + this.equippedItems.delete(itemId); + this.updateLayers(); + } + + has(itemId) { + return this.equippedItems.has(itemId); + } + + getAvatarCenterCoordinates() { + if (!this.container) return { x: 150, y: 200, eyeX: 140, eyeY: 100, handX: 190, handY: 220 }; + const rect = this.container.getBoundingClientRect(); + const parentRect = this.container.parentElement.getBoundingClientRect(); + const relX = rect.left - parentRect.left + (rect.width * 0.5); + const relY = rect.top - parentRect.top + (rect.height * 0.5); + return { + x: relX, + y: relY, + eyeX: relX - 10, + eyeY: relY - 110, + handX: relX + 45, + handY: relY + 15 + }; + } + + render() { + if (!this.container) return; + + this.container.innerHTML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + + this.updateLayers(); + } + + updateLayers() { + if (!this.container) return; + + // All gear IDs configured across scenarios + const allGearIds = [ + 'hard_hat_cap', 'hard_hat_chemical', + 'faceshield_glasses', 'chemical_splash_goggles', 'regular_sunglasses', 'standard_safety_glasses', + 'earmuffs_heavy', 'wireless_earbuds', 'organic_vapor_respirator', 'cloth_dust_mask', + 'heavy_leather_jacket', 'chemical_resistant_apron', 'cotton_lab_coat', + 'leather_grinding_gloves', 'nitrile_gauntlet_gloves', 'thin_latex_gloves', 'leather_work_gloves', + 'steel_toe_boots', 'chemical_rubber_boots', 'regular_sneakers' + ]; + + allGearIds.forEach(gearId => { + const el = document.getElementById(`layer-gear-${gearId}`); + if (el) { + el.style.display = this.equippedItems.has(gearId) ? 'block' : 'none'; + } + }); + + // Toggle bare hands layer if gloves equipped + const bareHandsEl = document.getElementById('layer-hands-bare'); + const hasAnyGloves = ['leather_grinding_gloves', 'nitrile_gauntlet_gloves', 'thin_latex_gloves', 'leather_work_gloves'] + .some(g => this.equippedItems.has(g)); + if (bareHandsEl) { + bareHandsEl.style.display = hasAnyGloves ? 'none' : 'block'; + } + + // Toggle base feet layer if special boots equipped + const baseFeetEl = document.getElementById('layer-feet-base'); + const hasAnyBoots = ['steel_toe_boots', 'chemical_rubber_boots', 'regular_sneakers'] + .some(b => this.equippedItems.has(b)); + if (baseFeetEl) { + baseFeetEl.style.display = hasAnyBoots ? 'none' : 'block'; + } + } +} diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..0101139 --- /dev/null +++ b/js/game.js @@ -0,0 +1,629 @@ +/** + * 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'), + 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}`; + + // Populate Hazard List + this.dom.hazardList.innerHTML = this.currentScenario.hazards.map(h => ` +
  • + ${h.label} +
  • + `).join(''); + + // Populate Locker Grid + this.renderLockerItems(); + this.updateCoverageIndicators(); + + // Start Ambient Sound for this scenario + window.soundEngine.setAmbientScenario(this.currentScenario.id); + } + + renderLockerItems() { + this.dom.lockerGrid.innerHTML = this.currentScenario.lockerItems.map((item, idx) => { + const isEquipped = this.equippedItems.has(item.id); + return ` +
    + ${idx + 1} +
    ${isEquipped ? '✓' : ''}
    +
    ${item.icon}
    + ${item.name} +
    + `; + }).join(''); + + // Bind click events on cards + this.dom.lockerGrid.querySelectorAll('.ppe-item-card').forEach(card => { + card.addEventListener('click', () => { + const itemId = card.dataset.itemId; + const slot = card.dataset.slot; + this.toggleItem(itemId, slot); + }); + }); + } + + toggleItem(itemId, slot) { + if (this.equippedItems.has(itemId)) { + // Unequip + this.equippedItems.delete(itemId); + this.avatar.unequip(itemId); + window.soundEngine.playDeselectSFX(); + } else { + // If equipping, replace any existing item occupying the same slot + const existingInSlot = this.currentScenario.lockerItems.find( + it => it.slot === slot && this.equippedItems.has(it.id) + ); + if (existingInSlot) { + this.equippedItems.delete(existingInSlot.id); + this.avatar.unequip(existingInSlot.id); + } + + this.equippedItems.add(itemId); + this.avatar.equip(itemId); + window.soundEngine.playEquipSFX(); + } + + this.renderLockerItems(); + this.updateCoverageIndicators(); + } + + updateCoverageIndicators() { + const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item'); + coverageItems.forEach(itemEl => { + const slot = itemEl.dataset.slot; + const statusEl = itemEl.querySelector('.cov-status'); + + // Find if any item is equipped in this slot + const isSlotEquipped = Array.from(this.equippedItems).some(itemId => { + const itemObj = this.currentScenario.lockerItems.find(it => it.id === itemId); + return itemObj && itemObj.slot === slot; + }); + + if (isSlotEquipped) { + statusEl.className = 'cov-status protected'; + statusEl.textContent = 'Equipped'; + } else { + statusEl.className = 'cov-status missing'; + statusEl.textContent = 'Missing'; + } + }); + } + + // ========================================================================= + // SAFETY CHECK EXECUTION & CONSEQUENCE DETECTION + // ========================================================================= + + executeSafetyTest() { + window.soundEngine.playButtonClick(); + + // 1. Identify ALL equipped distractors / incorrect items + const allEquippedDistractors = this.currentScenario.lockerItems.filter( + it => this.equippedItems.has(it.id) && !it.isCorrect + ); + + // 2. Identify ALL missing required critical gear checks + const allMissingChecks = this.currentScenario.criticalChecks.filter( + chk => !this.equippedItems.has(chk.requiredItem) + ); + + const isSafe = allEquippedDistractors.length === 0 && allMissingChecks.length === 0; + + if (isSafe) { + // 100% SAFE SUCCESS PATH + this.handleSafeSuccess(); + } else { + // UNSAFE FAILURE / CONSEQUENCE NEAR-MISS PATH (Collect all issues) + this.handleUnsafeConsequence(allMissingChecks, allEquippedDistractors); + } + } + + handleSafeSuccess() { + // 1. Update visual state + this.dom.statusLight.className = 'status-indicator-light running'; + this.dom.statusText.textContent = `OPERATION RUNNING SAFELY - ALL HAZARDS MITIGATED`; + + // 2. Start Task Simulation SFX & Particles + if (this.currentScenario.id === 'grinding') { + window.soundEngine.startGrinderSFX(); + this.particles.startMode('sparks'); + } else { + window.soundEngine.startLiquidTransferSFX(); + this.particles.startMode('chemical'); + } + + // 3. Play Success Chime & Star sound after short operational run + setTimeout(() => { + window.soundEngine.playSuccessChime(); + window.soundEngine.playSafetyStarSound(); + + // Award points (100 on first try, 80 on retry) + const points = this.scenarioFirstAttempt ? 100 : 80; + this.score += points; + this.scenarioCompleted[this.currentScenarioIndex] = true; + this.updateHUD(); + + // Show Success Modal Overlay + this.dom.successTitle.textContent = this.currentScenario.successMessage.title; + this.dom.successDesc.textContent = this.currentScenario.successMessage.body; + this.dom.awardedPointsText.textContent = `+${points} Safety Points ⭐`; + + if (this.currentScenarioIndex === SCENARIO_DATA.length - 1) { + this.dom.btnNextScenarioText.textContent = 'VIEW FINAL RESULTS ➔'; + } else { + this.dom.btnNextScenarioText.textContent = 'NEXT SCENARIO ➔'; + } + + this.dom.successOverlay.classList.add('active'); + }, 1200); + } + + handleUnsafeConsequence(allMissingChecks, allEquippedDistractors) { + this.scenarioFirstAttempt = false; + + const totalIssues = allMissingChecks.length + allEquippedDistractors.length; + + // 1. Start task machine momentarily + this.dom.statusLight.className = 'status-indicator-light danger'; + this.dom.statusText.textContent = `CRITICAL HAZARD DETECTED (${totalIssues} ISSUES) - FREEZING OPERATION!`; + + if (this.currentScenario.id === 'grinding') { + window.soundEngine.startGrinderSFX(); + this.particles.startMode('sparks'); + } else { + window.soundEngine.startLiquidTransferSFX(); + this.particles.startMode('chemical'); + } + + // 2. Determine target position for incoming projectile (prioritize eye/face if missing, otherwise hand/body) + const avatarCoords = this.avatar.getAvatarCenterCoordinates(); + const stageWidth = this.dom.sceneStage.clientWidth; + const stageHeight = this.dom.sceneStage.clientHeight; + const originX = stageWidth * 0.72; + const originY = stageHeight * 0.65; + + const hasEyeFaceIssue = allMissingChecks.some(chk => chk.slot === 'eyes' || chk.slot === 'head'); + const targetX = hasEyeFaceIssue ? avatarCoords.eyeX : avatarCoords.handX; + const targetY = hasEyeFaceIssue ? avatarCoords.eyeY : avatarCoords.handY; + + setTimeout(() => { + window.soundEngine.playWhooshImpact(); + this.particles.spawnHazardProjectile( + this.currentScenario.id === 'grinding' ? 'spark' : 'chemical_drop', + originX, originY, targetX, targetY + ); + }, 400); + + // Store consequence context for freeze overlay + this.activeConsequenceData = { allMissingChecks, allEquippedDistractors }; + } + + handleFreezeFrameAlert(projectile) { + // 1. Freeze particle engine & stop machine motors + this.particles.setFrozen(true); + window.soundEngine.stopGrinderSFX(); + window.soundEngine.stopLiquidTransferSFX(); + + // 2. Duck background music + window.soundEngine.duckMusic(true); + + // 3. Play warning tones + window.soundEngine.playWarningTone(); + window.soundEngine.playCriticalStopAlert(); + + const { allMissingChecks, allEquippedDistractors } = this.activeConsequenceData; + const totalIssues = allMissingChecks.length + allEquippedDistractors.length; + + // Slot icons mapping + const slotIcons = { + head: '🧢', + eyes: '👓', + ears: '🎧', + hands: '🧤', + body: '🦺', + feet: '🥾' + }; + + // 4. Update Header & Badges + this.dom.hazardFlasherBadge.textContent = `⚠️ STOP! ${totalIssues} SAFETY ${totalIssues === 1 ? 'HAZARD' : 'HAZARDS'} DETECTED`; + this.dom.missingCountNum.textContent = totalIssues; + + if (totalIssues === 1) { + if (allEquippedDistractors.length > 0) { + const d = allEquippedDistractors[0]; + this.dom.consequenceTitle.textContent = "Incorrect / Ineffective PPE Detected"; + this.dom.consequenceDesc.textContent = d.rejectReason; + } else { + const m = allMissingChecks[0]; + this.dom.consequenceTitle.textContent = m.consequenceTitle; + this.dom.consequenceDesc.textContent = m.consequenceDesc; + } + } else { + this.dom.consequenceTitle.textContent = "Multiple Critical Protection Gaps Detected"; + this.dom.consequenceDesc.textContent = "Operating without complete PPE exposes multiple body zones to direct, severe workplace injuries. All required safety equipment must be equipped before starting."; + } + + // 5. Build HTML for ALL Missing & Incorrect Items Simultaneously + let cardsHtml = ''; + + // Render all missing critical checks + allMissingChecks.forEach(chk => { + const icon = slotIcons[chk.slot] || '⚠️'; + cardsHtml += ` +
    +
    ${icon}
    +
    +
    + ${chk.missingLabel} + ${chk.slot} - Missing +
    + ${chk.consequenceDesc} +
    +
    + `; + }); + + // Render all equipped distractors that need to be removed + allEquippedDistractors.forEach(dist => { + cardsHtml += ` +
    +
    🚫
    +
    +
    + REMOVE: ${dist.name} + Unsafe Gear +
    + ${dist.rejectReason} +
    +
    + `; + }); + + this.dom.consequenceHazardList.innerHTML = cardsHtml; + + // 6. Highlight missing slots in Coverage Grid with visual alert animation + const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item'); + coverageItems.forEach(itemEl => { + const slot = itemEl.dataset.slot; + const isSlotMissing = allMissingChecks.some(chk => chk.slot === slot); + if (isSlotMissing) { + itemEl.classList.add('alert-missing'); + } else { + itemEl.classList.remove('alert-missing'); + } + }); + + // 7. Display freeze overlay + this.dom.consequenceOverlay.classList.add('active'); + } + + dismissConsequenceModal() { + // Unfreeze and restore normal background music + this.dom.consequenceOverlay.classList.remove('active'); + this.particles.clear(); + this.particles.setFrozen(false); + this.particles.stopMode(); + window.soundEngine.duckMusic(false); + + // Clear alert classes on coverage items + const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item'); + coverageItems.forEach(itemEl => itemEl.classList.remove('alert-missing')); + + this.dom.statusLight.className = 'status-indicator-light'; + this.dom.statusText.textContent = `TASK IDLE - ADJUST PPE SELECTION`; + } + + // ========================================================================= + // SUMMARY & COMPLETION SCREEN + // ========================================================================= + + showFinalSummary() { + this.switchScreen('summary'); + window.soundEngine.playVictorySting(); + this.particles.spawnConfetti(); + + // Update metrics + const totalMax = SCENARIO_DATA.length * 100; + const pct = Math.round((this.score / totalMax) * 100); + this.dom.summaryScore.textContent = `${this.score} / ${totalMax}`; + this.dom.summaryPercentage.textContent = `${pct}% Safety Compliance Rating`; + + if (this.score >= 180) { + this.dom.summaryStars.textContent = "⭐⭐"; + this.dom.summaryRank.textContent = "Master Safety Practitioner (Zero Accidents)"; + } else { + this.dom.summaryStars.textContent = "⭐"; + this.dom.summaryRank.textContent = "Safety Certified (Refresher Recommended)"; + } + } + + updateHUD() { + this.dom.hudScore.textContent = this.score; + const starCount = Math.floor(this.score / 100); + this.dom.hudStars.textContent = "⭐".repeat(starCount) + "☆".repeat(Math.max(0, 2 - starCount)); + } +} + +// Instantiate game on load +window.addEventListener('DOMContentLoaded', () => { + window.safetyGame = new SafetyGame(); +}); diff --git a/js/particles.js b/js/particles.js new file mode 100644 index 0000000..fbe0fb7 --- /dev/null +++ b/js/particles.js @@ -0,0 +1,267 @@ +/** + * PPE SAFETY CHALLENGE - PARTICLE PHYSICS & VISUAL FX ENGINE + * Renders high-velocity sparks, chemical pours, near-miss hazard projectiles, and freeze-frame effects. + */ + +class ParticleEngine { + constructor(canvasId) { + this.canvas = document.getElementById(canvasId); + this.ctx = this.canvas ? this.canvas.getContext('2d') : null; + this.particles = []; + this.projectiles = []; + this.confetti = []; + this.isRunning = false; + this.isFrozen = false; + this.activeMode = null; // 'sparks', 'chemical', 'confetti' + + this.resize(); + window.addEventListener('resize', () => this.resize()); + } + + resize() { + if (!this.canvas) return; + const rect = this.canvas.parentElement.getBoundingClientRect(); + this.canvas.width = rect.width; + this.canvas.height = rect.height; + } + + clear() { + this.particles = []; + this.projectiles = []; + this.confetti = []; + if (this.ctx && this.canvas) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + } + + setFrozen(freeze) { + this.isFrozen = freeze; + } + + startMode(mode) { + this.activeMode = mode; + this.isFrozen = false; + if (!this.isRunning) { + this.isRunning = true; + this.animate(); + } + } + + stopMode() { + this.activeMode = null; + this.clear(); + } + + // Spawn near-miss hazardous particle / projectile flying at avatar + spawnHazardProjectile(type, startX, startY, targetX, targetY) { + const dx = targetX - startX; + const dy = targetY - startY; + const dist = Math.sqrt(dx * dx + dy * dy); + const speed = 14; + + this.projectiles.push({ + type: type, // 'spark' or 'chemical_drop' + x: startX, + y: startY, + vx: (dx / dist) * speed, + vy: (dy / dist) * speed, + targetX: targetX, + targetY: targetY, + size: type === 'spark' ? 7 : 12, + color: type === 'spark' ? '#ff3b30' : '#00f0ff', + trail: [], + reached: false + }); + } + + spawnSparks(originX, originY, count = 12) { + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 0.8) + (Math.random() * 0.9 - 0.45); // Directed left-upward arc + const speed = Math.random() * 12 + 6; + this.particles.push({ + type: 'spark', + x: originX, + y: originY, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - (Math.random() * 4), + gravity: 0.35, + life: 1.0, + decay: Math.random() * 0.03 + 0.02, + size: Math.random() * 3 + 1.5, + color: Math.random() > 0.3 ? '#ffdd55' : '#ff5511' + }); + } + } + + spawnChemicalFlow(originX, originY) { + for (let i = 0; i < 4; i++) { + this.particles.push({ + type: 'chemical', + x: originX + (Math.random() * 8 - 4), + y: originY, + vx: -(Math.random() * 2 + 1), + vy: Math.random() * 4 + 2, + gravity: 0.25, + life: 1.0, + decay: 0.02, + size: Math.random() * 4 + 3, + color: '#10b981' + }); + } + } + + spawnConfetti() { + this.confetti = []; + const colors = ['#facc15', '#10b981', '#0ea5e9', '#ec4899', '#8b5cf6']; + for (let i = 0; i < 80; i++) { + this.confetti.push({ + x: this.canvas.width / 2, + y: this.canvas.height / 2, + vx: (Math.random() - 0.5) * 16, + vy: (Math.random() - 0.8) * 14, + size: Math.random() * 8 + 4, + color: colors[Math.floor(Math.random() * colors.length)], + rotation: Math.random() * Math.PI * 2, + rotSpeed: (Math.random() - 0.5) * 0.2, + gravity: 0.2, + life: 1.0, + decay: 0.012 + }); + } + } + + animate() { + if (!this.isRunning) return; + + if (!this.isFrozen) { + if (this.ctx && this.canvas) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + + // Origin coordinates relative to stage + const originX = this.canvas.width * 0.72; + const originY = this.canvas.height * 0.65; + + if (this.activeMode === 'sparks') { + this.spawnSparks(originX, originY, 6); + } else if (this.activeMode === 'chemical') { + this.spawnChemicalFlow(originX, originY); + } + + // 1. Update & Draw Ambient Task Particles + for (let i = this.particles.length - 1; i >= 0; i--) { + const p = this.particles[i]; + p.x += p.vx; + p.y += p.vy; + p.vy += p.gravity; + p.life -= p.decay; + + if (p.life <= 0) { + this.particles.splice(i, 1); + continue; + } + + this.ctx.save(); + this.ctx.globalAlpha = Math.max(0, p.life); + this.ctx.fillStyle = p.color; + this.ctx.shadowBlur = p.type === 'spark' ? 8 : 4; + this.ctx.shadowColor = p.color; + + this.ctx.beginPath(); + this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + this.ctx.fill(); + this.ctx.restore(); + } + + // 2. Update & Draw Near-Miss Projectiles (For Consequence Warning) + for (let i = this.projectiles.length - 1; i >= 0; i--) { + const prj = this.projectiles[i]; + prj.trail.push({ x: prj.x, y: prj.y }); + if (prj.trail.length > 8) prj.trail.shift(); + + prj.x += prj.vx; + prj.y += prj.vy; + + // Draw glowing motion trail + this.ctx.save(); + this.ctx.strokeStyle = prj.color; + this.ctx.lineWidth = prj.size * 0.8; + this.ctx.lineCap = 'round'; + this.ctx.shadowBlur = 12; + this.ctx.shadowColor = prj.color; + + if (prj.trail.length > 1) { + this.ctx.beginPath(); + this.ctx.moveTo(prj.trail[0].x, prj.trail[0].y); + for (let j = 1; j < prj.trail.length; j++) { + this.ctx.lineTo(prj.trail[j].x, prj.trail[j].y); + } + this.ctx.stroke(); + } + + // Draw head + this.ctx.fillStyle = '#ffffff'; + this.ctx.beginPath(); + this.ctx.arc(prj.x, prj.y, prj.size, 0, Math.PI * 2); + this.ctx.fill(); + this.ctx.restore(); + + // Check if close to worker target -> trigger freeze frame + const dist = Math.hypot(prj.x - prj.targetX, prj.y - prj.targetY); + if (dist < 35 && !prj.reached) { + prj.reached = true; + // Trigger visual freeze event + window.dispatchEvent(new CustomEvent('hazard-freeze-triggered', { detail: { projectile: prj } })); + } + } + + // 3. Update & Draw Victory Confetti + for (let i = this.confetti.length - 1; i >= 0; i--) { + const c = this.confetti[i]; + c.x += c.vx; + c.y += c.vy; + c.vy += c.gravity; + c.rotation += c.rotSpeed; + c.life -= c.decay; + + if (c.life <= 0) { + this.confetti.splice(i, 1); + continue; + } + + this.ctx.save(); + this.ctx.translate(c.x, c.y); + this.ctx.rotate(c.rotation); + this.ctx.globalAlpha = c.life; + this.ctx.fillStyle = c.color; + this.ctx.fillRect(-c.size / 2, -c.size / 4, c.size, c.size / 2); + this.ctx.restore(); + } + } else { + // FROZEN STATE: Draw pulsating danger halo around the frozen near-miss particle + if (this.projectiles.length > 0) { + const prj = this.projectiles[0]; + this.ctx.save(); + const pulse = Math.sin(Date.now() * 0.01) * 8 + 18; + this.ctx.strokeStyle = '#ef4444'; + this.ctx.lineWidth = 3; + this.ctx.setLineDash([6, 4]); + this.ctx.beginPath(); + this.ctx.arc(prj.x, prj.y, pulse, 0, Math.PI * 2); + this.ctx.stroke(); + + // Threat line to avatar + this.ctx.strokeStyle = 'rgba(239, 68, 68, 0.7)'; + this.ctx.lineWidth = 2; + this.ctx.setLineDash([4, 4]); + this.ctx.beginPath(); + this.ctx.moveTo(prj.x, prj.y); + this.ctx.lineTo(prj.targetX, prj.targetY); + this.ctx.stroke(); + this.ctx.restore(); + } + } + + requestAnimationFrame(() => this.animate()); + } +} diff --git a/js/scenarios.js b/js/scenarios.js new file mode 100644 index 0000000..4f9f734 --- /dev/null +++ b/js/scenarios.js @@ -0,0 +1,373 @@ +/** + * PPE SAFETY CHALLENGE - SCENARIO DEFINITIONS & SAFETY DATABASE + * Implements: SEE THE SITUATION -> IDENTIFY HAZARDS -> SELECT PPE -> CONSEQUENCES -> CORRECT + */ + +const SCENARIO_DATA = [ + { + id: "grinding", + index: 1, + title: "Metal Grinding Operation", + taskName: "ANGLE GRINDER WELD SMOOTHING", + sceneClass: "stage-grinding", + environmentDesc: "An industrial worker is preparing to grind rough weld seams on heavy structural steel using a high-speed angle grinder (11,000 RPM).", + audioCaption: "Workshop Ambience: Machinery hum & distant fabrication noise", + guideTip: "High-speed spinning discs eject fiery sparks and shrapnel at over 100 m/s with extreme noise (95+ dB).", + + hazards: [ + { + id: "flying_sparks", + label: "Flying hot metal sparks & abrasive wheel particles (11,000 RPM)", + criticalSlot: "eyes" + }, + { + id: "high_noise", + label: "Continuous high decibel motor and grinding noise (95-105 dB)", + criticalSlot: "ears" + }, + { + id: "sharp_burrs", + label: "Sharp metal burrs, intense friction heat & wheel kickback hazard", + criticalSlot: "hands" + }, + { + id: "falling_debris", + label: "Heavy steel drop hazard & structural workshop overhead risks", + criticalSlot: "feet" + } + ], + + // Required protective gear items + requiredPpe: [ + "faceshield_glasses", + "leather_grinding_gloves", + "earmuffs_heavy", + "hard_hat_cap", + "heavy_leather_jacket", + "steel_toe_boots" + ], + + // Critical slots for safety check evaluation + criticalChecks: [ + { + slot: "eyes", + requiredItem: "faceshield_glasses", + consequenceTitle: "Flying Particle Hazard", + consequenceDesc: "Without full face & eye impact protection, flying abrasive particles at 11,000 RPM can cause catastrophic corneal penetration or severe facial lacerations.", + missingLabel: "Full Face Shield + Safety Glasses (ANSI Z87+)" + }, + { + slot: "ears", + requiredItem: "earmuffs_heavy", + consequenceTitle: "Noise-Induced Hearing Damage", + consequenceDesc: "Angle grinding routinely generates 100+ dB. Operating without rated hearing protection causes irreversible inner-ear acoustic trauma.", + missingLabel: "Heavy Duty Hearing Protection (Earmuffs / Plugs)" + }, + { + slot: "hands", + requiredItem: "leather_grinding_gloves", + consequenceTitle: "Thermal Burn & Abrasion Danger", + consequenceDesc: "Thin latex or bare hands offer zero protection against glowing 800°C sparks and jagged steel edges. Heavy-duty leather gloves are required.", + missingLabel: "Heavy-Duty Split Cowhide Leather Grinding Gloves" + }, + { + slot: "feet", + requiredItem: "steel_toe_boots", + consequenceTitle: "Crush & Penetration Hazard", + consequenceDesc: "Standard footwear cannot protect against dropped structural steel sections or sharp discarded metal shards on workshop floors.", + missingLabel: "Steel-Toe Safety Boots with Puncture Resistant Sole" + }, + { + slot: "head", + requiredItem: "hard_hat_cap", + consequenceTitle: "Overhead Impact & Debris Risk", + consequenceDesc: "In fabrication bays, overhead cranes and moving metal fixtures require impact-certified head protection.", + missingLabel: "Industrial Hard Hat (Type 1, Class E)" + }, + { + slot: "body", + requiredItem: "heavy_leather_jacket", + consequenceTitle: "Clothing Ignition / Hot Spark Burn", + consequenceDesc: "Synthetic regular clothing easily melts or ignites from dense grinder spark streams. Heavy flame-resistant leather/cotton protective wear is essential.", + missingLabel: "Flame-Resistant Heavy Leather Welder/Grinder Jacket" + } + ], + + // Locker items available for selection (including distractors) + lockerItems: [ + { + id: "faceshield_glasses", + name: "Face Shield + Safety Glasses", + slot: "eyes", + icon: "🛡️", + spec: "ANSI Z87+ Impact Dual Protection", + isCorrect: true + }, + { + id: "regular_sunglasses", + name: "Standard Tinted Sunglasses", + slot: "eyes", + icon: "🕶️", + spec: "Non-impact rated tinted plastic", + isCorrect: false, + rejectReason: "Standard sunglasses shatter upon impact and leave the face exposed to flying debris." + }, + { + id: "leather_grinding_gloves", + name: "Leather Grinding Gloves", + slot: "hands", + icon: "🧤", + spec: "Heavy split cowhide heat/spark resistant", + isCorrect: true + }, + { + id: "thin_latex_gloves", + name: "Thin Disposable Latex Gloves", + slot: "hands", + icon: "🧤", + spec: "Medical grade 4mil rubber (Flaggable)", + isCorrect: false, + rejectReason: "Thin latex immediately melts onto skin when hit by 800°C grinder sparks." + }, + { + id: "earmuffs_heavy", + name: "Industrial Ear Muffs (NRR 30)", + slot: "ears", + icon: "🎧", + spec: "High noise reduction acoustic seal", + isCorrect: true + }, + { + id: "wireless_earbuds", + name: "Consumer Music Earbuds", + slot: "ears", + icon: "🎵", + spec: "Audio playback (Non-certified)", + isCorrect: false, + rejectReason: "Music earphones are not certified PPE and distract the operator from machine warnings." + }, + { + id: "hard_hat_cap", + name: "Industrial Hard Hat", + slot: "head", + icon: "⛑️", + spec: "ANSI Z89.1 Type 1 Class E", + isCorrect: true + }, + { + id: "heavy_leather_jacket", + name: "Flame-Resistant Work Jacket", + slot: "body", + icon: "🦺", + spec: "Heavy-duty spark-resistant leather/cotton", + isCorrect: true + }, + { + id: "steel_toe_boots", + name: "Steel-Toe Safety Boots", + slot: "feet", + icon: "🥾", + spec: "ASTM F2413 Impact & Puncture Rated", + isCorrect: true + }, + { + id: "regular_sneakers", + name: "Canvas Running Sneakers", + slot: "feet", + icon: "👟", + spec: "Fabric sports shoe (Zero drop rating)", + isCorrect: false, + rejectReason: "Fabric sneakers ignite from sparks and offer zero crush protection from dropped metal." + } + ], + + successMessage: { + title: "SAFE TO START", + headline: "Correct PPE selected.", + body: "Your eyes, face, hands, hearing and feet are protected from the main hazards of this task." + } + }, + + { + id: "chemical", + index: 2, + title: "Chemical Handling & Dispensing", + taskName: "CORROSIVE SOLVENT TRANSFER", + sceneClass: "stage-chemical", + environmentDesc: "An employee is preparing to transfer an industrial corrosive solvent between approved containment drums inside a ventilated chemical dispensing station.", + audioCaption: "Ventilation Ambience: Fume extraction hood airflow & quiet laboratory background", + guideTip: "Corrosive chemicals cause irreversible chemical burns upon skin contact and severe vapor irritation.", + + hazards: [ + { + id: "chemical_splash", + label: "Corrosive liquid splash risk during pouring & transfer", + criticalSlot: "eyes" + }, + { + id: "skin_absorption", + label: "Acute dermal chemical burn and toxic absorption", + criticalSlot: "hands" + }, + { + id: "body_soak", + label: "Direct container overflow or accidental splash onto torso & legs", + criticalSlot: "body" + }, + { + id: "foot_contact", + label: "Chemical pooling on floor and footwear degradation", + criticalSlot: "feet" + } + ], + + // Required protective gear items + requiredPpe: [ + "chemical_splash_goggles", + "nitrile_gauntlet_gloves", + "chemical_resistant_apron", + "chemical_rubber_boots", + "hard_hat_chemical", + "organic_vapor_respirator" + ], + + // Critical slots evaluation + criticalChecks: [ + { + slot: "eyes", + requiredItem: "chemical_splash_goggles", + consequenceTitle: "Chemical Splash to Eyes", + consequenceDesc: "Standard safety glasses with open side vents allow hazardous liquid droplets to roll directly into the eyes, causing permanent corneal scarring.", + missingLabel: "Indirectly Vented Chemical Splash Goggles (ANSI Z87.1 D3)" + }, + { + slot: "hands", + requiredItem: "nitrile_gauntlet_gloves", + consequenceTitle: "Dermal Chemical Permeation", + consequenceDesc: "Porous leather or basic household gloves rapidly absorb harsh solvents, causing chemical burns and systemic toxicity through the skin.", + missingLabel: "Heavy-Duty Chemical-Resistant Nitrile/Neoprene Gauntlets" + }, + { + slot: "body", + requiredItem: "chemical_resistant_apron", + consequenceTitle: "Body Splash & Acid Soak Hazard", + consequenceDesc: "Standard porous fabric clothing absorbs chemical splashes immediately, holding corrosive liquids against the skin.", + missingLabel: "Chemical-Resistant Impervious PVC/Neoprene Apron" + }, + { + slot: "feet", + requiredItem: "chemical_rubber_boots", + consequenceTitle: "Corrosive Liquid Footwear Infiltration", + consequenceDesc: "Standard leather safety boots dissolve and degrade when exposed to pooled industrial acids and solvents.", + missingLabel: "Chemical-Resistant Neoprene/Rubber Boots" + }, + { + slot: "ears", + requiredItem: "organic_vapor_respirator", // Uses respiratory/head slot + consequenceTitle: "Hazardous Vapor Inhalation", + consequenceDesc: "Concentrated chemical vapors overcome unprotected workers quickly. Half-mask respirator with chemical cartridges is required.", + missingLabel: "Half-Mask Respirator with Organic Vapor/Acid Gas Cartridges" + }, + { + slot: "head", + requiredItem: "hard_hat_chemical", + consequenceTitle: "Overhead Chemical Piping Hazard", + consequenceDesc: "Chemical distribution zones require head protection against overhead pipe drips and valve maintenance risks.", + missingLabel: "Chemical-Resistant Industrial Hard Hat" + } + ], + + lockerItems: [ + { + id: "chemical_splash_goggles", + name: "Chemical Splash Goggles", + slot: "eyes", + icon: "🥽", + spec: "ANSI Z87.1 D3 Sealed Splash Protection", + isCorrect: true + }, + { + id: "standard_safety_glasses", + name: "Open Safety Glasses", + slot: "eyes", + icon: "👓", + spec: "Impact rated with open side vents (Non-liquid)", + isCorrect: false, + rejectReason: "Open side vents allow liquid splashes and corrosive droplets to enter the eye." + }, + { + id: "nitrile_gauntlet_gloves", + name: "Nitrile Gauntlet Gloves", + slot: "hands", + icon: "🧤", + spec: "15-mil Heavy Nitrile Chemical Gauntlets", + isCorrect: true + }, + { + id: "leather_work_gloves", + name: "Standard Leather Work Gloves", + slot: "hands", + icon: "🧤", + spec: "Porous split cowhide (Absorbent)", + isCorrect: false, + rejectReason: "Leather absorbs corrosive chemicals like a sponge, causing deep chemical burns." + }, + { + id: "chemical_resistant_apron", + name: "Chemical-Resistant Apron", + slot: "body", + icon: "🦺", + spec: "Impermeable Heavy PVC / Neoprene Apron", + isCorrect: true + }, + { + id: "cotton_lab_coat", + name: "Standard Cotton Coat", + slot: "body", + icon: "🥼", + spec: "Porous woven cotton (Non-impervious)", + isCorrect: false, + rejectReason: "Cotton absorbs acids and solvents directly into underclothes." + }, + { + id: "chemical_rubber_boots", + name: "Chemical Rubber Boots", + slot: "feet", + icon: "🥾", + spec: "Seamless Neoprene Acid-Resistant Boots", + isCorrect: true + }, + { + id: "organic_vapor_respirator", + name: "Organic Vapor Respirator", + slot: "ears", // Grouped in face/respiratory slot + icon: "😷", + spec: "Half-Face Mask with Dual Acid/Vapor Cartridges", + isCorrect: true + }, + { + id: "hard_hat_chemical", + name: "Safety Hard Hat", + slot: "head", + icon: "⛑️", + spec: "Chemical-Resistant High Density Polyethylene", + isCorrect: true + }, + { + id: "cloth_dust_mask", + name: "Simple Cloth Dust Mask", + slot: "ears", + icon: "😷", + spec: "Particulate only (No gas/vapor barrier)", + isCorrect: false, + rejectReason: "Cloth dust masks offer ZERO filtration against toxic chemical fumes and organic solvent vapors." + } + ], + + successMessage: { + title: "SAFE TO PROCEED", + headline: "Correct chemical protection selected.", + body: "Your PPE helps protect the eyes, respiratory system and skin against accidental chemical splashes." + } + } +];