IT-BOS commited on
Commit
5421fca
·
verified ·
1 Parent(s): 59b306e

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +11 -5
  2. index.html +492 -18
  3. vg-game.zip +3 -0
README.md CHANGED
@@ -1,10 +1,16 @@
1
  ---
2
- title: VGgame
3
- emoji: 🐨
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: static
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
1
  ---
2
+ title: VirtuGuard Agent Training Game
3
+ emoji: 🎮
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: static
7
  pinned: false
8
  ---
9
 
10
+ Guided scenario mini game for the VirtuGuard webpage. Static HTML, no build step, no backend.
11
+
12
+ Files: index.html (the whole game), vglogo.svg (header logo).
13
+
14
+ Embed on the site with an iframe pointing at the Space URL, for example:
15
+
16
+ <iframe src="https://virtuguard-agent-training.hf.space" width="100%" height="1000" style="border:0;max-width:860px" title="VirtuGuard Agent Training" allow="autoplay"></iframe>
index.html CHANGED
@@ -1,19 +1,493 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>VirtuGuard Command Center: Agent Training</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root{
11
+ --navy:#272f4f; --navy-deep:#161b33; --navy-ink:#0d1024;
12
+ --teal:#00a9a5; --teal-dim:#0f6f6d; --amber:#f2b632; --red:#e0483e;
13
+ --paper:#e6ebf1; --muted:#9aa6bd; --line:#3b4468;
14
+ --pix:'Press Start 2P', monospace;
15
+ --body:Arial, Helvetica, sans-serif;
16
+ }
17
+ *{box-sizing:border-box}
18
+ html,body{margin:0;background:var(--navy-ink);color:var(--paper);font-family:var(--body)}
19
+ body{min-height:100vh;display:flex;justify-content:center;padding:16px 12px}
20
+ .console{width:100%;max-width:820px}
21
+ header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}
22
+ header img{height:32px;width:auto;filter:brightness(0) invert(1)}
23
+ .hud{font-family:var(--pix);font-size:10px;color:var(--teal);letter-spacing:.5px;display:flex;gap:14px;align-items:center}
24
+ .hud .score{color:var(--amber)}
25
+ .btn-icon{background:none;border:2px solid var(--line);color:var(--muted);font-family:var(--pix);font-size:9px;padding:6px 8px;cursor:pointer;border-radius:2px}
26
+ .btn-icon:hover,.btn-icon:focus-visible{border-color:var(--teal);color:var(--paper);outline:none}
27
+
28
+ .monitor{background:var(--navy);border:4px solid var(--line);border-radius:6px;padding:8px;box-shadow:inset 0 0 0 2px #1a2040}
29
+ .screen{position:relative;background:#000;aspect-ratio:16/9;overflow:hidden;border-radius:2px}
30
+ canvas{width:100%;height:100%;display:block;image-rendering:pixelated;image-rendering:crisp-edges}
31
+ .scan{position:absolute;inset:0;pointer-events:none;background:repeating-linear-gradient(0deg,rgba(0,0,0,.18) 0 1px,transparent 1px 3px);mix-blend-mode:multiply}
32
+
33
+ .panel{background:var(--navy-deep);border:2px solid var(--line);border-top:none;padding:14px 16px 16px}
34
+ .title{font-family:var(--pix);font-size:11px;color:var(--teal);margin:0 0 10px;line-height:1.6}
35
+ .narr{font-size:16px;line-height:1.55;margin:0 0 14px;max-width:70ch}
36
+ .narr b{color:var(--amber);font-weight:700}
37
+ .actions{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
38
+ .act{background:var(--navy);border:2px solid var(--line);border-bottom-width:5px;color:var(--paper);font-family:var(--pix);font-size:9px;line-height:1.5;padding:12px 6px;cursor:pointer;border-radius:3px;text-align:center;transition:transform .05s}
39
+ .act:hover,.act:focus-visible{border-color:var(--teal);outline:none}
40
+ .act:active{transform:translateY(3px);border-bottom-width:2px}
41
+ .act[disabled]{opacity:.4;cursor:default;transform:none}
42
+ .act.v{border-top:3px solid var(--teal)} .act.s{border-top:3px solid var(--amber)} .act.d{border-top:3px solid var(--red)} .act.c{border-top:3px solid var(--muted)}
43
+
44
+ .feedback{margin-top:14px;padding:12px 14px;border-left:4px solid var(--teal);background:#1c2340;font-size:15px;line-height:1.5}
45
+ .feedback.bad{border-left-color:var(--red)}
46
+ .feedback.ok{border-left-color:var(--amber)}
47
+ .feedback .pts{font-family:var(--pix);font-size:10px;display:block;margin-bottom:8px;color:var(--teal)}
48
+ .feedback.bad .pts{color:var(--red)} .feedback.ok .pts{color:var(--amber)}
49
+ .next{margin-top:12px;background:var(--teal);color:#062322;border:none;border-bottom:4px solid var(--teal-dim);font-family:var(--pix);font-size:10px;padding:12px 16px;cursor:pointer;border-radius:3px}
50
+ .next:hover,.next:focus-visible{background:#19c4bf;outline:none}
51
+ .next.ghost{background:transparent;color:var(--paper);border:2px solid var(--line);border-bottom-width:4px}
52
+ .next.ghost:hover{border-color:var(--teal)}
53
+
54
+ .menu{padding:22px 16px}
55
+ .menu h1{font-family:var(--pix);font-size:15px;color:var(--paper);line-height:1.7;margin:0 0 8px}
56
+ .menu p{font-size:16px;line-height:1.55;color:var(--muted);margin:0 0 18px;max-width:64ch}
57
+ .sites{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
58
+ .site{background:var(--navy);border:2px solid var(--line);border-bottom-width:5px;padding:0 0 10px;cursor:pointer;color:var(--paper);text-align:left;border-radius:3px;overflow:hidden}
59
+ .site:hover,.site:focus-visible{border-color:var(--teal);outline:none}
60
+ .site canvas{aspect-ratio:16/9;width:100%}
61
+ .site span{display:block;font-family:var(--pix);font-size:9px;line-height:1.6;padding:10px 10px 0;color:var(--teal)}
62
+ .site small{display:block;font-size:13px;color:var(--muted);padding:2px 10px 0}
63
+
64
+ .result h2{font-family:var(--pix);font-size:13px;line-height:1.7;margin:0 0 6px;color:var(--amber)}
65
+ .result .big{font-family:var(--pix);font-size:28px;color:var(--paper);margin:8px 0 12px}
66
+ .result .big small{font-size:11px;color:var(--muted)}
67
+ .recap{list-style:none;padding:0;margin:0 0 16px;font-size:14px;line-height:1.6;color:var(--muted)}
68
+ .recap li{padding:4px 0;border-bottom:1px solid #222a48}
69
+ .recap li b{color:var(--paper);font-weight:normal}
70
+ .row{display:flex;gap:10px;flex-wrap:wrap}
71
+ a.next{text-decoration:none;display:inline-block}
72
+ .hidden{display:none}
73
+ @media (max-width:560px){
74
+ .actions{grid-template-columns:repeat(2,1fr)} .sites{grid-template-columns:1fr}
75
+ .narr,.menu p{font-size:15px} header img{height:26px}
76
+ }
77
+ @media (prefers-reduced-motion:reduce){ .act,.act:active{transition:none;transform:none} }
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <div class="console">
82
+ <header>
83
+ <img src="vglogo.svg" alt="VirtuGuard">
84
+ <div class="hud"><span id="hudCam">CAM --</span><span class="score" id="hudScore"></span>
85
+ <button class="btn-icon" id="mute" aria-pressed="false" title="Toggle sound">SOUND ON</button></div>
86
+ </header>
87
+
88
+ <div class="monitor">
89
+ <div class="screen"><canvas id="cv" width="320" height="180" aria-label="Camera view"></canvas><div class="scan"></div></div>
90
+ </div>
91
+
92
+ <div class="panel menu" id="menu">
93
+ <h1>Take the agent seat.</h1>
94
+ <p>Pick a site. An alarm comes in, you decide what happens next. Verify what you see, use the right tool at the right time, and know when to bring in officers. Points come from judgment, not speed.</p>
95
+ <div class="sites" id="sites"></div>
96
+ </div>
97
+
98
+ <div class="panel hidden" id="play">
99
+ <p class="title" id="stepTitle"></p>
100
+ <p class="narr" id="narr"></p>
101
+ <div class="actions" id="actions"></div>
102
+ <div id="fb" class="feedback hidden"><span class="pts" id="fbPts"></span><span id="fbText"></span><br>
103
+ <button class="next" id="nextBtn">Continue</button></div>
104
+ </div>
105
+
106
+ <div class="panel result hidden" id="result">
107
+ <h2 id="rTitle"></h2>
108
+ <div class="big"><span id="rScore"></span> <small id="rMax"></small></div>
109
+ <p class="narr" id="rNote"></p>
110
+ <ul class="recap" id="recap"></ul>
111
+ <div class="row">
112
+ <button class="next" id="again">Play another site</button>
113
+ <a class="next ghost" id="learn" href="https://www.bossecurity.com/virtuguard/learn-more-about-virtuguard/" target="_blank" rel="noopener">Learn more about VirtuGuard</a>
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ <script>
119
+ /* ---------------- Actions ---------------- */
120
+ const ACTIONS = [
121
+ {id:'watch', cls:'v', label:'Watch and report'},
122
+ {id:'voice', cls:'v', label:'Voice down'},
123
+ {id:'strobe', cls:'s', label:'Strobes'},
124
+ {id:'siren', cls:'s', label:'Siren'},
125
+ {id:'guard', cls:'d', label:'Dispatch security'},
126
+ {id:'police', cls:'d', label:'Dispatch police'},
127
+ {id:'clear', cls:'c', label:'Clear event'},
128
+ ];
129
+ const DEFAULT_FB = {
130
+ watch: {pts:0, txt:'You take another look. Nothing new on screen. The situation still needs a decision.'},
131
+ voice: {pts:-5, txt:'A voice down here does not fit what is on camera. Save it for a person who needs to hear it.'},
132
+ strobe: {pts:-5, txt:'Strobes fire across an empty scene. Deterrents work best once a subject has been warned.'},
133
+ siren: {pts:-5, txt:'Siren blares with no one to move. That is a noise complaint waiting to happen.'},
134
+ guard: {pts:-5, txt:'There is nothing yet for a guard to respond to.'},
135
+ police: {pts:-15,txt:'Police get called on confirmed activity. Sending them early wastes their trust and can cost the client a false alarm fee.'},
136
+ clear: {pts:-15,txt:'Clearing an open event without confirming what happened is how incidents get missed.'},
137
+ };
138
+
139
+ /* ---------------- Scenarios ---------------- */
140
+ /* scene: actors and props; each step's opts map action -> {pts, txt, go:'next'|'stay'|'end', fx} */
141
+ const SCENARIOS = [
142
+ {
143
+ id:'dealer', name:'Auto dealership', sub:'Night lot, 1:47 AM', cam:'CAM 03 NORTH LOT', max:75,
144
+ steps:[
145
+ { title:'Motion alert, Cam 01, front row', cam:'CAM 01 FRONT ROW',
146
+ narr:'A flatbed pulls onto the front row with a sedan on the back. The lot closed at 9 PM.',
147
+ scene:{actors:[{t:'tow',x:200,y:118}]},
148
+ opts:{
149
+ watch:{pts:10,txt:'Live view: the truck carries the logo of the client\'s transport company. The site notes list them as approved for after-hours deliveries.',go:'next'},
150
+ voice:{pts:-5,txt:'You almost shout at the client\'s own delivery driver. Check the site notes before you key the speaker.',go:'stay'},
151
+ clear:{pts:-10,txt:'You have not looked yet. A truck on a closed lot could be a rollback thief just as easily as a delivery.',go:'stay'},
152
+ }},
153
+ { title:'Approved delivery confirmed', cam:'CAM 01 FRONT ROW',
154
+ narr:'The driver unloads the sedan and heads back to the truck. Everything matches the approved list.',
155
+ scene:{actors:[{t:'tow',x:200,y:118},{t:'person',x:190,y:120,c:'#c9a66b'}]},
156
+ opts:{
157
+ clear:{pts:10,txt:'Event cleared with a note: approved transport delivery, sedan dropped front row. A clean audit trail matters as much as a catch.',go:'next'},
158
+ watch:{pts:5,txt:'Reasonable to watch him leave. Once he is off the lot, close the event with a note.',go:'next'},
159
+ }},
160
+ { title:'Person detected, Cam 03', cam:'CAM 03 NORTH LOT',
161
+ narr:'Twenty minutes later. A figure walks between the rows of trucks on the north lot. AI flags <b>person detected</b>.',
162
+ scene:{actors:[{t:'person',x:150,y:112,c:'#5c6470',pose:'walk'}]},
163
+ opts:{
164
+ watch:{pts:10,txt:'Live view confirms an adult in a gray hoodie moving truck to truck, pulling on door handles. No vehicle, no badge, no reason to be there.',go:'next'},
165
+ voice:{pts:5,txt:'Fair instinct, and it works, but protocol is verify first so you never voice down the general manager grabbing a file.',go:'next'},
166
+ police:DEFAULT_FB.police, clear:{pts:-15,txt:'AI flagged a person and the lot is closed. You do not clear that without looking.',go:'stay'},
167
+ }},
168
+ { title:'Subject checking door handles', cam:'CAM 03 NORTH LOT',
169
+ narr:'He stops at a white pickup and tries the driver door. Locked. He cups his hands against the glass.',
170
+ scene:{actors:[{t:'person',x:118,y:112,c:'#5c6470',pose:'reach'}]},
171
+ opts:{
172
+ voice:{pts:15,txt:'Live audio: "You in the gray hoodie at the white pickup, you are on camera and this property is being monitored. Leave now." He freezes and looks for the speaker.',go:'next',fx:'voice'},
173
+ strobe:{pts:5,txt:'The strobes get his attention, but a live voice naming his clothing is what proves a human is watching. Use it first.',go:'next',fx:'strobe'},
174
+ siren:{pts:5,txt:'Noise makes him flinch. A voice down naming what he is wearing lands harder. Use it first.',go:'next',fx:'siren'},
175
+ police:{pts:0,txt:'Hold that. Most subjects leave within seconds of a voice down. Police are for the ones who stay.',go:'stay'},
176
+ guard:{pts:-5,txt:'This dealership is monitoring only after hours. No guard on site.',go:'stay'},
177
+ }},
178
+ { title:'Subject not complying', cam:'CAM 03 NORTH LOT',
179
+ narr:'He ignores the warning, pulls a tool from his pocket and starts working the window seal.',
180
+ scene:{actors:[{t:'person',x:118,y:112,c:'#5c6470',pose:'reach'}],spark:true},
181
+ opts:{
182
+ strobe:{pts:15,txt:'Strobes light the lot. He drops the tool and backs away from the truck.',go:'next',fx:'strobe'},
183
+ siren:{pts:15,txt:'The siren hits and he drops the tool and backs off. Now he knows the whole street can hear it.',go:'next',fx:'siren'},
184
+ voice:{pts:5,txt:'Second warning delivered. He hesitates, then goes back to the window. Time to escalate.',go:'stay',fx:'voice'},
185
+ police:{pts:10,txt:'Defensible call. Officers are on the way, but firing the on-site deterrents first often ends it before they roll.',go:'next'},
186
+ guard:{pts:-5,txt:'No guard on this site after hours.',go:'stay'},
187
+ }},
188
+ { title:'Subject still on property', cam:'CAM 04 EAST FENCE',
189
+ narr:'He backs off the truck but does not leave. He crouches behind a silver sedan near the east fence, watching the road.',
190
+ scene:{actors:[{t:'person',x:236,y:118,c:'#5c6470',pose:'crouch'}],cam:'CAM 04 EAST FENCE'},
191
+ opts:{
192
+ police:{pts:15,txt:'Dispatch is on the line. You give it clean: male, gray hoodie, jeans, tool in hand, crouched behind a silver sedan at the east fence. You stay on the call.',go:'next',fx:'police'},
193
+ voice:{pts:5,txt:'Third warning. He is not leaving. Bring in officers and keep your eyes on him.',go:'stay',fx:'voice'},
194
+ siren:{pts:0,txt:'Siren again. He stays put. Deterrents have done their job; this one needs officers.',go:'stay',fx:'siren'},
195
+ clear:{pts:-15,txt:'He is still on the lot with a tool. This is the exact moment the client is paying you for.',go:'stay'},
196
+ }},
197
+ { title:'Officers arriving', cam:'CAM 05 SERVICE BAYS',
198
+ narr:'Two units pull in the front entrance. The subject moves toward the rear service bays. Dispatch asks where he is now.',
199
+ scene:{actors:[{t:'person',x:70,y:114,c:'#5c6470',pose:'walk'},{t:'police',x:236,y:104}],cam:'CAM 05 SERVICE BAYS'},
200
+ opts:{
201
+ watch:{pts:15,txt:'"He is behind the service bays, north side, moving west along the wall." Officers come around both ends and take him without a chase. Event documented, client notified.',go:'end'},
202
+ police:{pts:5,txt:'They are already here. What dispatch needs now is your eyes. You relay his position and officers take him at the service bays.',go:'end'},
203
+ siren:{pts:-5,txt:'Not now. Officers on scene need to hear each other. Relay his location instead.',go:'stay'},
204
+ }},
205
+ ]},
206
+ {
207
+ id:'multi', name:'Multifamily housing', sub:'Riverbend Apartments, 11:52 PM', cam:'CAM 02 POOL DECK', max:75,
208
+ steps:[
209
+ { title:'Loitering alert, pool deck', cam:'CAM 02 POOL DECK',
210
+ narr:'Two people on the pool deck. Pool hours ended at 10 PM.',
211
+ scene:{actors:[{t:'person',x:196,y:112,c:'#d8895b'},{t:'person',x:212,y:112,c:'#7fb3d5'}]},
212
+ opts:{
213
+ watch:{pts:10,txt:'Two young adults on lounge chairs, one holding a key fob. Almost certainly residents. Still a rule violation the property asked you to enforce.',go:'next'},
214
+ voice:{pts:5,txt:'It works, but always look first. A resident and a trespasser get very different tones.',go:'next'},
215
+ siren:{pts:-15,txt:'A siren at midnight over two residents at the pool. The property manager will hear about that from every neighbor.',go:'stay',fx:'siren'},
216
+ police:DEFAULT_FB.police,
217
+ }},
218
+ { title:'Residents after hours', cam:'CAM 02 POOL DECK',
219
+ narr:'They are chatting, no alcohol visible, feet in the water. The site notes say residents get a courteous reminder.',
220
+ scene:{actors:[{t:'person',x:196,y:112,c:'#d8895b'},{t:'person',x:212,y:112,c:'#7fb3d5'}]},
221
+ opts:{
222
+ voice:{pts:15,txt:'"Good evening, this is Riverbend security. The pool closed at 10. Please gather your things and head back to your unit. Thank you." They wave at the camera and go.',go:'next',fx:'voice'},
223
+ strobe:{pts:-10,txt:'Strobes on residents is the wrong register. A calm voice does this job.',go:'stay',fx:'strobe'},
224
+ guard:{pts:0,txt:'The courtesy officer could handle it, but a voice down resolves this in ten seconds and keeps him free for real calls.',go:'next'},
225
+ clear:{pts:-10,txt:'The client asked you to enforce pool hours. Give the reminder, then close it.',go:'stay'},
226
+ }},
227
+ { title:'Motion, rear gate', cam:'CAM 05 REAR GATE',
228
+ narr:'A figure at the rear pedestrian gate. Something small moving at their feet.',
229
+ scene:{actors:[{t:'person',x:250,y:114,c:'#9fbf8f',pose:'walk'},{t:'dog',x:238,y:124}],cam:'CAM 05 REAR GATE'},
230
+ opts:{
231
+ watch:{pts:10,txt:'Resident walking a dog. He badges through the gate and it latches behind him.',go:'next'},
232
+ voice:{pts:-5,txt:'You nearly bark at a man walking his dog. Look first.',go:'stay'},
233
+ clear:{pts:0,txt:'Probably right, but you did not confirm the gate latched. Watch, then clear.',go:'stay'},
234
+ }},
235
+ { title:'Resident confirmed', cam:'CAM 05 REAR GATE',
236
+ narr:'Gate closed and latched. Resident and dog heading to building B.',
237
+ scene:{actors:[{t:'person',x:150,y:114,c:'#9fbf8f',pose:'walk'},{t:'dog',x:138,y:124}],cam:'CAM 05 REAR GATE'},
238
+ opts:{
239
+ clear:{pts:10,txt:'Cleared with a note: resident with dog, badge access, gate secured.',go:'next'},
240
+ watch:{pts:5,txt:'Fine to watch him in. Then close it out with a note.',go:'next'},
241
+ }},
242
+ { title:'Person detected, building C breezeway', cam:'CAM 06 BREEZEWAY C',
243
+ narr:'A man in the second floor breezeway pulling on apartment door handles, one after another.',
244
+ scene:{actors:[{t:'person',x:120,y:80,c:'#3c3f4a',pose:'reach'}],cam:'CAM 06 BREEZEWAY C',breezeway:true},
245
+ opts:{
246
+ voice:{pts:15,txt:'"You on the second floor of building C in the dark jacket, you are on camera. Step away from the doors and leave the property." He spins around looking for the speaker.',go:'next',fx:'voice'},
247
+ police:{pts:5,txt:'Trying doors is real cause, but a voice down usually clears it faster than a patrol car. Warn first, then escalate if he stays.',go:'next'},
248
+ guard:{pts:5,txt:'The courtesy officer is a good next step. Warn the subject first so the officer is not walking into a surprise.',go:'next'},
249
+ clear:{pts:-15,txt:'A stranger trying doors at midnight is the opposite of clear.',go:'stay'},
250
+ }},
251
+ { title:'Subject moving', cam:'CAM 08 MAIL ROOM',
252
+ narr:'He ducks into the stairwell, out of speaker range, and reappears at the mail room prying at the parcel lockers. This property has an on-site courtesy officer overnight.',
253
+ scene:{actors:[{t:'person',x:96,y:112,c:'#3c3f4a',pose:'reach'}],cam:'CAM 08 MAIL ROOM',spark:true},
254
+ opts:{
255
+ guard:{pts:15,txt:'You radio the courtesy officer: male, dark jacket, at the parcel lockers with a pry tool, alone. He heads over. You stay on the camera.',go:'next',fx:'guard'},
256
+ police:{pts:10,txt:'Solid. You also let the on-site officer know what is coming so nobody is surprised.',go:'next',fx:'police'},
257
+ voice:{pts:0,txt:'No speaker covers the mail room. He cannot hear you.',go:'stay'},
258
+ strobe:{pts:-5,txt:'No strobe in the mail room. Use the people you have on site.',go:'stay'},
259
+ }},
260
+ { title:'Officer approaching', cam:'CAM 09 GARAGE ENTRY',
261
+ narr:'The subject sees the officer coming and runs toward the parking garage. The officer keys the radio: where did he go?',
262
+ scene:{actors:[{t:'person',x:60,y:112,c:'#3c3f4a',pose:'walk'},{t:'guard',x:200,y:116}],cam:'CAM 09 GARAGE ENTRY'},
263
+ opts:{
264
+ watch:{pts:15,txt:'"Garage level one, running toward the north exit ramp." The officer cuts him off at the ramp and holds him. You call police, complete the incident report, and the property manager has video by morning.',go:'end'},
265
+ police:{pts:10,txt:'Right call, and you relay the garage location at the same time. Officers meet the courtesy officer at the north ramp.',go:'end',fx:'police'},
266
+ siren:{pts:-5,txt:'Not now. The officer needs to hear the radio. Tell him where the subject went.',go:'stay'},
267
+ }},
268
+ ]},
269
+ {
270
+ id:'build', name:'Construction site', sub:'Highway 316 jobsite, 2:14 AM', cam:'CAM 02 EAST FENCE', max:75,
271
+ steps:[
272
+ { title:'Perimeter alert, solar trailer Cam 2', cam:'CAM 02 EAST FENCE',
273
+ narr:'Movement along the east fence line. Low to the ground, moving slowly.',
274
+ scene:{actors:[{t:'deer',x:230,y:120}]},
275
+ opts:{
276
+ watch:{pts:10,txt:'Thermal view shows a deer grazing along the fence. No person, no vehicle.',go:'next'},
277
+ siren:{pts:-10,txt:'You just sirened a deer. It ran off. The neighbors are awake.',go:'stay',fx:'siren'},
278
+ police:DEFAULT_FB.police,
279
+ clear:{pts:0,txt:'Probably an animal, but verify. Thermal takes two seconds.',go:'stay'},
280
+ }},
281
+ { title:'Wildlife confirmed', cam:'CAM 02 EAST FENCE',
282
+ narr:'The deer wanders off toward the tree line.',
283
+ scene:{actors:[{t:'deer',x:280,y:120}]},
284
+ opts:{
285
+ clear:{pts:10,txt:'Cleared as wildlife. Logged so the client sees why the alert count is what it is.',go:'next'},
286
+ watch:{pts:5,txt:'It is gone. Close the event with a note.',go:'next'},
287
+ }},
288
+ { title:'Person detected, Cam 4 laydown yard', cam:'CAM 04 LAYDOWN YARD',
289
+ narr:'Two subjects at the fence with bolt cutters, working toward the copper wire spools stacked inside.',
290
+ scene:{actors:[{t:'person',x:246,y:116,c:'#4a4d5a',pose:'reach'},{t:'person',x:262,y:118,c:'#6b4f3a'}],cam:'CAM 04 LAYDOWN YARD',spools:true},
291
+ opts:{
292
+ voice:{pts:15,txt:'"You two at the east fence, you are on camera and this site is being monitored. Drop the tools and leave now." One straightens up and steps back.',go:'next',fx:'voice'},
293
+ siren:{pts:10,txt:'Siren gets both of their heads up. Good, but the voice down telling them you can see the bolt cutters is what convinces them a person is watching.',go:'next',fx:'siren'},
294
+ police:{pts:5,txt:'Two people cutting a fence is real cause. Warn them first anyway; most leave and officers stay available.',go:'next'},
295
+ guard:{pts:-5,txt:'Remote site, no guard. Solar and cellular is the whole reason you are watching it.',go:'stay'},
296
+ clear:DEFAULT_FB.clear,
297
+ }},
298
+ { title:'One backs off, one keeps cutting', cam:'CAM 04 LAYDOWN YARD',
299
+ narr:'The second subject looks toward the camera, then goes back to cutting.',
300
+ scene:{actors:[{t:'person',x:246,y:116,c:'#4a4d5a',pose:'reach'},{t:'person',x:280,y:118,c:'#6b4f3a'}],cam:'CAM 04 LAYDOWN YARD',spools:true,spark:true},
301
+ opts:{
302
+ strobe:{pts:15,txt:'The trailer strobes light up the whole yard. He stops cutting and shields his eyes.',go:'next',fx:'strobe'},
303
+ siren:{pts:15,txt:'Siren on. He stops cutting and looks up and down the road.',go:'next',fx:'siren'},
304
+ voice:{pts:5,txt:'Second warning. He does not care. Escalate.',go:'stay',fx:'voice'},
305
+ police:{pts:10,txt:'Fair. Fire the deterrents too; they buy time before officers arrive.',go:'next'},
306
+ }},
307
+ { title:'Subjects inside the fence', cam:'CAM 04 LAYDOWN YARD',
308
+ narr:'They are through. One loads a copper spool into a wheelbarrow while the other watches the road.',
309
+ scene:{actors:[{t:'person',x:190,y:116,c:'#4a4d5a',pose:'reach'},{t:'person',x:270,y:118,c:'#6b4f3a'}],cam:'CAM 04 LAYDOWN YARD',spools:true,barrow:true},
310
+ opts:{
311
+ police:{pts:15,txt:'Sheriff dispatch on the line: two males inside the east fence, one dark jacket, one brown, wheelbarrow, copper spools, bolt cutters on scene. Deputies are rolling.',go:'next',fx:'police'},
312
+ voice:{pts:0,txt:'They heard you the first two times. Officers now.',go:'stay'},
313
+ strobe:{pts:0,txt:'Already lit. They have decided to stay. Officers now.',go:'stay',fx:'strobe'},
314
+ clear:{pts:-15,txt:'Two people are stealing copper on your screen.',go:'stay'},
315
+ }},
316
+ { title:'Deputies three minutes out', cam:'CAM 05 EQUIPMENT ROW',
317
+ narr:'The subjects push the wheelbarrow behind the excavator, out of Cam 4. Cam 5 still has them. Dispatch asks for a location.',
318
+ scene:{actors:[{t:'person',x:96,y:118,c:'#4a4d5a',pose:'walk'},{t:'person',x:112,y:118,c:'#6b4f3a',pose:'walk'}],cam:'CAM 05 EQUIPMENT ROW',barrow:true,excav:true},
319
+ opts:{
320
+ watch:{pts:15,txt:'"Behind the excavator, north side of the equipment row, both subjects, wheelbarrow with them. Best approach is the north gate." Deputies come in from the north and detain both. Client gets the video and the incident report before the crew arrives at six.',go:'end'},
321
+ siren:{pts:-5,txt:'Deputies are close. Give them the location, not more noise.',go:'stay'},
322
+ police:{pts:5,txt:'Already en route. What they need is your eyes. You relay the north side of the excavator and both are detained.',go:'end'},
323
+ }},
324
+ ]},
325
+ ];
326
+
327
+ /* ---------------- Pixel renderer ---------------- */
328
+ const cv=document.getElementById('cv'); let ctx=cv.getContext('2d');
329
+ ctx.imageSmoothingEnabled=false;
330
+ const W=320,H=180;
331
+ let current=null, stepIdx=0, score=0, recap=[], fx={type:null,t0:0};
332
+ let sceneOverride=null;
333
+ const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
334
+
335
+ function px(x,y,w,h,c){ctx.fillStyle=c;ctx.fillRect(x|0,y|0,w,h);}
336
+ function starfield(seed){ let s=seed; for(let i=0;i<40;i++){ s=(s*9301+49297)%233280; const x=(s/233280)*W; s=(s*9301+49297)%233280; const y=(s/233280)*70; px(x,y,1,1,(i%5==0)?'#d9e6ff':'#6c7aa8'); } }
337
+ function sky(){ px(0,0,W,40,'#0b0f2a');px(0,40,W,30,'#101740');px(0,70,W,40,'#182052'); starfield(7); px(270,16,7,7,'#e8e6c8');px(272,15,3,1,'#e8e6c8');px(272,23,3,1,'#e8e6c8');px(269,18,1,3,'#e8e6c8');px(277,18,1,3,'#e8e6c8'); }
338
+ function pole(x,base){ px(x,base-70,2,70,'#2a2f45'); px(x-6,base-72,14,3,'#3b4160'); px(x-4,base-69,10,2,'#f6e7a1'); ctx.fillStyle='rgba(246,231,161,0.06)'; ctx.beginPath(); ctx.moveTo(x-4,base-68); ctx.lineTo(x-40,base); ctx.lineTo(x+44,base); ctx.lineTo(x+6,base-68); ctx.fill(); }
339
+ function speaker(x,y){ px(x,y,6,8,'#3b4160'); px(x+1,y+1,4,6,'#1c2038'); px(x+6,y+2,2,4,'#00a9a5'); }
340
+ function camIcon(x,y){ px(x,y,8,5,'#2f3652');px(x+8,y+1,3,3,'#1c2038');px(x+3,y+5,2,3,'#2f3652'); px(x+9,y+2,1,1,'#e0483e'); }
341
+
342
+ function car(x,y,c){ px(x+2,y,16,4,c);px(x,y+3,20,5,c);px(x+3,y+1,5,2,'#9fd3e8');px(x+10,y+1,5,2,'#9fd3e8');px(x+2,y+8,4,3,'#111');px(x+14,y+8,4,3,'#111');px(x+3,y+9,2,1,'#666');px(x+15,y+9,2,1,'#666'); }
343
+ function truck(x,y,c){ px(x+12,y-2,10,6,c);px(x,y+2,24,6,c);px(x+14,y-1,6,3,'#9fd3e8');px(x+1,y+3,10,3,shade(c));px(x+3,y+8,4,3,'#111');px(x+16,y+8,4,3,'#111'); }
344
+ function shade(c){ return c==='#e6e6e6'?'#bdbdbd':'#0000004d'; }
345
+ function person(a,t){ const {x,y}=a, c=a.c||'#555'; const pose=a.pose||'stand'; const bob=(pose==='walk')?((Math.floor(t/220)%2)?1:0):0;
346
+ if(pose==='crouch'){ px(x,y+4,6,4,c);px(x+1,y+1,4,3,'#d9b48f');px(x,y+8,2,3,'#2b2b33');px(x+4,y+8,2,3,'#2b2b33'); return; }
347
+ px(x+1,y-bob,4,4,'#d9b48f'); px(x,y+4-bob,6,7,c); px(x,y+11-bob,2,5,'#2b2b33'); px(x+4,y+11-bob,2,5,'#2b2b33');
348
+ if(pose==='reach'){ px(x+6,y+5,4,2,c); px(x+10,y+5,2,2,'#d9b48f'); }
349
+ else if(pose==='walk'){ px(x-2+bob*2,y+6,2,2,c); px(x+6-bob*2,y+6,2,2,c); }
350
+ else { px(x-2,y+5,2,5,c); px(x+6,y+5,2,5,c); }
351
+ }
352
+ function guardSprite(a,t){ person({...a,c:'#1b2a4a'},t); px(a.x+1,a.y-3,4,2,'#1b2a4a'); px(a.x+2,a.y+6,2,2,'#f2b632'); }
353
+ function dog(a,t){ const w=(Math.floor(t/200)%2); px(a.x,a.y,8,4,'#8a6a4a');px(a.x+7,a.y-2,4,3,'#8a6a4a');px(a.x+1,a.y+4,1,2+w,'#5a4330');px(a.x+6,a.y+4,1,3-w,'#5a4330');px(a.x-2,a.y-1,2,2,'#8a6a4a'); }
354
+ function deer(a,t){ const w=(Math.floor(t/400)%2); px(a.x,a.y,12,6,'#a9825a');px(a.x+11,a.y-5,4,6,'#a9825a');px(a.x+12,a.y-8,1,3,'#6e5238');px(a.x+14,a.y-8,1,3,'#6e5238');px(a.x+1,a.y+6,2,5-w,'#7a5a3c');px(a.x+9,a.y+6,2,5,'#7a5a3c');px(a.x+5,a.y+6,2,5,'#7a5a3c'); }
355
+ function tow(a){ const {x,y}=a; px(x+30,y-2,12,6,'#e0a72f');px(x,y+3,44,6,'#e0a72f');px(x+2,y-1,26,5,'#3b4160');px(x+6,y+1,14,2,'#8c8c8c');px(x+32,y-1,7,3,'#9fd3e8');px(x+4,y+9,4,3,'#111');px(x+20,y+9,4,3,'#111');px(x+36,y+9,4,3,'#111'); car(x+6,y-6,'#c94a4a'); }
356
+ function policeCar(a,t){ const {x,y}=a; car(x,y,'#e6e6e6'); px(x+2,y+5,16,2,'#1e3a8a'); px(x+7,y-2,6,2,(Math.floor(t/150)%2)?'#3b82f6':'#ef4444'); if(!reduced){ ctx.fillStyle=(Math.floor(t/150)%2)?'rgba(59,130,246,.12)':'rgba(239,68,68,.12)'; ctx.fillRect(0,0,W,H);} }
357
+ function barrow(x,y){ px(x,y,10,5,'#5c6470');px(x+10,y+1,4,1,'#5c6470');px(x+2,y+5,3,3,'#222');px(x+1,y-3,8,3,'#b5622c'); }
358
+ function spools(x,y){ for(let i=0;i<3;i++){ px(x+i*14,y,12,12,'#8a5a2b');px(x+2+i*14,y+2,8,8,'#b5622c');px(x+5+i*14,y+5,2,2,'#3a2412'); } }
359
+
360
+ function drawDealer(sc,t){ sky(); px(0,110,W,70,'#2c2f39'); px(0,110,W,2,'#3f4352');
361
+ px(20,58,120,52,'#1f2544'); px(24,64,112,40,'#3a5a7a'); for(let i=0;i<7;i++)px(28+i*16,66,12,36,'#6f9ec2'); px(20,54,120,4,'#00a9a5'); px(50,46,60,8,'#e6ebf1'); px(54,48,52,4,'#272f4f');
362
+ px(0,88,W,2,'#4a4f66'); for(let i=0;i<16;i++)px(i*20,84,2,6,'#4a4f66');
363
+ const cols=['#c94a4a','#e6e6e6','#3b6fb6','#5a5a5a','#e6e6e6','#8e3a3a','#2f7a5a','#e6e6e6'];
364
+ for(let i=0;i<8;i++) truck(10+i*40,128,cols[i]); for(let i=0;i<7;i++) car(30+i*42,150,cols[(i+3)%8]);
365
+ pole(180,110); pole(300,110); speaker(186,44); camIcon(140,40);
366
+ if(sc.cam&&sc.cam.startsWith('CAM 05')){ px(200,60,120,50,'#252a45'); for(let i=0;i<3;i++)px(206+i*38,70,30,40,'#151830'); px(200,56,120,4,'#3a4062'); }
367
+ }
368
+ function drawMulti(sc,t){ sky(); px(0,112,W,68,'#3a4a3a'); px(0,112,W,2,'#4c6048');
369
+ px(10,40,150,72,'#4b3f36'); px(10,40,150,3,'#2f2822'); for(let r=0;r<3;r++)for(let i=0;i<6;i++){ const lit=((r*7+i*3)%5)<2; px(16+i*24,46+r*22,14,12,lit?'#f6d98a':'#1c1f36'); }
370
+ for(let r=0;r<3;r++)px(10,58+r*22,150,3,'#6b5a4c');
371
+ px(70,96,30,16,'#2a2420'); px(72,98,26,14,'#7fb3d5');
372
+ if(sc.breezeway){ px(10,68,150,2,'#e6ebf1'); for(let i=0;i<10;i++)px(12+i*15,62,1,6,'#e6ebf1'); }
373
+ px(180,122,110,30,'#0e6e6c'); px(184,126,102,22,'#12a3a0'); px(178,120,114,2,'#cfd6dc'); px(178,152,114,2,'#cfd6dc'); px(180,118,2,20,'#cfd6dc');px(186,118,2,20,'#cfd6dc');px(180,120,8,1,'#cfd6dc');
374
+ px(170,112,6,10,'#7c8a99');px(150,110,4,20,'#c2c7cc');
375
+ for(let x=200;x<320;x+=8)px(x,90,2,22,'#3b3f4a'); px(200,90,120,2,'#3b3f4a'); px(200,100,120,1,'#3b3f4a');
376
+ if(sc.cam&&sc.cam.startsWith('CAM 05')){ px(240,86,3,26,'#4b4f5c');px(262,86,3,26,'#4b4f5c'); px(243,92,19,2,'#4b4f5c'); }
377
+ if(sc.cam&&sc.cam.startsWith('CAM 08')){ px(60,70,120,42,'#2b2f44'); for(let r=0;r<3;r++)for(let i=0;i<8;i++)px(64+i*14,74+r*12,11,9,'#4a5170'); }
378
+ if(sc.cam&&sc.cam.startsWith('CAM 09')){ px(20,60,200,52,'#2a2d3d'); px(30,70,80,42,'#0c0e1a'); px(120,70,80,42,'#0c0e1a'); px(20,56,200,4,'#f2b632'); }
379
+ pole(300,112); speaker(150,56); camIcon(165,36);
380
+ }
381
+ function drawBuild(sc,t){ sky(); px(0,114,W,66,'#6e5a3e'); px(0,114,W,2,'#8a7050'); px(40,140,60,6,'#5a4a34'); px(180,160,90,5,'#5a4a34');
382
+ px(60,20,4,94,'#c9a227'); px(30,24,120,4,'#c9a227'); px(62,24,2,30,'#c9a227'); px(128,28,1,40,'#666'); px(124,68,10,6,'#888');
383
+ px(220,60,90,54,'#4f5866'); px(224,66,82,42,'#333a48'); px(226,70,10,10,'#f6d98a'); px(220,56,90,4,'#c9a227');
384
+ for(let x=0;x<W;x+=6)px(x,92,1,22,'#a8adb8'); px(0,92,W,1,'#a8adb8'); px(0,100,W,1,'#a8adb8'); px(0,108,W,1,'#a8adb8'); for(let x=0;x<W;x+=24)px(x,88,2,26,'#7c8290');
385
+ if(sc.spools) spools(150,116);
386
+ if(sc.excav){ px(20,96,60,18,'#e0a72f'); px(24,84,30,14,'#e0a72f'); px(28,88,14,8,'#9fd3e8'); px(70,70,6,30,'#e0a72f'); px(76,70,30,6,'#e0a72f'); px(100,76,10,12,'#7a6a3a'); px(16,114,68,8,'#333'); }
387
+ if(sc.barrow) barrow(sc.actors[0].x-14,sc.actors[0].y+8);
388
+ px(280,84,14,30,'#3b4160'); px(283,74,8,10,'#1f2a5a'); px(286,64,2,10,'#3b4160'); speaker(292,66); camIcon(276,60);
389
+ }
390
+
391
+ function draw(t){ ctx.clearRect(0,0,W,H);
392
+ const sc=sceneOverride||(current?current.steps[stepIdx].scene:{actors:[]});
393
+ const kind=sceneOverride?sceneOverride.kind:current.id;
394
+ if(kind==='dealer')drawDealer(sc,t); else if(kind==='multi')drawMulti(sc,t); else drawBuild(sc,t);
395
+ const el=fx.type?t-fx.t0:0; const active=fx.type&&el<2600;
396
+ for(const a of sc.actors){
397
+ let aa=a;
398
+ if(active&&fx.type!=='police'&&fx.type!=='guard'&&a.t==='person'&&!reduced){ aa={...a,pose:'stand'}; }
399
+ if(a.t==='person'){ person(aa,t); if(!sceneOverride){ ctx.strokeStyle='#00a9a5'; ctx.lineWidth=1; ctx.strokeRect(a.x-3.5,a.y-3.5,13,(a.pose==='crouch'?15:21)); px(a.x-4,a.y-10,30,6,'#00a9a5'); ctx.fillStyle='#062322'; ctx.font='5px "Press Start 2P", monospace'; ctx.fillText('PERSON',a.x-2,a.y-5);} }
400
+ else if(a.t==='person')person(aa,t); else if(a.t==='dog')dog(a,t); else if(a.t==='deer')deer(a,t); else if(a.t==='tow')tow(a); else if(a.t==='police')policeCar(a,t); else if(a.t==='guard')guardSprite(a,t);
401
+ }
402
+ if(sc.spark&&!active){ const p=sc.actors[0]; if(Math.floor(t/90)%3===0)px(p.x+11,p.y+4,2,2,'#fff3b0'); }
403
+ if(active){
404
+ if(fx.type==='strobe'){ const on=reduced?true:(Math.floor(el/80)%2===0); if(on){ctx.fillStyle=reduced?'rgba(255,255,255,.25)':'rgba(255,255,255,.75)';ctx.fillRect(0,0,W,H);} }
405
+ if(fx.type==='siren'){ ctx.strokeStyle='#f2b632'; ctx.lineWidth=1; for(let i=0;i<3;i++){ const r=((el/8)+i*14)%50; ctx.beginPath(); ctx.arc(W/2,60,r,0,Math.PI*2); ctx.stroke(); } if(!reduced&&Math.floor(el/300)%2===0){ctx.fillStyle='rgba(242,182,50,.10)';ctx.fillRect(0,0,W,H);} }
406
+ if(fx.type==='voice'){ px(W/2-40,8,80,18,'#e6ebf1'); px(W/2-6,26,4,3,'#e6ebf1'); ctx.fillStyle='#272f4f'; ctx.font='8px "Press Start 2P", monospace'; ctx.fillText('YOU ARE ON',W/2-36,16); ctx.fillText('CAMERA',W/2-20,24); }
407
+ if(fx.type==='police'){ const x=-30+Math.min(el/2600,1)*140; policeCar({x,y:150},t); }
408
+ if(fx.type==='guard'){ const x=W+10-Math.min(el/2600,1)*120; guardSprite({x,y:118},t); }
409
+ }
410
+ // HUD
411
+ ctx.fillStyle='rgba(0,0,0,.45)'; ctx.fillRect(0,0,W,12); ctx.fillStyle='#e6ebf1'; ctx.font='7px "Press Start 2P", monospace';
412
+ ctx.fillText(sc.cam||(current?current.steps[stepIdx].cam:'CAM 01'),4,9);
413
+ const rec=Math.floor(t/600)%2===0; if(rec||reduced){px(W-40,3,5,5,'#e0483e');} ctx.fillText('REC',W-32,9);
414
+ const d=new Date(); const hh=String(d.getHours()).padStart(2,'0'),mm=String(d.getMinutes()).padStart(2,'0'),ss=String(d.getSeconds()).padStart(2,'0');
415
+ ctx.fillText(hh+':'+mm+':'+ss,W-108,9);
416
+ for(let i=0;i<5;i++)px(2+i*2,H-4,1,2,'#00a9a5');
417
+ requestAnimationFrame(draw);
418
+ }
419
+
420
+ /* ---------------- Sound ---------------- */
421
+ let muted=false, ac=null;
422
+ function tone(f,dur,type='square',g=0.04,sweep=null){ if(muted)return; try{ ac=ac||new (window.AudioContext||window.webkitAudioContext)(); const o=ac.createOscillator(),v=ac.createGain(); o.type=type;o.frequency.value=f; if(sweep){o.frequency.linearRampToValueAtTime(sweep,ac.currentTime+dur);} v.gain.value=g; v.gain.exponentialRampToValueAtTime(0.0001,ac.currentTime+dur); o.connect(v).connect(ac.destination); o.start(); o.stop(ac.currentTime+dur);}catch(e){} }
423
+ const SFX={ voice:()=>{tone(440,.12);setTimeout(()=>tone(660,.18),130);}, strobe:()=>{tone(1200,.05,'square',.03);setTimeout(()=>tone(1200,.05,'square',.03),160);setTimeout(()=>tone(1200,.05,'square',.03),320);},
424
+ siren:()=>{tone(600,1.0,'sawtooth',.03,1100);setTimeout(()=>tone(1100,1.0,'sawtooth',.03,600),1000);}, police:()=>{tone(700,.4,'square',.025,900);setTimeout(()=>tone(900,.4,'square',.025,700),400);setTimeout(()=>tone(700,.4,'square',.025,900),800);},
425
+ guard:()=>{tone(300,.08,'square',.03);setTimeout(()=>tone(300,.08,'square',.03),120);}, good:()=>{tone(523,.08,'square',.03);setTimeout(()=>tone(784,.12,'square',.03),90);}, bad:()=>{tone(220,.25,'square',.03,140);} };
426
+ document.getElementById('mute').onclick=e=>{muted=!muted;e.target.textContent=muted?'SOUND OFF':'SOUND ON';e.target.setAttribute('aria-pressed',muted);};
427
+
428
+ /* ---------------- Flow ---------------- */
429
+ const $=id=>document.getElementById(id);
430
+ function show(id){ for(const p of ['menu','play','result'])$(p).classList.toggle('hidden',p!==id); }
431
+ function hudScore(){ $('hudScore').textContent=current?('SCORE '+score):''; }
432
+
433
+ function buildMenu(){
434
+ const wrap=$('sites'); wrap.innerHTML='';
435
+ SCENARIOS.forEach(s=>{
436
+ const b=document.createElement('button'); b.className='site';
437
+ const c=document.createElement('canvas'); c.width=320;c.height=180; b.appendChild(c);
438
+ b.insertAdjacentHTML('beforeend',`<span>${s.name}</span><small>${s.sub}</small>`);
439
+ b.onclick=()=>start(s); wrap.appendChild(b);
440
+ // thumbnail rendered on its own canvas
441
+ const tctx=c.getContext('2d'); tctx.imageSmoothingEnabled=false;
442
+ const main=ctx; ctx=tctx; const thumb={kind:s.id,actors:[],cam:s.cam};
443
+ if(s.id==='dealer')drawDealer(thumb,0); else if(s.id==='multi')drawMulti(thumb,0); else drawBuild(thumb,0);
444
+ ctx=main;
445
+ });
446
+ sceneOverride={kind:'dealer',actors:[],cam:'CAM 01 FRONT ROW'};
447
+ }
448
+
449
+ function start(s){ current=s; stepIdx=0; score=0; recap=[]; sceneOverride=null; fx={type:null,t0:0}; show('play'); renderStep(); hudScore(); window.scrollTo({top:0,behavior:'smooth'}); }
450
+ function renderStep(){
451
+ const st=current.steps[stepIdx];
452
+ $('stepTitle').textContent=(stepIdx+1)+' / '+current.steps.length+' '+st.title;
453
+ $('narr').innerHTML=st.narr; $('hudCam').textContent=st.cam;
454
+ const wrap=$('actions'); wrap.innerHTML='';
455
+ ACTIONS.forEach(a=>{ const b=document.createElement('button'); b.className='act '+a.cls; b.textContent=a.label; b.onclick=()=>choose(a.id); wrap.appendChild(b); });
456
+ $('fb').classList.add('hidden');
457
+ }
458
+ function choose(id){
459
+ const st=current.steps[stepIdx];
460
+ const o=st.opts[id]||{...DEFAULT_FB[id],go:'stay'};
461
+ score+=o.pts; hudScore();
462
+ if(o.fx){ fx={type:o.fx,t0:performance.now()}; SFX[o.fx](); } else { (o.pts>0?SFX.good:SFX.bad)(); }
463
+ const cls=o.pts>=10?'':(o.pts>=0?'ok':'bad');
464
+ $('fb').className='feedback '+cls;
465
+ $('fbPts').textContent=(o.pts>0?'+':'')+o.pts+' points';
466
+ $('fbText').textContent=o.txt;
467
+ $('fb').classList.remove('hidden');
468
+ const label=ACTIONS.find(a=>a.id===id).label;
469
+ if(o.go!=='stay'){ recap.push({step:st.title,label,pts:o.pts}); }
470
+ $('nextBtn').textContent=o.go==='end'?'See results':(o.go==='stay'?'Try again':'Continue');
471
+ $('nextBtn').onclick=()=>{ fx={type:null,t0:0}; if(o.go==='end')finish(); else if(o.go==='next'){stepIdx++;renderStep();} else {$('fb').classList.add('hidden');} };
472
+ document.querySelectorAll('.act').forEach(b=>b.disabled=true);
473
+ $('nextBtn').focus({preventScroll:true});
474
+ }
475
+ function finish(){
476
+ show('result'); $('rScore').textContent=score; $('rMax').textContent='of '+current.max;
477
+ const pct=score/current.max;
478
+ let title,note;
479
+ if(pct>=0.9){ title='Command Center ready.'; note='You verified before acting, warned before escalating, and gave officers what they needed. That is the job.'; }
480
+ else if(pct>=0.65){ title='Solid shift.'; note='The incident ended well. A few escalations came early or late. Verify first, warn second, escalate when they stay.'; }
481
+ else if(pct>=0.35){ title='Trainee shift.'; note='The subject was handled, but the order of operations cost points. Most incidents end at the voice down when it comes at the right moment.'; }
482
+ else { title='Back to the classroom.'; note='Early police calls and cleared events without verification are the two mistakes clients feel most. Watch first, then act.'; }
483
+ $('rTitle').textContent=title; $('rNote').textContent=note;
484
+ $('recap').innerHTML=recap.map(r=>`<li><b>${r.step}</b>: ${r.label} (${r.pts>0?'+':''}${r.pts})</li>`).join('');
485
+ sceneOverride=null;
486
+ }
487
+ $('again').onclick=()=>{ current=null; hudScore(); $('hudCam').textContent='CAM --'; show('menu'); sceneOverride={kind:'dealer',actors:[],cam:'CAM 01 FRONT ROW'}; };
488
+
489
+ SCENARIOS.forEach(s=>{ s.max=s.steps.reduce((a,st)=>a+Math.max(...Object.values(st.opts).filter(o=>o.go!=='stay').map(o=>o.pts)),0); });
490
+ buildMenu(); requestAnimationFrame(draw);
491
+ </script>
492
+ </body>
493
  </html>
vg-game.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da5f770ba5dc2a9857909f20b34308f85b5c3fd14ec62b305cfaa27436c39cf0
3
+ size 16152