slakbaum.html 105 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390
  1. <!DOCTYPE html>
  2. <html lang="ru">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>КПП-3 · СКУД · Пост охраны</title>
  6. <style>
  7. * { box-sizing: border-box; margin: 0; padding: 0; }
  8. html, body { height: 100%; overflow: hidden; background: #050810;
  9. color: #a8b8c8; font-family: "Courier New", monospace; font-size: 13px; }
  10. .app { height: 100vh; display: flex; flex-direction: column; }
  11. header { display: flex; align-items: center; gap: 20px; padding: 8px 20px;
  12. background: linear-gradient(180deg, #202830, #10161e);
  13. border-bottom: 1px solid #000; flex-shrink: 0; }
  14. .brand { color: #ffd060; font-weight: bold; letter-spacing: 2px;
  15. font-size: 14px; text-shadow: 0 0 10px rgba(255,208,96,.5); }
  16. .brand small { color: #506080; font-weight: normal; letter-spacing: 1px;
  17. font-size: 10px; margin-left: 8px; }
  18. .model-badge { color: #80e0ff; font-size: 11px; letter-spacing: 1px;
  19. border: 1px solid #2a4a6a; padding: 3px 8px; border-radius: 2px;
  20. background: rgba(64,160,224,.08); }
  21. .header-status { display: flex; align-items: center; gap: 10px; margin-left: auto; }
  22. .clock { color: #6090c0; letter-spacing: 2px; font-size: 13px; }
  23. .sys-status { display: flex; align-items: center; gap: 8px;
  24. color: #40d080; font-weight: bold; font-size: 12px; letter-spacing: 1px; }
  25. .sys-status .led { width: 10px; height: 10px; border-radius: 50%;
  26. background: #40d080; box-shadow: 0 0 10px #40d080; }
  27. .sys-status.alarm { color: #ff4040; }
  28. .sys-status.alarm .led { background: #ff4040;
  29. box-shadow: 0 0 15px #ff4040; animation: blink .4s infinite; }
  30. .sys-status.wait { color: #ffb040; }
  31. .sys-status.wait .led { background: #ffb040; box-shadow: 0 0 10px #ffb040; }
  32. .sys-status.pause { color: #60a0e0; }
  33. .sys-status.pause .led { background: #60a0e0; box-shadow: 0 0 10px #60a0e0; }
  34. .sys-status.senior { color: #a0c8ff; }
  35. .sys-status.senior .led { background: #a0c8ff; box-shadow: 0 0 10px #a0c8ff; }
  36. @keyframes blink { 50% { opacity: .2; } }
  37. .log-toggle {
  38. background: linear-gradient(180deg, #1a2028, #101418);
  39. color: #a0c0e0; border: 1px solid #304050;
  40. border-radius: 3px; padding: 5px 12px;
  41. font-family: inherit; font-size: 11px; letter-spacing: 1.2px;
  42. cursor: pointer; transition: all .15s; margin-left: 8px;
  43. }
  44. .log-toggle:hover { border-color: #4080c0; color: #c0e0ff;
  45. box-shadow: 0 0 10px rgba(96,160,224,.3); }
  46. .log-toggle.has-alarm { border-color: #702020; color: #ffb0b0;
  47. animation: blink 1s infinite; }
  48. main { flex: 1; display: grid; grid-template-columns: 1fr 420px; min-height: 0; }
  49. .stage { position: relative; background: #02040a; overflow: hidden; }
  50. .stage canvas { display: block; width: 100%; height: 100%; }
  51. aside { background: #0a0e14; border-left: 1px solid #1a2028;
  52. display: flex; flex-direction: column; overflow-y: auto; }
  53. aside .section { border-bottom: 1px solid #1a2028; flex-shrink: 0; }
  54. aside .section h3 { padding: 8px 14px; background: #080c12; color: #6070a0;
  55. font-size: 10.5px; letter-spacing: 2px; font-weight: normal;
  56. border-bottom: 1px solid #1a2028; }
  57. aside .content { padding: 10px 12px; }
  58. .model-switcher { display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
  59. padding: 10px 12px; }
  60. .model-btn {
  61. background: linear-gradient(180deg, #10161e, #0a0e14);
  62. border: 1px solid #2a3442; border-radius: 3px;
  63. padding: 8px 8px; cursor: pointer; transition: all .15s;
  64. font-family: inherit; color: #8090a8; text-align: left;
  65. font-size: 10.5px; line-height: 1.35;
  66. }
  67. .model-btn:hover { border-color: #4080c0; background: #0f1822; color: #c0d0e0; }
  68. .model-btn.active {
  69. border-color: #40d080; color: #c0f0d0;
  70. background: linear-gradient(180deg, #10201a, #0a1a14);
  71. box-shadow: 0 0 12px rgba(64,208,128,.25), inset 0 0 8px rgba(64,208,128,.08);
  72. }
  73. .model-btn .mb-code { display: block; color: #ffd060; font-weight: bold;
  74. font-size: 12px; letter-spacing: 1.5px; margin-bottom: 2px; }
  75. .model-btn .mb-name { display: block; color: #80e0ff; font-size: 10px;
  76. margin-bottom: 3px; }
  77. .model-btn .mb-spec { display: block; color: #506080; font-size: 9.5px; }
  78. .model-btn.active .mb-spec { color: #80a0c0; }
  79. .passport-grid { display: grid; grid-template-columns: 1fr 1fr;
  80. gap: 3px 12px; font-size: 11px; }
  81. .passport-row { display: flex; justify-content: space-between;
  82. padding: 3px 0; border-bottom: 1px dashed #1a2028; }
  83. .passport-row .pk { color: #607890; }
  84. .passport-row .pv { color: #a0d0ff; font-weight: bold; }
  85. .passport-title { color: #ffd060; font-size: 12.5px;
  86. font-weight: bold; letter-spacing: 1.5px; margin-bottom: 8px; }
  87. .passport-sub { color: #80e0ff; font-size: 10.5px;
  88. letter-spacing: 1px; margin-bottom: 8px; }
  89. .passport-desc { color: #8090a8; font-size: 10.5px;
  90. line-height: 1.5; margin-bottom: 10px;
  91. padding: 6px 8px; background: #060a10; border-left: 2px solid #2a4a6a;
  92. border-radius: 2px; }
  93. .terminal-list { display: flex; flex-wrap: wrap; gap: 4px;
  94. margin-top: 8px; }
  95. .terminal-chip { font-size: 9.5px; padding: 2px 6px;
  96. border-radius: 2px; background: #060a10; border: 1px solid #2a3442;
  97. color: #8090a8; letter-spacing: .5px; }
  98. .terminal-chip.on { background: rgba(64,208,128,.12); color: #80e0a0;
  99. border-color: rgba(64,208,128,.5); }
  100. .terminal-chip.alarm { background: rgba(255,64,64,.12); color: #ff8080;
  101. border-color: rgba(255,64,64,.5); }
  102. .skud-group { margin-bottom: 12px; }
  103. .skud-group:last-child { margin-bottom: 0; }
  104. .skud-group-title { color: #4a5a70; font-size: 9.5px;
  105. letter-spacing: 1.5px; margin-bottom: 6px;
  106. padding-bottom: 3px; border-bottom: 1px solid #141c24; }
  107. .skud-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 10px;
  108. font-size: 11px; }
  109. .skud-row { display: flex; align-items: center; gap: 6px; padding: 3px 0;
  110. color: #8090a0; }
  111. .skud-led { width: 9px; height: 9px; border-radius: 50%;
  112. background: #1a2028; border: 1px solid #2a3442; flex-shrink: 0;
  113. transition: all .1s; }
  114. .skud-led.on { background: #40d080; box-shadow: 0 0 8px #40d080; border-color: #40d080; }
  115. .skud-led.red { background: #ff4040; box-shadow: 0 0 8px #ff4040; border-color: #ff4040; }
  116. .skud-led.yel { background: #ffb040; box-shadow: 0 0 8px #ffb040; border-color: #ffb040; }
  117. .skud-led.blu { background: #60a0e0; box-shadow: 0 0 8px #60a0e0; border-color: #60a0e0; }
  118. .skud-led.blink { animation: blink .5s infinite; }
  119. .fsm-table { background: #060a10; border: 1px solid #1a2028;
  120. border-radius: 2px; padding: 8px 10px; }
  121. .fsm-row { display: flex; justify-content: space-between;
  122. padding: 3px 0; font-size: 11px; }
  123. .fsm-k { color: #607890; }
  124. .fsm-v { color: #80e0ff; font-weight: bold; letter-spacing: .5px; }
  125. .scope-canvas { display: block; width: 100%;
  126. background: #04060a; border: 1px solid #1a2028; border-radius: 2px;
  127. image-rendering: pixelated; }
  128. .passes-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  129. .pass-card { background: #060a10; border: 1px solid #202836;
  130. border-radius: 3px; padding: 8px 10px; font-size: 11.5px; }
  131. .pass-card .head { color: #506080; font-size: 9.5px; letter-spacing: 1.5px;
  132. margin-bottom: 6px; text-transform: uppercase; }
  133. .pass-card .num { color: #ffd060; font-weight: bold; font-size: 15px;
  134. letter-spacing: 1px; margin-bottom: 4px; }
  135. .pass-card .name { color: #c0d0e0; margin-bottom: 4px; font-size: 11px; line-height: 1.3; }
  136. .pass-card .dept { color: #607890; font-size: 10px; margin-bottom: 6px; }
  137. .pass-card .status { padding: 3px 6px; border-radius: 2px; font-size: 10px;
  138. letter-spacing: 1px; text-align: center; display: inline-block; }
  139. .pass-card .status.ok { background: rgba(64,208,128,.15); color: #40d080;
  140. border: 1px solid rgba(64,208,128,.4); }
  141. .pass-card .status.bad { background: rgba(255,64,64,.15); color: #ff6060;
  142. border: 1px solid rgba(255,64,64,.4); }
  143. .pass-card .status.empty { background: rgba(80,90,110,.1); color: #506080;
  144. border: 1px dashed #303848; }
  145. .decision-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
  146. padding: 10px 12px; border-bottom: 1px solid #1a2028; }
  147. button.big { background: linear-gradient(180deg, #1a2028, #101418);
  148. color: #c0d0e0; border: 1px solid #304050;
  149. border-radius: 3px; padding: 12px 10px;
  150. font-family: inherit; font-size: 12.5px; letter-spacing: 1.5px;
  151. cursor: pointer; transition: all .15s; font-weight: bold; }
  152. button.big.allow { border-color: #206030; color: #80e0a0; }
  153. button.big.allow:hover:not(:disabled) {
  154. background: linear-gradient(180deg, #204830, #103020); border-color: #40d080; }
  155. button.big.deny { border-color: #702020; color: #ffb0b0; }
  156. button.big.deny:hover:not(:disabled) {
  157. background: linear-gradient(180deg, #401818, #200808); border-color: #ff4040; }
  158. button.big:disabled { opacity: .3; cursor: not-allowed; }
  159. .action-buttons { padding: 10px 12px; display: flex; flex-direction: column;
  160. gap: 6px; border-bottom: 1px solid #1a2028; }
  161. button.ctrl { background: linear-gradient(180deg, #1a2028, #101418);
  162. color: #c0d0e0; border: 1px solid #304050;
  163. border-radius: 2px; padding: 8px 12px;
  164. font-family: inherit; font-size: 11.5px; letter-spacing: .5px;
  165. cursor: pointer; transition: all .15s; }
  166. button.ctrl:hover:not(:disabled) { background: linear-gradient(180deg, #243040, #182028);
  167. border-color: #4080c0; }
  168. button.ctrl:disabled { opacity: .35; cursor: not-allowed; }
  169. button.ctrl.primary { border-color: #4060a0; color: #a0d0ff; }
  170. button.ctrl.danger { border-color: #702020; color: #ffb0b0; }
  171. button.ctrl.green { border-color: #206030; color: #80e0a0; }
  172. button.ctrl.yel { border-color: #706020; color: #ffd060; }
  173. button.ctrl.red { border-color: #702020; color: #ffb0b0; }
  174. button.ctrl.active { background: linear-gradient(180deg, #2a3a4a, #1a2028);
  175. border-color: #60a0e0; color: #c0e0ff; box-shadow: 0 0 8px rgba(96,160,224,.4); }
  176. .ctrl-panel-grid { display: grid; grid-template-columns: 1fr 1fr 1fr;
  177. gap: 6px; padding: 10px 12px; }
  178. .ctrl-panel-grid button { padding: 10px 4px; font-size: 11px;
  179. letter-spacing: 1px; font-weight: bold; }
  180. .mode-row { display: flex; gap: 6px; padding: 0 12px 10px; }
  181. .mode-row button { flex: 1; padding: 7px 4px; font-size: 10.5px; }
  182. .cond-row { display: flex; align-items: center; gap: 8px;
  183. padding: 6px 0; font-size: 11.5px; color: #8090a0; }
  184. .cond-row label { min-width: 60px; color: #607890; }
  185. .cond-row select {
  186. flex: 1; background: #060a10; color: #c0d0e0;
  187. border: 1px solid #304050; border-radius: 2px;
  188. padding: 5px 8px; font-family: inherit; font-size: 11.5px;
  189. cursor: pointer; }
  190. .log { overflow-y: auto; padding: 8px 12px;
  191. font-size: 10.5px; line-height: 1.7; }
  192. .log .entry { color: #8090a8; }
  193. .log .entry.warn { color: #ffb040; }
  194. .log .entry.alarm { color: #ff4040; font-weight: bold; }
  195. .log .entry.ok { color: #40d080; }
  196. .log .entry.info { color: #60a0e0; }
  197. .log .entry.senior { color: #a0c8ff; font-weight: bold; }
  198. .log .entry.skud { color: #80e0ff; }
  199. .log .entry .time { color: #405060; margin-right: 6px; }
  200. .log-full { max-height: 60vh; min-height: 300px;
  201. background: #060a10; border: 1px solid #1a2028; border-radius: 3px;
  202. padding: 12px 14px; font-size: 11.5px; line-height: 1.8; }
  203. .hint { padding: 8px 12px; background: rgba(96,160,224,.08);
  204. border-left: 2px solid #4080c0; color: #a0c0e0;
  205. font-size: 11px; line-height: 1.5; }
  206. .hint b { color: #ffd060; }
  207. .modal { position: fixed; inset: 0; background: rgba(0,0,0,.85);
  208. display: none; align-items: center; justify-content: center;
  209. z-index: 100; padding: 20px; }
  210. .modal.on { display: flex; }
  211. .modal-content {
  212. background: linear-gradient(180deg, #1a2230, #0d141c);
  213. border: 2px solid #3a4858;
  214. border-radius: 4px;
  215. box-shadow: 0 0 0 1px #000, 0 20px 60px rgba(0,0,0,.9);
  216. padding: 24px 28px; width: min(640px, 100%);
  217. max-height: 90vh; overflow-y: auto;
  218. color: #c0d0e0; font-family: "Courier New", monospace; }
  219. .modal-content h2 {
  220. color: #ffd060; font-size: 16px; letter-spacing: 2px;
  221. margin-bottom: 20px; text-align: center;
  222. text-shadow: 0 0 10px rgba(255,208,96,.4); }
  223. .modal-content .row {
  224. display: flex; justify-content: space-between;
  225. padding: 8px 0; border-bottom: 1px solid #1a2028;
  226. font-size: 12.5px; }
  227. .modal-content .row:last-of-type { border-bottom: none; }
  228. .modal-content .row .k { color: #8090a0; }
  229. .modal-content .row .v { color: #a0d0ff; font-weight: bold; }
  230. .modal-content .row .v.good { color: #40d080; }
  231. .modal-content .row .v.bad { color: #ff6060; }
  232. .modal-content button {
  233. display: block; margin: 20px auto 0;
  234. padding: 10px 30px; font-family: inherit;
  235. font-size: 13px; letter-spacing: 1.5px;
  236. background: linear-gradient(180deg, #1a2028, #101418);
  237. color: #c0d0e0; border: 1px solid #4060a0;
  238. border-radius: 3px; cursor: pointer; }
  239. .modal-content button:hover { background: linear-gradient(180deg, #243040, #182028); }
  240. .refuse-menu {
  241. display: none; padding: 10px 12px;
  242. background: rgba(200,80,80,.08);
  243. border-top: 1px solid rgba(255,64,64,.3);
  244. border-bottom: 1px solid #1a2028; }
  245. .refuse-menu.on { display: block; }
  246. .refuse-menu .title {
  247. font-size: 10.5px; color: #ffb040; letter-spacing: 1.5px;
  248. margin-bottom: 8px; }
  249. .refuse-menu button.ctrl {
  250. width: 100%; margin-bottom: 5px;
  251. text-align: left; font-size: 11.5px; padding: 7px 10px; }
  252. .refuse-menu button.ctrl:last-child { margin-bottom: 0; }
  253. </style>
  254. </head>
  255. <body>
  256. <div class="app">
  257. <header>
  258. <div class="brand">◤ КПП-3<small>СКУД · ПОСТ ОХРАНЫ</small></div>
  259. <div class="model-badge" id="modelBadge">МОДЕЛЬ: —</div>
  260. <div class="header-status">
  261. <div class="clock" id="clock">--:--:--</div>
  262. <div class="sys-status" id="sysStatus"><span class="led"></span><span id="sysStatusText">ПОСТ СВОБОДЕН</span></div>
  263. <button class="log-toggle" id="btnLogToggle" title="Журнал событий (F2)">☰ ЖУРНАЛ</button>
  264. </div>
  265. </header>
  266. <main>
  267. <div class="stage"><canvas id="cv"></canvas></div>
  268. <aside>
  269. <div class="section">
  270. <h3>ПАСПОРТ МОДЕЛИ · ТЕХНИЧЕСКИЕ ХАРАКТЕРИСТИКИ</h3>
  271. <div class="content" id="modelPassport"></div>
  272. </div>
  273. <div class="section">
  274. <h3>МОДЕЛЬ ШЛАГБАУМА · ПЕРЕКЛЮЧАТЕЛЬ</h3>
  275. <div class="model-switcher" id="modelSwitcher"></div>
  276. </div>
  277. <div class="section">
  278. <h3>СТАНЦИЯ СКУД · ИНДИКАЦИЯ</h3>
  279. <div class="content">
  280. <div class="skud-group">
  281. <div class="skud-group-title">ПИТАНИЕ И ПРИВОД</div>
  282. <div class="skud-grid">
  283. <div class="skud-row"><span class="skud-led" id="ledPower"></span>ПИТАНИЕ 24V</div>
  284. <div class="skud-row"><span class="skud-led" id="ledArmState"></span>СОСТОЯНИЕ ПРИВОДА</div>
  285. </div>
  286. </div>
  287. <div class="skud-group">
  288. <div class="skud-group-title">КОНЦЕВЫЕ ВЫКЛЮЧАТЕЛИ</div>
  289. <div class="skud-grid">
  290. <div class="skud-row"><span class="skud-led" id="ledKW1"></span>КВ-1 · СТРЕЛА ВНИЗ</div>
  291. <div class="skud-row"><span class="skud-led" id="ledKW2"></span>КВ-2 · СТРЕЛА ВВЕРХ</div>
  292. </div>
  293. </div>
  294. <div class="skud-group">
  295. <div class="skud-group-title">ДАТЧИКИ И ПЕТЛИ</div>
  296. <div class="skud-grid">
  297. <div class="skud-row"><span class="skud-led" id="ledLoop1"></span>ПЕТЛЯ 1 · ВХОД</div>
  298. <div class="skud-row"><span class="skud-led" id="ledLoop2"></span>ПЕТЛЯ 2 · ВЫХОД</div>
  299. <div class="skud-row"><span class="skud-led" id="ledPhoto"></span>ФОТОЭЛЕМЕНТ</div>
  300. <div class="skud-row"><span class="skud-led" id="ledImpact"></span>ДАТЧИК УДАРА</div>
  301. </div>
  302. </div>
  303. <div class="skud-group">
  304. <div class="skud-group-title">РЕЛЕ КОНТРОЛЛЕРА</div>
  305. <div class="skud-grid">
  306. <div class="skud-row"><span class="skud-led" id="ledRelayOpen"></span>РЕЛЕ 1 · OPEN</div>
  307. <div class="skud-row"><span class="skud-led" id="ledRelayClose"></span>РЕЛЕ 2 · CLOSE</div>
  308. <div class="skud-row"><span class="skud-led" id="ledRelayStop"></span>РЕЛЕ 3 · STOP</div>
  309. <div class="skud-row"><span class="skud-led" id="ledPass"></span>СИГНАЛ ПРОПУСКА</div>
  310. </div>
  311. </div>
  312. <div class="skud-group">
  313. <div class="skud-group-title">ТАБЛО СОСТОЯНИЙ КОНТРОЛЛЕРА</div>
  314. <div class="fsm-table">
  315. <div class="fsm-row"><span class="fsm-k">Состояние:</span>
  316. <span class="fsm-v" id="fsmState">IDLE</span></div>
  317. <div class="fsm-row"><span class="fsm-k">Таймер автозакрытия:</span>
  318. <span class="fsm-v" id="fsmTimer">0.0 / 0.0 с</span></div>
  319. <div class="fsm-row"><span class="fsm-k">Позиция стрелы:</span>
  320. <span class="fsm-v" id="fsmLevel">0 %</span></div>
  321. <div class="fsm-row"><span class="fsm-k">Активных тревог:</span>
  322. <span class="fsm-v" id="fsmAlarms">0</span></div>
  323. </div>
  324. </div>
  325. <div class="skud-group">
  326. <div class="skud-group-title">ОСЦИЛЛОГРАММА СИГНАЛОВ · REALTIME</div>
  327. <canvas id="scopeCanvas" class="scope-canvas" width="360" height="90"></canvas>
  328. </div>
  329. </div>
  330. </div>
  331. <div class="section">
  332. <h3>ПУЛЬТ ОПЕРАТОРА</h3>
  333. <div class="ctrl-panel-grid">
  334. <button class="ctrl green" id="btnOpen">▲ ОТКР</button>
  335. <button class="ctrl red" id="btnClose">▼ ЗАКР</button>
  336. <button class="ctrl yel" id="btnStop">■ СТОП</button>
  337. </div>
  338. <div class="mode-row">
  339. <button class="ctrl" id="btnHold">УДЕРЖАНИЕ</button>
  340. <button class="ctrl" id="btnMode">РЕЖИМ: АВТО</button>
  341. <button class="ctrl" id="btnResetAlarm">СБРОС АВАРИИ</button>
  342. </div>
  343. </div>
  344. <div class="section">
  345. <h3>СРАВНЕНИЕ ПРОПУСКОВ</h3>
  346. <div class="content"><div class="passes-compare" id="passesCompare"></div></div>
  347. </div>
  348. <div class="hint" id="hint">Нажмите «Следующая машина».</div>
  349. <div class="decision-buttons">
  350. <button class="big allow" id="btnAllow" disabled>✓ РАЗРЕШИТЬ</button>
  351. <button class="big deny" id="btnDeny" disabled>✕ ЗАПРЕТИТЬ</button>
  352. </div>
  353. <div class="refuse-menu" id="refuseMenu">
  354. <div class="title">МАШИНА ЖДЁТ РЕШЕНИЯ</div>
  355. <button class="ctrl" data-refuse="explain">📢 Объяснить причину отказа</button>
  356. <button class="ctrl" data-refuse="senior">📞 Вызвать старшего смены</button>
  357. </div>
  358. <div class="action-buttons">
  359. <button class="ctrl primary" id="btnNext">▸ Следующая машина</button>
  360. <button class="ctrl danger" id="btnBreach">⚠ Силовой проезд</button>
  361. <button class="ctrl" id="btnNote">✎ Отметить в журнале</button>
  362. <button class="ctrl" id="btnEndShift">⏹ Закончить смену</button>
  363. <button class="ctrl" id="btnReset">↻ Сброс</button>
  364. </div>
  365. <div class="section">
  366. <h3>ВНЕШНИЕ УСЛОВИЯ</h3>
  367. <div class="content">
  368. <div class="cond-row">
  369. <label>Время</label>
  370. <select id="selTimeOfDay">
  371. <option value="day">☀ День</option>
  372. <option value="night" selected>🌙 Ночь</option>
  373. </select>
  374. </div>
  375. <div class="cond-row">
  376. <label>Погода</label>
  377. <select id="selWeather">
  378. <option value="clear" selected>☀ Ясно</option>
  379. <option value="rain">🌧 Дождь</option>
  380. <option value="snow">❄ Снег</option>
  381. <option value="fog">🌫 Туман</option>
  382. </select>
  383. </div>
  384. </div>
  385. </div>
  386. </aside>
  387. </main>
  388. </div>
  389. <div class="modal" id="logModal">
  390. <div class="modal-content" style="width: min(900px, 100%);">
  391. <h2>ЖУРНАЛ СОБЫТИЙ · СКУД</h2>
  392. <div class="log log-full" id="logContentFull"></div>
  393. <div style="display:flex; gap:8px; justify-content:center; margin-top:16px;">
  394. <button id="btnLogClear" style="margin:0;">ОЧИСТИТЬ</button>
  395. <button id="btnLogClose" style="margin:0;">ЗАКРЫТЬ</button>
  396. </div>
  397. </div>
  398. </div>
  399. <div class="modal" id="shiftModal">
  400. <div class="modal-content">
  401. <h2>СМЕНА ЗАВЕРШЕНА</h2>
  402. <div class="row"><span class="k">Впущено автомобилей:</span><span class="v good" id="statIn">0</span></div>
  403. <div class="row"><span class="k">Отказано в проезде:</span><span class="v" id="statOut">0</span></div>
  404. <div class="row"><span class="k">Верных решений:</span><span class="v good" id="statCorrect">0</span></div>
  405. <div class="row"><span class="k">Ошибок оператора:</span><span class="v bad" id="statWrong">0</span></div>
  406. <div class="row"><span class="k">Сработало тревог:</span><span class="v bad" id="statAlarms">0</span></div>
  407. <div class="row"><span class="k">Срабатываний датчиков:</span><span class="v" id="statSensors">0</span></div>
  408. <div class="row"><span class="k">Силовых проездов:</span><span class="v bad" id="statBreaches">0</span></div>
  409. <div class="row"><span class="k">Вызовов старшего:</span><span class="v" id="statSenior">0</span></div>
  410. <div class="row"><span class="k">Оценка смены:</span><span class="v" id="statGrade">—</span></div>
  411. <button id="btnCloseShift">ЗАКРЫТЬ И СБРОСИТЬ</button>
  412. </div>
  413. </div>
  414. <script>
  415. const cv = document.getElementById('cv');
  416. const ctx = cv.getContext('2d');
  417. let W = 0, H = 0;
  418. function resizeCanvas() {
  419. const stage = cv.parentElement;
  420. const dpr = window.devicePixelRatio || 1;
  421. W = stage.clientWidth; H = stage.clientHeight;
  422. cv.width = Math.floor(W * dpr); cv.height = Math.floor(H * dpr);
  423. ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  424. }
  425. window.addEventListener('resize', resizeCanvas);
  426. function L() {
  427. return {
  428. horizonY: H * 0.32,
  429. topLaneY: H * 0.58, topLaneTop: H * 0.52, topLaneBot: H * 0.66,
  430. botLaneY: H * 0.78, botLaneTop: H * 0.72, botLaneBot: H * 0.84,
  431. boothX: W * 0.40, boothW: 150, boothH: 165, boothBaseY: H * 0.55,
  432. pillarX: W * 0.72, pillarW: 30, pillarH: 260,
  433. pivotX: W * 0.72, pivotY: H * 0.58 - 80,
  434. armLen: 240, armH: 16
  435. };
  436. }
  437. /* ===== МОДЕЛИ ===== */
  438. const BARRIER_MODELS = {
  439. A: {
  440. code:'A', name:'СКОРОСТНОЙ', short:'«Спринт-90»',
  441. vendor:'СКУД-Системы', series:'SP-90',
  442. desc:'Высокоскоростная модель для офисных КПП и паркингов. Полный цикл ~1 с.',
  443. openTime:0.9, closeTime:1.2, autoClose:3, armLen:180,
  444. hasHold:true, hasPhoto:false, hasLoop:false, hasImpact:false, hasKW2:true,
  445. driveType:'Линейный', driveVoltage:'24 В DC', drivePower:'120 Вт',
  446. ingressProtection:'IP54', operatingTemp:'−40…+55 °C',
  447. dutyCycle:'80 %', mttr:'~15 мин',
  448. terminals:['+24V','GND','OPEN','CLOSE','STOP','KW1','KW2','LOOP1','LOOP2','PHOTO']
  449. },
  450. B: {
  451. code:'B', name:'МАГИСТРАЛЬНЫЙ', short:'«Стандарт-240»',
  452. vendor:'СКУД-Системы', series:'ST-240',
  453. desc:'Для въезда на территорию предприятий. Петля + фотоэлемент.',
  454. openTime:3.5, closeTime:3.5, autoClose:8, armLen:240,
  455. hasHold:false, hasPhoto:true, hasLoop:true, hasImpact:false, hasKW2:true,
  456. driveType:'Рычажный', driveVoltage:'230 В AC', drivePower:'250 Вт',
  457. ingressProtection:'IP65', operatingTemp:'−40…+60 °C',
  458. dutyCycle:'60 %', mttr:'~30 мин',
  459. terminals:['L','N','PE','OPEN','CLOSE','STOP','KW1','KW2','LOOP1','LOOP2','PHOTO','BUZZ']
  460. },
  461. C: {
  462. code:'C', name:'ПРОМЫШЛЕННЫЙ', short:'«Терминал-280»',
  463. vendor:'ПромАвтоматика', series:'IND-280',
  464. desc:'Для грузового транспорта. Две петли, фотоэлемент, датчик удара.',
  465. openTime:6, closeTime:6, autoClose:15, armLen:280,
  466. hasHold:false, hasPhoto:true, hasLoop:true, hasImpact:true, hasKW2:true,
  467. driveType:'Редукторный', driveVoltage:'380 В AC', drivePower:'750 Вт',
  468. ingressProtection:'IP66', operatingTemp:'−50…+60 °C',
  469. dutyCycle:'100 %', mttr:'~45 мин',
  470. terminals:['L1','L2','L3','N','PE','OPEN','CLOSE','STOP','KW1','KW2',
  471. 'LOOP1','LOOP2','PHOTO','IMPACT','ALARM','READY']
  472. },
  473. D: {
  474. code:'D', name:'БЮДЖЕТНЫЙ', short:'«Мини-200»',
  475. vendor:'ЭкономСервис', series:'EC-200',
  476. desc:'Для внутренних КПП. Без автоматики, ручное управление с пульта.',
  477. openTime:4, closeTime:4, autoClose:0, armLen:200,
  478. hasHold:true, hasPhoto:false, hasLoop:false, hasImpact:false, hasKW2:false,
  479. driveType:'Линейный', driveVoltage:'24 В DC', drivePower:'90 Вт',
  480. ingressProtection:'IP44', operatingTemp:'−25…+50 °C',
  481. dutyCycle:'50 %', mttr:'~20 мин',
  482. terminals:['+24V','GND','OPEN','CLOSE','STOP','KW1']
  483. }
  484. };
  485. let selectedModelCode = 'B';
  486. const state = {
  487. time: 0,
  488. model: null,
  489. barrier: { level: 0, target: 0, moving: false },
  490. holdMode: false,
  491. manualMode: false,
  492. autoCloseTimer: 0,
  493. car: null,
  494. correctPass: null, presentedPass: null,
  495. phase: 'idle',
  496. userDecision: null, correctDecision: null,
  497. alarm: false, alarmEnd: 0, sparks: [],
  498. police: null, tow: null, towDriver: null, towCable: false, leaving: false,
  499. timeOfDay: 'night',
  500. weather: 'clear',
  501. weatherParticles: [],
  502. senior: null,
  503. skud: {
  504. power: true, kw1: true, kw2: false,
  505. loop1: false, loop2: false,
  506. photo: false, impact: false,
  507. relayOpen: false, relayClose: false, relayStop: false,
  508. passValid: false, alarm: false
  509. },
  510. stats: { in:0, out:0, correct:0, wrong:0, alarms:0, breaches:0, senior:0, sensors:0 }
  511. };
  512. /* ===== ЖУРНАЛ ===== */
  513. const logEntries = [];
  514. function logEvent(text, cls) {
  515. const now = new Date();
  516. const t = String(now.getHours()).padStart(2,'0')+':'+String(now.getMinutes()).padStart(2,'0')+':'+String(now.getSeconds()).padStart(2,'0');
  517. logEntries.push({ t, text, cls: cls||'info' });
  518. if (logEntries.length > 200) logEntries.shift();
  519. renderLog();
  520. }
  521. function logSkud(text, cls) { logEvent('[СКУД] ' + text, cls || 'skud'); }
  522. function renderLog() {
  523. const html = logEntries.map(e =>
  524. `<div class="entry ${e.cls}"><span class="time">${e.t}</span>${e.text}</div>`
  525. ).join('');
  526. const el = document.getElementById('logContentFull');
  527. if (el) {
  528. const wasBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) < 30;
  529. el.innerHTML = html;
  530. if (wasBottom) el.scrollTop = el.scrollHeight;
  531. }
  532. updateLogBadge();
  533. }
  534. function updateLogBadge() {
  535. const btn = document.getElementById('btnLogToggle');
  536. if (!btn) return;
  537. const modal = document.getElementById('logModal');
  538. const modalOpen = modal && modal.classList.contains('on');
  539. const hasAlarm = logEntries.slice(-5).some(e => e.cls === 'alarm');
  540. btn.classList.toggle('has-alarm', hasAlarm && !modalOpen);
  541. }
  542. function setStatus(text, cls) {
  543. document.getElementById('sysStatusText').textContent = text;
  544. document.getElementById('sysStatus').className = 'sys-status' + (cls ? ' ' + cls : '');
  545. }
  546. function setHint(text) { document.getElementById('hint').innerHTML = text; }
  547. /* ===== ОСВЕЩЕНИЕ ===== */
  548. function lighting() {
  549. const isDay = state.timeOfDay === 'day';
  550. const w = state.weather;
  551. if (w === 'fog') return isDay ? { mode: 'dawn' } : { mode: 'dusk' };
  552. if (w === 'rain') return isDay ? { mode: 'overcast' } : { mode: 'wetnight' };
  553. if (w === 'snow') return isDay ? { mode: 'overcast' } : { mode: 'snownight' };
  554. return isDay ? { mode: 'day' } : { mode: 'night' };
  555. }
  556. function skyColors() {
  557. const m = lighting().mode;
  558. switch (m) {
  559. case 'day': return ['#3d5d80', '#98c4dc', '#d8e8f2'];
  560. case 'dawn': return ['#2a3a5c', '#7a94b4', '#c4b8b0'];
  561. case 'dusk': return ['#1a1428', '#382a48', '#605060'];
  562. case 'overcast': return ['#48505c', '#6a7280', '#8a94a4'];
  563. case 'night': return ['#02040a', '#0a1220', '#182230'];
  564. case 'wetnight': return ['#060a14', '#0e1626', '#1a2032'];
  565. case 'snownight': return ['#0a0e1a', '#1a2234', '#2a3044'];
  566. }
  567. return ['#02040a', '#0a1220', '#182230'];
  568. }
  569. function isSunVisible() {
  570. const m = lighting().mode;
  571. return m === 'day' || m === 'dawn';
  572. }
  573. function isStarVisible() {
  574. const m = lighting().mode;
  575. return m === 'night' || m === 'wetnight' || m === 'snownight' || m === 'dusk';
  576. }
  577. function isWet() {
  578. return state.weather === 'rain';
  579. }
  580. function weatherLabel() {
  581. const w = { clear:'ясно', rain:'дождь', snow:'снег', fog:'туман' }[state.weather];
  582. const t = state.timeOfDay === 'night' ? 'ночь' : 'день';
  583. return t + ', ' + w;
  584. }
  585. /* ===== ПРОПУСКА ===== */
  586. const firstNames = ['Иванов','Петров','Сидоров','Кузнецов','Морозов','Волков','Соколов','Лебедев'];
  587. const initials = ['И.И.','П.П.','С.С.','А.В.','Д.А.','М.Н.'];
  588. const departments = ['Администрация','Служба охраны','Бухгалтерия','Отдел кадров','ИТ-отдел','Логистика'];
  589. const series = ['А','Б','В','Г'];
  590. function randomPass() {
  591. return {
  592. num: series[Math.floor(Math.random()*series.length)]+'-'+String(Math.floor(Math.random()*900)+100),
  593. name: firstNames[Math.floor(Math.random()*firstNames.length)]+' '+initials[Math.floor(Math.random()*initials.length)],
  594. dept: departments[Math.floor(Math.random()*departments.length)],
  595. valid: Math.random() > 0.25
  596. };
  597. }
  598. function generateScenario(mode) {
  599. const correct = randomPass(); correct.valid = true;
  600. if (mode === 'breach') return { correct, presented: null, shouldAllow: false };
  601. const r = Math.random();
  602. let presented, shouldAllow;
  603. if (r < 0.4) { presented = { ...correct }; shouldAllow = true; }
  604. else if (r < 0.65) { presented = randomPass(); while (presented.num === correct.num) presented = randomPass(); shouldAllow = false; }
  605. else { presented = { ...correct, valid: false }; shouldAllow = false; }
  606. return { correct, presented, shouldAllow };
  607. }
  608. function renderPasses() {
  609. const el = document.getElementById('passesCompare');
  610. const c = state.correctPass, p = state.presentedPass;
  611. const card = (head, pass) => {
  612. if (!pass) return `<div class="pass-card"><div class="head">${head}</div><div class="num">—</div><div class="name">не предъявлен</div><div class="dept">—</div><div class="status empty">НЕТ ДАННЫХ</div></div>`;
  613. return `<div class="pass-card"><div class="head">${head}</div><div class="num">${pass.num}</div><div class="name">${pass.name}</div><div class="dept">${pass.dept}</div><div class="status ${pass.valid?'ok':'bad'}">${pass.valid?'АКТИВЕН':'ОТОЗВАН'}</div></div>`;
  614. };
  615. el.innerHTML = card('В БАЗЕ', c) + card('ПРЕДЪЯВЛЕН', p);
  616. const active = state.phase === 'waiting' && state.userDecision === null;
  617. document.getElementById('btnAllow').disabled = !active;
  618. document.getElementById('btnDeny').disabled = !active;
  619. state.skud.passValid = !!(c && c.valid);
  620. }
  621. function showRefuseMenu() { document.getElementById('refuseMenu').classList.add('on'); }
  622. function hideRefuseMenu() { document.getElementById('refuseMenu').classList.remove('on'); }
  623. function speedMult() {
  624. if (state.weather === 'rain') return 0.75;
  625. if (state.weather === 'snow') return 0.6;
  626. if (state.weather === 'fog') return 0.85;
  627. return 1;
  628. }
  629. /* ===== ПЕРЕКЛЮЧАТЕЛЬ МОДЕЛИ ===== */
  630. function renderModelSwitcher() {
  631. const el = document.getElementById('modelSwitcher');
  632. el.innerHTML = Object.values(BARRIER_MODELS).map(m => `
  633. <button class="model-btn ${m.code===selectedModelCode?'active':''}" data-model="${m.code}">
  634. <span class="mb-code">МОДЕЛЬ ${m.code}</span>
  635. <span class="mb-name">${m.name}</span>
  636. <span class="mb-spec">▸ ${m.openTime}с/${m.closeTime}с · стрела ${m.armLen}</span>
  637. </button>`).join('');
  638. el.querySelectorAll('.model-btn').forEach(b => {
  639. b.onclick = () => switchModel(b.dataset.model);
  640. });
  641. }
  642. function switchModel(code) {
  643. if (!BARRIER_MODELS[code]) return;
  644. selectedModelCode = code;
  645. state.model = BARRIER_MODELS[code];
  646. document.getElementById('modelBadge').textContent = 'МОДЕЛЬ: ' + state.model.code + ' · ' + state.model.name;
  647. document.getElementById('btnHold').style.display = state.model.hasHold ? '' : 'none';
  648. document.getElementById('btnHold').classList.remove('active');
  649. state.holdMode = false;
  650. state.skud.kw1 = state.barrier.level < 0.05;
  651. state.skud.kw2 = state.barrier.level > 0.95;
  652. if (!state.model.hasLoop) state.skud.loop1 = false;
  653. if (!state.model.hasPhoto) state.skud.photo = false;
  654. if (!state.model.hasImpact) state.skud.impact = false;
  655. renderModelSwitcher();
  656. renderModelPassport();
  657. logSkud('▸ ПЕРЕКЛЮЧЕНИЕ МОДЕЛИ: ' + state.model.code + ' · ' + state.model.name, 'warn');
  658. logSkud('Открытие/закрытие: ' + state.model.openTime + 'с / ' + state.model.closeTime + 'с');
  659. logSkud('Автозакрытие: ' + (state.model.autoClose ? state.model.autoClose + ' с' : 'отключено'));
  660. setHint('Модель <b>' + state.model.code + '</b> активна. Нажмите «Следующая машина».');
  661. }
  662. /* ===== ПАСПОРТ МОДЕЛИ ===== */
  663. function isTerminalActive(t) {
  664. const s = state.skud;
  665. const power = t === '+24V' || t === 'GND' || t === 'L' || t === 'N' ||
  666. t === 'PE' || t === 'L1' || t === 'L2' || t === 'L3';
  667. if (power) return s.power ? 'on' : null;
  668. if (t === 'OPEN') return s.relayOpen ? 'on' : null;
  669. if (t === 'CLOSE') return s.relayClose ? 'on' : null;
  670. if (t === 'STOP') return s.relayStop ? 'alarm' : null;
  671. if (t === 'KW1') return s.kw1 ? 'on' : null;
  672. if (t === 'KW2') return s.kw2 ? 'on' : null;
  673. if (t === 'LOOP1') return s.loop1 ? 'on' : null;
  674. if (t === 'LOOP2') return s.loop2 ? 'on' : null;
  675. if (t === 'PHOTO') return s.photo ? 'alarm' : 'on';
  676. if (t === 'IMPACT') return s.impact ? 'alarm' : null;
  677. if (t === 'ALARM') return s.alarm ? 'alarm' : null;
  678. if (t === 'READY') return !s.alarm ? 'on' : null;
  679. return null;
  680. }
  681. function renderModelPassport() {
  682. const m = state.model;
  683. if (!m) return;
  684. const el = document.getElementById('modelPassport');
  685. const rows = [
  686. ['Производитель', m.vendor],
  687. ['Серия', m.series],
  688. ['Тип привода', m.driveType],
  689. ['Питание', m.driveVoltage],
  690. ['Мощность', m.drivePower],
  691. ['Открытие', m.openTime + ' с'],
  692. ['Закрытие', m.closeTime + ' с'],
  693. ['Автозакрытие', m.autoClose ? m.autoClose + ' с' : 'нет'],
  694. ['Длина стрелы', m.armLen + ' см'],
  695. ['Защита', m.ingressProtection],
  696. ['Температура', m.operatingTemp],
  697. ['Режим работы', m.dutyCycle],
  698. ];
  699. const term = (m.terminals || []).map(t => {
  700. const active = isTerminalActive(t);
  701. const cls = active === 'on' ? 'on' : (active === 'alarm' ? 'alarm' : '');
  702. return `<span class="terminal-chip ${cls}">${t}</span>`;
  703. }).join('');
  704. el.innerHTML = `
  705. <div class="passport-title">МОДЕЛЬ ${m.code} · ${m.short}</div>
  706. <div class="passport-sub">${m.name} · ${m.vendor} ${m.series}</div>
  707. <div class="passport-desc">${m.desc}</div>
  708. <div class="passport-grid">
  709. ${rows.map(([k,v]) => `<div class="passport-row"><span class="pk">${k}</span><span class="pv">${v}</span></div>`).join('')}
  710. </div>
  711. <div style="color:#607890;font-size:10px;letter-spacing:1.5px;margin-top:12px;">КЛЕММЫ ПОДКЛЮЧЕНИЯ</div>
  712. <div class="terminal-list">${term}</div>
  713. `;
  714. }
  715. /* ===== СЦЕНАРИИ ===== */
  716. function nextCar() {
  717. if (state.phase !== 'idle') return;
  718. const scen = generateScenario('normal');
  719. state.correctPass = scen.correct;
  720. state.presentedPass = scen.presented;
  721. state.correctDecision = scen.shouldAllow ? 'allow' : 'deny';
  722. state.userDecision = null;
  723. state.senior = null;
  724. state.phase = 'approaching';
  725. hideRefuseMenu();
  726. const colors = ['#3a5a8a','#8a3a3a','#2a2a30','#3a6a4a','#7a6a2a','#5a3a6a'];
  727. state.car = {
  728. x: -250, speed: 160 * speedMult(),
  729. color: colors[Math.floor(Math.random()*colors.length)],
  730. wheelAngle: 0, isPriora: false
  731. };
  732. state.skud.loop1 = false; state.skud.loop2 = false;
  733. setStatus('ПРИБЛИЖАЕТСЯ АВТО', 'wait');
  734. logEvent('Приближается автомобиль' + (state.weather !== 'clear' || state.timeOfDay === 'night' ? ` (${weatherLabel()})` : ''), 'info');
  735. renderPasses();
  736. setHint('Автомобиль подъезжает.');
  737. }
  738. function breachCar() {
  739. if (state.phase !== 'idle') return;
  740. const scen = generateScenario('breach');
  741. state.correctPass = scen.correct;
  742. state.presentedPass = null;
  743. state.correctDecision = 'deny';
  744. state.userDecision = null;
  745. state.senior = null;
  746. state.phase = 'approaching';
  747. hideRefuseMenu();
  748. state.car = { x: -250, speed: 340 * speedMult(), color: '#0a0a0a', wheelAngle: 0, isPriora: true };
  749. setStatus('НАРУШИТЕЛЬ !', 'alarm');
  750. logEvent('Тёмный автомобиль не сбрасывает скорость!', 'alarm');
  751. renderPasses();
  752. setHint('Нарушитель не останавливается.');
  753. }
  754. function decide(decision) {
  755. if (state.phase !== 'waiting' || state.userDecision !== null) return;
  756. state.userDecision = decision;
  757. const correct = (decision === state.correctDecision);
  758. if (correct) state.stats.correct++; else state.stats.wrong++;
  759. logEvent(`Решение «${decision==='allow'?'РАЗРЕШИТЬ':'ЗАПРЕТИТЬ'}» — ${correct?'верно':'ОШИБКА'}`,
  760. correct?'ok':'warn');
  761. if (decision === 'allow') {
  762. state.car.action = 'pass';
  763. openBarrier();
  764. state.phase = 'resolved';
  765. setStatus('ПРОЕЗД РАЗРЕШЁН');
  766. setHint('Шлагбаум открывается, автомобиль проезжает.');
  767. hideRefuseMenu();
  768. } else {
  769. state.car.action = 'wait';
  770. state.phase = 'refuse_wait';
  771. setStatus('МАШИНА ЖДЁТ', 'pause');
  772. setHint('Объясните причину отказа или вызовите старшего смены.');
  773. showRefuseMenu();
  774. }
  775. renderPasses();
  776. }
  777. /* ===== ПУЛЬТ ОПЕРАТОРА ===== */
  778. function openBarrier() {
  779. if (state.skud.alarm && !state.manualMode) { logSkud('Отказ: авария не сброшена', 'warn'); return; }
  780. state.barrier.target = 1;
  781. state.barrier.moving = true;
  782. state.skud.relayOpen = true;
  783. state.skud.relayClose = false;
  784. state.skud.relayStop = false;
  785. logSkud('РЕЛЕ «ОТКРЫТЬ»: вкл');
  786. state.autoCloseTimer = 0;
  787. }
  788. function closeBarrier() {
  789. if (state.skud.alarm && !state.manualMode) { logSkud('Отказ: авария не сброшена', 'warn'); return; }
  790. if (state.skud.photo) {
  791. logSkud('Отказ закрытия: фотоэлемент разорван', 'warn');
  792. setHint('Фотоэлемент разорван — стрела не опускается.');
  793. return;
  794. }
  795. if (state.holdMode) { logSkud('Отказ: включено удержание', 'warn'); return; }
  796. state.barrier.target = 0;
  797. state.barrier.moving = true;
  798. state.skud.relayClose = true;
  799. state.skud.relayOpen = false;
  800. state.skud.relayStop = false;
  801. logSkud('РЕЛЕ «ЗАКРЫТЬ»: вкл');
  802. }
  803. function stopBarrier() {
  804. state.barrier.target = state.barrier.level;
  805. state.barrier.moving = false;
  806. state.skud.relayStop = true;
  807. state.skud.relayOpen = false;
  808. state.skud.relayClose = false;
  809. logSkud('РЕЛЕ «СТОП»: вкл — привод остановлен', 'warn');
  810. setHint('Шлагбаум остановлен оператором.');
  811. }
  812. function resetAlarm() {
  813. state.skud.alarm = false;
  814. state.alarm = false;
  815. state.skud.impact = false;
  816. logSkud('СБРОС АВАРИИ выполнен', 'ok');
  817. }
  818. document.getElementById('btnOpen').onclick = openBarrier;
  819. document.getElementById('btnClose').onclick = closeBarrier;
  820. document.getElementById('btnStop').onclick = stopBarrier;
  821. document.getElementById('btnResetAlarm').onclick = resetAlarm;
  822. document.getElementById('btnHold').onclick = () => {
  823. if (!state.model.hasHold) return;
  824. state.holdMode = !state.holdMode;
  825. document.getElementById('btnHold').classList.toggle('active', state.holdMode);
  826. logSkud('УДЕРЖАНИЕ: ' + (state.holdMode ? 'включено' : 'выключено'), state.holdMode ? 'warn' : 'info');
  827. };
  828. document.getElementById('btnMode').onclick = () => {
  829. state.manualMode = !state.manualMode;
  830. document.getElementById('btnMode').textContent = 'РЕЖИМ: ' + (state.manualMode ? 'РУЧНОЙ' : 'АВТО');
  831. document.getElementById('btnMode').classList.toggle('active', state.manualMode);
  832. logSkud('Режим: ' + (state.manualMode ? 'РУЧНОЙ' : 'АВТО'));
  833. };
  834. document.querySelectorAll('[data-refuse]').forEach(b => {
  835. b.onclick = () => {
  836. const action = b.dataset.refuse;
  837. if (action === 'explain') {
  838. logEvent('Оператор объяснил причину отказа водителю', 'info');
  839. state.stats.out++;
  840. state.car.action = 'turn';
  841. state.phase = 'resolved';
  842. setStatus('ОТКАЗ', 'wait');
  843. setHint('Машина уезжает в обратном направлении.');
  844. hideRefuseMenu();
  845. renderPasses();
  846. }
  847. if (action === 'senior') {
  848. state.stats.senior++;
  849. logEvent('Оператор вызвал старшего смены', 'senior');
  850. state.phase = 'senior_walk';
  851. const carX = state.car ? state.car.x : 0;
  852. state.senior = { x: W + 40, y: L().botLaneY, targetX: carX + 130, targetY: L().topLaneY,
  853. state: 'walk_to_lane', walking: true, alpha: 0, decideTimer: 0 };
  854. hideRefuseMenu();
  855. setStatus('СТАРШИЙ ВЫЗВАН', 'senior');
  856. setHint('Старший смены направляется к машине.');
  857. }
  858. };
  859. });
  860. function resetAll() {
  861. state.car = null; state.correctPass = null; state.presentedPass = null;
  862. state.userDecision = null; state.correctDecision = null;
  863. state.phase = 'idle'; state.barrier.target = 0; state.barrier.level = 0;
  864. state.alarm = false; state.sparks = []; state.police = null;
  865. state.tow = null; state.towDriver = null; state.towCable = false; state.leaving = false;
  866. state.senior = null; state.holdMode = false; state.autoCloseTimer = 0;
  867. state.skud = { power:true, kw1:true, kw2:false, loop1:false, loop2:false,
  868. photo:false, impact:false, relayOpen:false, relayClose:false, relayStop:false,
  869. passValid:false, alarm:false };
  870. document.getElementById('btnHold').classList.remove('active');
  871. hideRefuseMenu();
  872. logEntries.length = 0; renderLog(); renderPasses();
  873. setStatus('ПОСТ СВОБОДЕН'); setHint('Нажмите <b>«Следующая машина»</b>.');
  874. logEvent('Система сброшена', 'ok');
  875. }
  876. document.getElementById('btnNext').onclick = nextCar;
  877. document.getElementById('btnBreach').onclick = breachCar;
  878. document.getElementById('btnReset').onclick = resetAll;
  879. document.getElementById('btnAllow').onclick = () => decide('allow');
  880. document.getElementById('btnDeny').onclick = () => decide('deny');
  881. document.getElementById('btnNote').onclick = () => {
  882. const txt = prompt('Введите отметку для журнала:', '');
  883. if (txt && txt.trim()) logEvent('[ОПЕРАТОР] ' + txt.trim(), 'info');
  884. };
  885. document.getElementById('btnEndShift').onclick = () => {
  886. const s = state.stats;
  887. document.getElementById('statIn').textContent = s.in;
  888. document.getElementById('statOut').textContent = s.out;
  889. document.getElementById('statCorrect').textContent = s.correct;
  890. document.getElementById('statWrong').textContent = s.wrong;
  891. document.getElementById('statAlarms').textContent = s.alarms;
  892. document.getElementById('statSensors').textContent = s.sensors;
  893. document.getElementById('statBreaches').textContent = s.breaches;
  894. document.getElementById('statSenior').textContent = s.senior;
  895. const total = s.correct + s.wrong;
  896. let grade = '—';
  897. if (total > 0) {
  898. const pct = s.correct / total;
  899. if (pct >= 0.95) grade = 'ОТЛИЧНО';
  900. else if (pct >= 0.8) grade = 'ХОРОШО';
  901. else if (pct >= 0.6) grade = 'УДОВЛЕТВОРИТЕЛЬНО';
  902. else grade = 'ТРЕБУЕТСЯ ПОДГОТОВКА';
  903. }
  904. document.getElementById('statGrade').textContent = grade;
  905. document.getElementById('shiftModal').classList.add('on');
  906. logEvent('Смена завершена. Открыт отчёт.', 'info');
  907. };
  908. document.getElementById('btnCloseShift').onclick = () => {
  909. document.getElementById('shiftModal').classList.remove('on');
  910. state.stats = { in:0, out:0, correct:0, wrong:0, alarms:0, breaches:0, senior:0, sensors:0 };
  911. logEntries.length = 0; renderLog();
  912. logEvent('Начата новая смена', 'ok');
  913. };
  914. /* ===== ЖУРНАЛ: кнопки ===== */
  915. document.getElementById('btnLogToggle').onclick = () => {
  916. document.getElementById('logModal').classList.add('on');
  917. const el = document.getElementById('logContentFull');
  918. el.scrollTop = el.scrollHeight;
  919. updateLogBadge();
  920. };
  921. document.getElementById('btnLogClose').onclick = () => {
  922. document.getElementById('logModal').classList.remove('on');
  923. updateLogBadge();
  924. };
  925. document.getElementById('btnLogClear').onclick = () => {
  926. logEntries.length = 0;
  927. renderLog();
  928. logEvent('Журнал очищен оператором', 'info');
  929. };
  930. document.addEventListener('keydown', (e) => {
  931. if (e.key === 'F2') {
  932. e.preventDefault();
  933. document.getElementById('btnLogToggle').click();
  934. }
  935. if (e.key === 'Escape') {
  936. document.getElementById('logModal').classList.remove('on');
  937. updateLogBadge();
  938. }
  939. });
  940. document.getElementById('selTimeOfDay').onchange = (e) => {
  941. state.timeOfDay = e.target.value;
  942. logEvent('Время суток: ' + (state.timeOfDay === 'day' ? 'день' : 'ночь'), 'info');
  943. if (state.timeOfDay === 'night') logEvent('Камеры переведены в ИК-режим', 'info');
  944. };
  945. document.getElementById('selWeather').onchange = (e) => {
  946. state.weather = e.target.value;
  947. const names = { clear:'ясно', rain:'дождь', snow:'снег', fog:'туман' };
  948. logEvent('Погода: ' + names[state.weather], 'info');
  949. if (state.weather === 'fog') logEvent('Туман — сумеречный режим', 'warn');
  950. if (state.weather === 'rain') logEvent('Мокрое покрытие — снижение скорости ТС', 'warn');
  951. if (state.weather === 'snow') logEvent('Снегопад — ограничение скорости ТС', 'warn');
  952. state.weatherParticles = [];
  953. };
  954. /* ===== LED ===== */
  955. function setLed(id, on, cls) {
  956. const el = document.getElementById(id);
  957. if (!el) return;
  958. el.className = 'skud-led' + (on ? ' ' + (cls || 'on') : '');
  959. }
  960. function updateSkudLeds() {
  961. const s = state.skud;
  962. setLed('ledPower', s.power);
  963. setLed('ledArmState', true,
  964. state.barrier.moving ? 'yel blink' :
  965. (state.barrier.level > 0.9 ? 'on' : 'red'));
  966. setLed('ledKW1', s.kw1);
  967. setLed('ledKW2', s.kw2);
  968. setLed('ledLoop1', s.loop1, 'blu');
  969. setLed('ledLoop2', s.loop2, 'blu');
  970. setLed('ledPhoto', s.photo, 'yel');
  971. setLed('ledImpact', s.impact, 'red blink');
  972. setLed('ledRelayOpen', s.relayOpen);
  973. setLed('ledRelayClose', s.relayClose);
  974. setLed('ledRelayStop', s.relayStop, 'yel');
  975. setLed('ledPass', s.passValid);
  976. renderModelPassport();
  977. updateFsmTable();
  978. }
  979. /* ===== ТАБЛО FSM ===== */
  980. function updateFsmTable() {
  981. const s = state.skud;
  982. let fsm = 'IDLE';
  983. if (state.phase === 'approaching') fsm = 'APPROACH';
  984. else if (state.phase === 'waiting') fsm = 'WAIT_OPERATOR';
  985. else if (state.phase === 'resolved') fsm = 'RESOLVED';
  986. else if (state.phase === 'refuse_wait') fsm = 'REFUSE_WAIT';
  987. else if (state.phase === 'senior_walk') fsm = 'SENIOR_CALL';
  988. else if (state.phase === 'crash_scene') fsm = 'EMERGENCY';
  989. else if (state.barrier.moving) fsm = state.barrier.target > 0 ? 'OPENING' : 'CLOSING';
  990. else if (state.barrier.level > 0.95) fsm = 'OPEN';
  991. else if (state.barrier.level < 0.05) fsm = 'CLOSED';
  992. const el = document.getElementById('fsmState');
  993. if (el) el.textContent = fsm;
  994. const tEl = document.getElementById('fsmTimer');
  995. if (tEl) {
  996. const max = state.model ? state.model.autoClose : 0;
  997. tEl.textContent = max > 0
  998. ? state.autoCloseTimer.toFixed(1) + ' / ' + max.toFixed(1) + ' с'
  999. : '— отключено —';
  1000. }
  1001. const lEl = document.getElementById('fsmLevel');
  1002. if (lEl) lEl.textContent = Math.round(state.barrier.level * 100) + ' %';
  1003. const aEl = document.getElementById('fsmAlarms');
  1004. if (aEl) {
  1005. const count = (s.alarm ? 1 : 0) + (s.impact ? 1 : 0) +
  1006. (s.photo && state.barrier.moving && state.barrier.target === 0 ? 1 : 0);
  1007. aEl.textContent = count;
  1008. aEl.style.color = count > 0 ? '#ff6060' : '#80e0ff';
  1009. }
  1010. }
  1011. /* ===== ОСЦИЛЛОГРАММА ===== */
  1012. const scopeCv = document.getElementById('scopeCanvas');
  1013. const scopeCtx = scopeCv.getContext('2d');
  1014. const scopeSignals = [
  1015. { id:'relayOpen', color:'#40d080', label:'OPEN' },
  1016. { id:'relayClose', color:'#ff4040', label:'CLOSE' },
  1017. { id:'relayStop', color:'#ffb040', label:'STOP' },
  1018. { id:'photo', color:'#60a0e0', label:'PHOTO' },
  1019. { id:'loop1', color:'#a0c8ff', label:'LOOP1' }
  1020. ];
  1021. const scopeHistory = scopeSignals.map(() => new Array(120).fill(0));
  1022. let scopeFrame = 0;
  1023. function drawScope() {
  1024. const SW = scopeCv.width, SH = scopeCv.height;
  1025. scopeCtx.fillStyle = '#04060a';
  1026. scopeCtx.fillRect(0, 0, SW, SH);
  1027. scopeCtx.strokeStyle = 'rgba(40,60,80,.35)';
  1028. scopeCtx.lineWidth = 1;
  1029. for (let x = 0; x < SW; x += 30) {
  1030. scopeCtx.beginPath(); scopeCtx.moveTo(x, 0); scopeCtx.lineTo(x, SH); scopeCtx.stroke();
  1031. }
  1032. const rowH = SH / scopeSignals.length;
  1033. scopeSignals.forEach((sig, i) => {
  1034. const y = rowH * i + rowH * 0.7;
  1035. scopeCtx.strokeStyle = 'rgba(40,60,80,.35)';
  1036. scopeCtx.beginPath(); scopeCtx.moveTo(0, y); scopeCtx.lineTo(SW, y); scopeCtx.stroke();
  1037. const hist = scopeHistory[i];
  1038. scopeCtx.strokeStyle = sig.color;
  1039. scopeCtx.lineWidth = 1.6;
  1040. scopeCtx.beginPath();
  1041. for (let x = 0; x < hist.length; x++) {
  1042. const px = (x / hist.length) * SW;
  1043. const py = y - hist[x] * (rowH * 0.55);
  1044. if (x === 0) scopeCtx.moveTo(px, py);
  1045. else scopeCtx.lineTo(px, py);
  1046. }
  1047. scopeCtx.stroke();
  1048. scopeCtx.fillStyle = sig.color;
  1049. scopeCtx.font = 'bold 8px "Courier New"';
  1050. scopeCtx.fillText(sig.label, 4, rowH * i + 9);
  1051. });
  1052. }
  1053. function pushScopeSample() {
  1054. scopeFrame++;
  1055. if (scopeFrame % 4 !== 0) return;
  1056. scopeSignals.forEach((sig, i) => {
  1057. const val = state.skud[sig.id] ? 1 : 0;
  1058. scopeHistory[i].shift();
  1059. scopeHistory[i].push(val);
  1060. });
  1061. }
  1062. /* ===== ФОН ===== */
  1063. function drawSky() {
  1064. const l = L();
  1065. const [c1, c2, c3] = skyColors();
  1066. const grad = ctx.createLinearGradient(0, 0, 0, l.horizonY);
  1067. grad.addColorStop(0, c1); grad.addColorStop(0.6, c2); grad.addColorStop(1, c3);
  1068. ctx.fillStyle = grad; ctx.fillRect(0, 0, W, l.horizonY);
  1069. if (isStarVisible()) {
  1070. for (let i = 0; i < 90; i++) {
  1071. const x = (i * 173.5) % W, y = (i * 97.3) % (l.horizonY - 15);
  1072. const a = (0.15 + 0.4 * Math.abs(Math.sin(state.time * 0.5 + i))) *
  1073. (state.weather === 'clear' ? 1 : 0.35);
  1074. ctx.fillStyle = `rgba(200,220,255,${a})`;
  1075. ctx.fillRect(x, y, 1.5, 1.5);
  1076. }
  1077. const mx = W * 0.78, my = l.horizonY * 0.30;
  1078. const moonG = ctx.createRadialGradient(mx, my, 3, mx, my, 55);
  1079. moonG.addColorStop(0, 'rgba(220,230,255,.55)');
  1080. moonG.addColorStop(0.4, 'rgba(180,200,230,.10)');
  1081. moonG.addColorStop(1, 'rgba(180,200,230,0)');
  1082. ctx.fillStyle = moonG; ctx.fillRect(mx - 60, my - 60, 120, 120);
  1083. ctx.beginPath(); ctx.arc(mx, my, 12, 0, Math.PI * 2);
  1084. ctx.fillStyle = 'rgba(240,240,220,.85)'; ctx.fill();
  1085. ctx.beginPath(); ctx.arc(mx - 4, my - 3, 10, 0, Math.PI * 2);
  1086. ctx.fillStyle = 'rgba(230,235,220,.75)'; ctx.fill();
  1087. }
  1088. if (isSunVisible()) {
  1089. const isDawn = lighting().mode === 'dawn';
  1090. const sy = l.horizonY * (isDawn ? 0.55 : 0.32);
  1091. const sx = W * 0.22;
  1092. const sg = ctx.createRadialGradient(sx, sy, 6, sx, sy, 130);
  1093. sg.addColorStop(0, isDawn ? 'rgba(255,220,180,.9)' : 'rgba(255,250,210,.95)');
  1094. sg.addColorStop(0.3, isDawn ? 'rgba(255,200,140,.25)' : 'rgba(255,240,180,.25)');
  1095. sg.addColorStop(1, 'rgba(255,240,180,0)');
  1096. ctx.fillStyle = sg; ctx.fillRect(sx - 140, sy - 140, 280, 280);
  1097. ctx.beginPath(); ctx.arc(sx, sy, 22, 0, Math.PI * 2);
  1098. ctx.fillStyle = isDawn ? 'rgba(255,220,180,.95)' : 'rgba(255,252,220,.98)'; ctx.fill();
  1099. ctx.save();
  1100. ctx.globalCompositeOperation = 'lighter';
  1101. for (let i = 0; i < 8; i++) {
  1102. const a = -0.4 + i * 0.1 + Math.sin(state.time * 0.3 + i) * 0.02;
  1103. const len = 180 + (i % 3) * 40;
  1104. ctx.beginPath();
  1105. ctx.moveTo(sx, sy);
  1106. ctx.lineTo(sx + Math.cos(a) * len, sy + Math.sin(a) * len);
  1107. ctx.lineTo(sx + Math.cos(a + 0.04) * len, sy + Math.sin(a + 0.04) * len);
  1108. ctx.closePath();
  1109. ctx.fillStyle = isDawn ? 'rgba(255,220,180,.06)' : 'rgba(255,250,220,.05)';
  1110. ctx.fill();
  1111. }
  1112. ctx.restore();
  1113. }
  1114. const m = lighting().mode;
  1115. let bcol = '#0a1220';
  1116. if (m === 'day') bcol = '#3a4a5a';
  1117. else if (m === 'overcast') bcol = '#454e58';
  1118. else if (m === 'dawn') bcol = '#3a4050';
  1119. else if (m === 'dusk') bcol = '#1c1a28';
  1120. ctx.fillStyle = bcol; ctx.globalAlpha = 0.55;
  1121. for (let i = 0; i < 30; i++) {
  1122. const bx = i * (W / 30), bw = 20 + (i % 5) * 14, bh = 20 + (i % 7) * 12;
  1123. ctx.fillRect(bx, l.horizonY - bh, bw, bh);
  1124. }
  1125. ctx.globalAlpha = 1;
  1126. for (let i = 0; i < 24; i++) {
  1127. const bx = i * (W / 24), bw = 18 + (i % 4) * 12, bh = 30 + (i % 5) * 12;
  1128. ctx.fillRect(bx, l.horizonY - bh, bw, bh);
  1129. if (isStarVisible() && (i * 13) % 5 === 0) {
  1130. ctx.fillStyle = 'rgba(255,200,120,.35)';
  1131. ctx.fillRect(bx + 4, l.horizonY - bh + 8, 3, 3);
  1132. ctx.fillRect(bx + 12, l.horizonY - bh + 20, 3, 3);
  1133. ctx.fillStyle = bcol;
  1134. }
  1135. }
  1136. }
  1137. function drawGround() {
  1138. const l = L();
  1139. const m = lighting().mode;
  1140. let g;
  1141. if (m === 'day' || m === 'overcast') {
  1142. g = ctx.createLinearGradient(0, l.horizonY, 0, H);
  1143. g.addColorStop(0, '#2a3a28'); g.addColorStop(1, '#101810');
  1144. } else if (m === 'dawn' || m === 'dusk') {
  1145. g = ctx.createLinearGradient(0, l.horizonY, 0, H);
  1146. g.addColorStop(0, '#1a2018'); g.addColorStop(1, '#0a0e08');
  1147. } else {
  1148. g = ctx.createLinearGradient(0, l.horizonY, 0, H);
  1149. g.addColorStop(0, '#0a1410'); g.addColorStop(1, '#03060a');
  1150. }
  1151. ctx.fillStyle = g; ctx.fillRect(0, l.horizonY, W, H - l.horizonY);
  1152. const haze = ctx.createLinearGradient(0, l.horizonY - 4, 0, l.horizonY + 30);
  1153. haze.addColorStop(0, 'rgba(80,110,140,.35)');
  1154. haze.addColorStop(1, 'rgba(80,110,140,0)');
  1155. ctx.fillStyle = haze;
  1156. ctx.fillRect(0, l.horizonY - 4, W, 34);
  1157. }
  1158. function drawRoads() {
  1159. const l = L();
  1160. const m = lighting().mode;
  1161. let c1, c2, edge, dash;
  1162. if (m === 'day') {
  1163. c1 = ['#3a3e44', '#25282c']; c2 = ['#3a3e44', '#25282c'];
  1164. edge = 'rgba(180,180,180,.7)'; dash = 'rgba(220,200,140,.6)';
  1165. } else if (m === 'overcast' || m === 'dawn') {
  1166. c1 = ['#2a2e34', '#1a1d22']; c2 = ['#2a2e34', '#1a1d22'];
  1167. edge = 'rgba(140,150,160,.6)'; dash = 'rgba(200,180,140,.5)';
  1168. } else if (m === 'dusk') {
  1169. c1 = ['#1a1d24', '#0d1014']; c2 = ['#1a1d24', '#0d1014'];
  1170. edge = 'rgba(120,130,140,.5)'; dash = 'rgba(200,180,140,.4)';
  1171. } else {
  1172. c1 = ['#1a1e24', '#0d1014']; c2 = ['#1a1e24', '#0d1014'];
  1173. edge = 'rgba(120,130,140,.5)'; dash = 'rgba(220,200,140,.4)';
  1174. }
  1175. const g1 = ctx.createLinearGradient(0, l.topLaneTop, 0, l.topLaneBot);
  1176. g1.addColorStop(0, c1[0]); g1.addColorStop(1, c1[1]);
  1177. ctx.fillStyle = g1; ctx.fillRect(0, l.topLaneTop, W, l.topLaneBot - l.topLaneTop);
  1178. const g2 = ctx.createLinearGradient(0, l.botLaneTop, 0, l.botLaneBot);
  1179. g2.addColorStop(0, c2[0]); g2.addColorStop(1, c2[1]);
  1180. ctx.fillStyle = g2; ctx.fillRect(0, l.botLaneTop, W, l.botLaneBot - l.botLaneTop);
  1181. ctx.strokeStyle = edge; ctx.lineWidth = 2;
  1182. ctx.beginPath(); ctx.moveTo(0, (l.topLaneBot + l.botLaneTop) / 2);
  1183. ctx.lineTo(W, (l.topLaneBot + l.botLaneTop) / 2); ctx.stroke();
  1184. ctx.strokeStyle = dash; ctx.lineWidth = 3;
  1185. ctx.setLineDash([30, 25]);
  1186. ctx.beginPath(); ctx.moveTo(0, (l.topLaneBot + l.botLaneTop) / 2 + 6);
  1187. ctx.lineTo(W, (l.topLaneBot + l.botLaneTop) / 2 + 6); ctx.stroke();
  1188. ctx.setLineDash([]);
  1189. if (isWet()) {
  1190. ctx.fillStyle = 'rgba(120,150,200,.06)';
  1191. ctx.fillRect(0, l.topLaneTop, W, l.topLaneBot - l.topLaneTop);
  1192. ctx.fillRect(0, l.botLaneTop, W, l.botLaneBot - l.botLaneTop);
  1193. if (state.car) {
  1194. const cx = state.car.x;
  1195. const refl = ctx.createLinearGradient(cx, 0, cx, l.topLaneBot);
  1196. refl.addColorStop(0, 'rgba(255,240,180,.22)');
  1197. refl.addColorStop(1, 'rgba(255,240,180,0)');
  1198. ctx.fillStyle = refl;
  1199. ctx.fillRect(cx - 90, l.topLaneTop, 180, l.topLaneBot - l.topLaneTop);
  1200. }
  1201. }
  1202. }
  1203. function drawBooth() {
  1204. const l = L();
  1205. const bx = l.boothX, by = l.boothBaseY, bw = l.boothW, bh = l.boothH;
  1206. const m = lighting().mode;
  1207. const bright = (m === 'day' || m === 'overcast' || m === 'dawn');
  1208. ctx.fillStyle = 'rgba(0,0,0,.55)';
  1209. ctx.beginPath(); ctx.ellipse(bx + bw/2, by + 6, bw/2 + 14, 9, 0, 0, Math.PI * 2); ctx.fill();
  1210. ctx.fillStyle = '#0a0f14'; ctx.fillRect(bx - 8, by, bw + 16, 12);
  1211. const wg = ctx.createLinearGradient(bx, 0, bx + bw, 0);
  1212. if (bright) { wg.addColorStop(0, '#6a7482'); wg.addColorStop(0.5, '#8a94a4'); wg.addColorStop(1, '#4a5462'); }
  1213. else { wg.addColorStop(0, '#2a3442'); wg.addColorStop(0.5, '#4a5a6a'); wg.addColorStop(1, '#182028'); }
  1214. ctx.fillStyle = wg; ctx.fillRect(bx, by - bh, bw, bh);
  1215. ctx.strokeStyle = '#4a5a70'; ctx.lineWidth = 2; ctx.strokeRect(bx, by - bh, bw, bh);
  1216. ctx.fillStyle = '#0a0f14'; ctx.fillRect(bx - 12, by - bh - 14, bw + 24, 14);
  1217. ctx.strokeStyle = '#3a4452'; ctx.strokeRect(bx - 12, by - bh - 14, bw + 24, 14);
  1218. ctx.fillStyle = 'rgba(255,255,255,.08)';
  1219. ctx.fillRect(bx - 12, by - bh - 14, bw + 24, 4);
  1220. const glow = bright ? 0.4 : 0.8 + 0.15 * Math.sin(state.time * 1.5);
  1221. ctx.fillStyle = `rgba(255,220,140,${glow})`;
  1222. ctx.fillRect(bx + 15, by - bh + 25, 60, 60);
  1223. const wgl = ctx.createLinearGradient(bx + 15, by - bh + 25, bx + 75, by - bh + 85);
  1224. wgl.addColorStop(0, 'rgba(255,255,255,.28)');
  1225. wgl.addColorStop(0.5, 'rgba(255,255,255,0)');
  1226. wgl.addColorStop(1, 'rgba(255,255,255,.10)');
  1227. ctx.fillStyle = wgl; ctx.fillRect(bx + 15, by - bh + 25, 60, 60);
  1228. ctx.strokeStyle = '#4a5a70'; ctx.strokeRect(bx + 15, by - bh + 25, 60, 60);
  1229. ctx.beginPath(); ctx.moveTo(bx + 45, by - bh + 25); ctx.lineTo(bx + 45, by - bh + 85); ctx.stroke();
  1230. ctx.beginPath(); ctx.moveTo(bx + 15, by - bh + 55); ctx.lineTo(bx + 75, by - bh + 55); ctx.stroke();
  1231. const oGlow = ctx.createRadialGradient(bx + 45, by - bh + 55, 8, bx + 45, by - bh + 55, 60);
  1232. oGlow.addColorStop(0, `rgba(255,220,140,${0.18 * glow + 0.08})`);
  1233. oGlow.addColorStop(1, 'rgba(255,220,140,0)');
  1234. ctx.fillStyle = oGlow; ctx.fillRect(bx + 15 - 60, by - bh + 25 - 60, 180, 180);
  1235. ctx.fillStyle = '#1a2028'; ctx.fillRect(bx + bw - 45, by - bh + 25, 35, bh - 25);
  1236. ctx.strokeStyle = '#4a5a70'; ctx.strokeRect(bx + bw - 45, by - bh + 25, 35, bh - 25);
  1237. ctx.fillStyle = '#e8c070';
  1238. ctx.beginPath(); ctx.arc(bx + bw - 40, by - bh/2 - 5, 2.6, 0, Math.PI * 2); ctx.fill();
  1239. ctx.fillStyle = 'rgba(255,255,255,.5)';
  1240. ctx.beginPath(); ctx.arc(bx + bw - 41, by - bh/2 - 6, 1.1, 0, Math.PI * 2); ctx.fill();
  1241. ctx.fillStyle = '#80a0c0'; ctx.font = 'bold 11px "Courier New"';
  1242. ctx.textAlign = 'center';
  1243. ctx.fillText('ОХРАНА', bx + bw/2, by - bh - 22);
  1244. ctx.textAlign = 'left';
  1245. }
  1246. function drawGuard(x, y) {
  1247. ctx.fillStyle = 'rgba(0,0,0,.55)';
  1248. ctx.beginPath(); ctx.ellipse(x, y + 2, 14, 4, 0, 0, Math.PI * 2); ctx.fill();
  1249. ctx.fillStyle = '#1a2230';
  1250. ctx.fillRect(x - 8, y - 35, 7, 35); ctx.fillRect(x + 1, y - 35, 7, 35);
  1251. ctx.fillStyle = '#0a0a0a';
  1252. ctx.fillRect(x - 10, y - 3, 10, 5); ctx.fillRect(x, y - 3, 10, 5);
  1253. const bg = ctx.createLinearGradient(x - 11, 0, x + 11, 0);
  1254. bg.addColorStop(0, '#1a3a5e'); bg.addColorStop(0.4, '#3a6a9a'); bg.addColorStop(1, '#1a3050');
  1255. ctx.fillStyle = bg; ctx.fillRect(x - 11, y - 68, 22, 36);
  1256. ctx.strokeStyle = '#0a1a30'; ctx.lineWidth = 1; ctx.strokeRect(x - 11, y - 68, 22, 36);
  1257. ctx.fillStyle = '#e8e8f0';
  1258. ctx.fillRect(x - 11, y - 68, 5, 3); ctx.fillRect(x + 6, y - 68, 5, 3);
  1259. ctx.fillStyle = '#0a0a0a'; ctx.fillRect(x - 11, y - 38, 22, 4);
  1260. ctx.fillStyle = '#1a3a5e';
  1261. ctx.fillRect(x - 16, y - 66, 6, 30); ctx.fillRect(x + 10, y - 66, 6, 30);
  1262. ctx.fillStyle = '#d0a880';
  1263. ctx.fillRect(x - 16, y - 38, 6, 7); ctx.fillRect(x + 10, y - 38, 6, 7);
  1264. ctx.fillStyle = '#d0a880'; ctx.fillRect(x - 3, y - 72, 6, 4);
  1265. ctx.beginPath(); ctx.arc(x, y - 81, 9, 0, Math.PI * 2);
  1266. const fg = ctx.createRadialGradient(x - 3, y - 83, 2, x, y - 81, 9);
  1267. fg.addColorStop(0, '#e8b890'); fg.addColorStop(1, '#b08868');
  1268. ctx.fillStyle = fg; ctx.fill();
  1269. ctx.fillStyle = '#1a1a1a';
  1270. ctx.beginPath(); ctx.arc(x - 3, y - 82, 1, 0, Math.PI * 2); ctx.fill();
  1271. ctx.beginPath(); ctx.arc(x + 3, y - 82, 1, 0, Math.PI * 2); ctx.fill();
  1272. ctx.fillStyle = '#0f2340';
  1273. ctx.beginPath(); ctx.arc(x, y - 88, 10, Math.PI, Math.PI * 2); ctx.fill();
  1274. ctx.fillRect(x - 11, y - 88, 22, 3);
  1275. ctx.fillStyle = '#050a15'; ctx.fillRect(x - 12, y - 85, 24, 3);
  1276. ctx.fillStyle = '#c0a040'; ctx.beginPath(); ctx.arc(x, y - 90, 2, 0, Math.PI * 2); ctx.fill();
  1277. ctx.fillStyle = 'rgba(255,220,140,.6)'; ctx.beginPath(); ctx.arc(x - 0.5, y - 90.5, 0.8, 0, Math.PI * 2); ctx.fill();
  1278. }
  1279. function drawBarrier() {
  1280. const l = L();
  1281. const pillarX = l.pillarX, pillarW = l.pillarW, pillarH = l.pillarH;
  1282. const groundBase = l.topLaneY;
  1283. const pillarTopY = groundBase - pillarH;
  1284. const pivotX = pillarX, pivotY = groundBase - 80;
  1285. const armLen = (state.model ? state.model.armLen : 240);
  1286. const armH = l.armH;
  1287. const level = state.barrier.level;
  1288. const m = lighting().mode;
  1289. const bright = (m === 'day' || m === 'overcast' || m === 'dawn');
  1290. ctx.fillStyle = 'rgba(0,0,0,.55)';
  1291. ctx.beginPath(); ctx.ellipse(pillarX + pillarW/2, groundBase + 4, 26, 8, 0, 0, Math.PI * 2); ctx.fill();
  1292. ctx.fillStyle = '#1a1e24'; ctx.fillRect(pillarX - 10, groundBase - 10, pillarW + 20, 16);
  1293. ctx.strokeStyle = '#3a4858'; ctx.lineWidth = 1.5; ctx.strokeRect(pillarX - 10, groundBase - 10, pillarW + 20, 16);
  1294. ctx.fillStyle = 'rgba(255,255,255,.06)';
  1295. ctx.fillRect(pillarX - 10, groundBase - 10, pillarW + 20, 3);
  1296. const g = ctx.createLinearGradient(pillarX, 0, pillarX + pillarW, 0);
  1297. if (bright) { g.addColorStop(0, '#3a4858'); g.addColorStop(0.35, '#7a8898'); g.addColorStop(0.6, '#5a6878'); g.addColorStop(1, '#2a3442'); }
  1298. else { g.addColorStop(0, '#1a2028'); g.addColorStop(0.35, '#4a5a6a'); g.addColorStop(0.6, '#2a3442'); g.addColorStop(1, '#0a1018'); }
  1299. ctx.fillStyle = g; ctx.fillRect(pillarX, pillarTopY, pillarW, pillarH);
  1300. ctx.strokeStyle = '#000'; ctx.lineWidth = 1.5; ctx.strokeRect(pillarX, pillarTopY, pillarW, pillarH);
  1301. const shine = ctx.createLinearGradient(pillarX + 4, 0, pillarX + 12, 0);
  1302. shine.addColorStop(0, 'rgba(255,255,255,.18)');
  1303. shine.addColorStop(1, 'rgba(255,255,255,0)');
  1304. ctx.fillStyle = shine; ctx.fillRect(pillarX + 4, pillarTopY + 4, 8, pillarH - 8);
  1305. for (let i = 0; i < 4; i++) {
  1306. ctx.fillStyle = i % 2 === 0 ? '#a08030' : '#1a1a1a';
  1307. ctx.fillRect(pillarX, pillarTopY + pillarH - 90 + i * 20, pillarW, 20);
  1308. }
  1309. ctx.fillStyle = 'rgba(255,220,140,.12)';
  1310. ctx.fillRect(pillarX, pillarTopY + pillarH - 90, pillarW, 80);
  1311. ctx.fillStyle = '#505860';
  1312. ctx.beginPath();
  1313. ctx.moveTo(pillarX - 5, pillarTopY); ctx.lineTo(pillarX + pillarW + 5, pillarTopY);
  1314. ctx.lineTo(pillarX + pillarW, pillarTopY - 12); ctx.lineTo(pillarX, pillarTopY - 12);
  1315. ctx.closePath(); ctx.fill();
  1316. ctx.strokeStyle = '#000'; ctx.stroke();
  1317. ctx.fillStyle = 'rgba(255,255,255,.12)';
  1318. ctx.fillRect(pillarX, pillarTopY - 12, pillarW, 3);
  1319. const blink = Math.sin(state.time * 4) > 0;
  1320. const lampColor = level > 0.9 ? '#40d080' : (level > 0.1 ? '#ffb040' : '#ff2020');
  1321. const lampX = pillarX + pillarW/2, lampY = pillarTopY - 26;
  1322. if (blink) {
  1323. const lampGlow = ctx.createRadialGradient(lampX, lampY, 3, lampX, lampY, 40);
  1324. lampGlow.addColorStop(0, lampColor + 'cc');
  1325. lampGlow.addColorStop(0.4, lampColor + '44');
  1326. lampGlow.addColorStop(1, lampColor + '00');
  1327. ctx.fillStyle = lampGlow;
  1328. ctx.fillRect(lampX - 44, lampY - 44, 88, 88);
  1329. }
  1330. ctx.beginPath(); ctx.arc(lampX, lampY, 9, 0, Math.PI * 2);
  1331. const lampGrad = ctx.createRadialGradient(lampX - 3, lampY - 3, 2, lampX, lampY, 9);
  1332. if (blink) {
  1333. lampGrad.addColorStop(0, '#ffffff');
  1334. lampGrad.addColorStop(0.4, lampColor);
  1335. lampGrad.addColorStop(1, lampColor);
  1336. } else {
  1337. lampGrad.addColorStop(0, '#3a3a3a');
  1338. lampGrad.addColorStop(1, '#1a1a1a');
  1339. }
  1340. ctx.fillStyle = lampGrad; ctx.fill();
  1341. ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.stroke();
  1342. ctx.beginPath(); ctx.arc(lampX - 3, lampY - 3, 2.5, 0, Math.PI * 2);
  1343. ctx.fillStyle = 'rgba(255,255,255,.6)'; ctx.fill();
  1344. ctx.beginPath(); ctx.arc(pivotX, pivotY, 15, 0, Math.PI * 2);
  1345. ctx.fillStyle = '#2a3442'; ctx.fill();
  1346. ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.stroke();
  1347. ctx.beginPath(); ctx.arc(pivotX, pivotY, 6, 0, Math.PI * 2);
  1348. const hg = ctx.createRadialGradient(pivotX - 2, pivotY - 2, 1, pivotX, pivotY, 6);
  1349. hg.addColorStop(0, '#c8d4e0'); hg.addColorStop(1, '#506070');
  1350. ctx.fillStyle = hg; ctx.fill();
  1351. ctx.beginPath(); ctx.arc(pivotX - 3, pivotY - 3, 2, 0, Math.PI * 2);
  1352. ctx.fillStyle = 'rgba(255,255,255,.5)'; ctx.fill();
  1353. const angle = Math.PI + level * Math.PI / 2;
  1354. ctx.save();
  1355. ctx.translate(pivotX, pivotY); ctx.rotate(angle);
  1356. const seg = 14;
  1357. for (let i = 0; i < seg; i++) {
  1358. ctx.fillStyle = i % 2 === 0 ? '#e04040' : '#e8e8e8';
  1359. ctx.fillRect(i * (armLen / seg), -armH/2, armLen / seg, armH);
  1360. }
  1361. ctx.fillStyle = 'rgba(0,0,0,.35)';
  1362. ctx.fillRect(0, armH/2 - 2, armLen, 2);
  1363. ctx.fillStyle = 'rgba(255,255,255,.4)';
  1364. ctx.fillRect(0, -armH/2, armLen, 2);
  1365. ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.strokeRect(0, -armH/2, armLen, armH);
  1366. ctx.beginPath(); ctx.arc(armLen, 0, 12, 0, Math.PI * 2);
  1367. const ng = ctx.createRadialGradient(armLen - 3, -3, 2, armLen, 0, 12);
  1368. ng.addColorStop(0, '#ffffff'); ng.addColorStop(1, '#a8b0b8');
  1369. ctx.fillStyle = ng; ctx.fill();
  1370. ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.stroke();
  1371. ctx.beginPath(); ctx.arc(armLen, 0, 5, 0, Math.PI * 2);
  1372. ctx.fillStyle = '#8090a0'; ctx.fill();
  1373. ctx.restore();
  1374. if (level < 0.9) {
  1375. const endX = pivotX + Math.cos(angle) * armLen;
  1376. const endY = pivotY + Math.sin(angle) * armLen;
  1377. const blinkE = Math.sin(state.time * 6) > 0;
  1378. if (blinkE) {
  1379. const eGlow = ctx.createRadialGradient(endX, endY, 2, endX, endY, 26);
  1380. eGlow.addColorStop(0, 'rgba(255,64,64,.55)');
  1381. eGlow.addColorStop(1, 'rgba(255,64,64,0)');
  1382. ctx.fillStyle = eGlow;
  1383. ctx.fillRect(endX - 28, endY - 28, 56, 56);
  1384. }
  1385. ctx.beginPath(); ctx.arc(endX, endY, 6, 0, Math.PI * 2);
  1386. ctx.fillStyle = blinkE ? '#ff4040' : '#601010'; ctx.fill();
  1387. ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.stroke();
  1388. }
  1389. if (state.model && state.model.hasLoop) {
  1390. const loopX = l.pivotX - 280;
  1391. const loopY = l.topLaneY + 10;
  1392. const loopW = 200, loopH = 18;
  1393. ctx.strokeStyle = state.skud.loop1 ? 'rgba(96,180,255,.85)' : 'rgba(96,160,224,.35)';
  1394. ctx.lineWidth = 2;
  1395. ctx.strokeRect(loopX, loopY, loopW, loopH);
  1396. if (state.skud.loop1) {
  1397. ctx.fillStyle = 'rgba(96,180,255,.18)';
  1398. ctx.fillRect(loopX, loopY, loopW, loopH);
  1399. const lg = ctx.createRadialGradient(loopX + loopW/2, loopY + loopH/2, 5, loopX + loopW/2, loopY + loopH/2, loopW/2);
  1400. lg.addColorStop(0, 'rgba(120,200,255,.18)');
  1401. lg.addColorStop(1, 'rgba(120,200,255,0)');
  1402. ctx.fillStyle = lg;
  1403. ctx.fillRect(loopX - 30, loopY - 30, loopW + 60, loopH + 60);
  1404. }
  1405. }
  1406. if (state.model && state.model.hasPhoto) {
  1407. const px1 = l.pivotX - state.model.armLen - 5;
  1408. const px2 = l.pivotX + 55;
  1409. const py = l.topLaneY - 4;
  1410. ctx.fillStyle = '#304050';
  1411. ctx.fillRect(px1 - 3, py - 44, 6, 44);
  1412. ctx.fillRect(px2 - 3, py - 44, 6, 44);
  1413. ctx.strokeStyle = '#506070'; ctx.lineWidth = 1;
  1414. ctx.strokeRect(px1 - 3, py - 44, 6, 44);
  1415. ctx.strokeRect(px2 - 3, py - 44, 6, 44);
  1416. const on = !state.skud.photo;
  1417. const dCol = on ? '#40d080' : '#ff4040';
  1418. if (on) {
  1419. const dg = ctx.createRadialGradient(px1, py - 36, 2, px1, py - 36, 16);
  1420. dg.addColorStop(0, dCol + 'aa'); dg.addColorStop(1, dCol + '00');
  1421. ctx.fillStyle = dg;
  1422. ctx.fillRect(px1 - 18, py - 54, 36, 36);
  1423. ctx.fillRect(px2 - 18, py - 54, 36, 36);
  1424. }
  1425. ctx.fillStyle = dCol;
  1426. ctx.beginPath(); ctx.arc(px1, py - 36, 3.5, 0, Math.PI * 2); ctx.fill();
  1427. ctx.beginPath(); ctx.arc(px2, py - 36, 3.5, 0, Math.PI * 2); ctx.fill();
  1428. ctx.strokeStyle = '#000'; ctx.lineWidth = 1;
  1429. ctx.beginPath(); ctx.arc(px1, py - 36, 3.5, 0, Math.PI * 2); ctx.stroke();
  1430. ctx.beginPath(); ctx.arc(px2, py - 36, 3.5, 0, Math.PI * 2); ctx.stroke();
  1431. if (on) {
  1432. ctx.strokeStyle = 'rgba(64,208,128,.6)';
  1433. ctx.setLineDash([4,4]); ctx.lineWidth = 1.2;
  1434. ctx.beginPath(); ctx.moveTo(px1, py - 36); ctx.lineTo(px2, py - 36); ctx.stroke();
  1435. ctx.setLineDash([]);
  1436. } else {
  1437. ctx.strokeStyle = 'rgba(255,64,64,.5)';
  1438. ctx.setLineDash([2,6]);
  1439. ctx.beginPath(); ctx.moveTo(px1, py - 36); ctx.lineTo(px2, py - 36); ctx.stroke();
  1440. ctx.setLineDash([]);
  1441. }
  1442. }
  1443. }
  1444. /* ===== МАШИНЫ ===== */
  1445. function drawWheels(x, y, dxRear, dxFront, radius, rim, angle) {
  1446. [x - dxRear, x + dxFront].forEach(wx => {
  1447. ctx.beginPath(); ctx.arc(wx, y, radius, 0, Math.PI * 2);
  1448. const tg = ctx.createRadialGradient(wx - radius*0.3, y - radius*0.3, 1, wx, y, radius);
  1449. tg.addColorStop(0, '#2a2a2a'); tg.addColorStop(1, '#000');
  1450. ctx.fillStyle = tg; ctx.fill();
  1451. ctx.beginPath(); ctx.arc(wx, y, radius * 0.62, 0, Math.PI * 2);
  1452. ctx.fillStyle = '#1a2028'; ctx.fill();
  1453. ctx.strokeStyle = rim; ctx.lineWidth = 1.5; ctx.stroke();
  1454. ctx.save(); ctx.translate(wx, y); ctx.rotate(angle);
  1455. ctx.strokeStyle = rim; ctx.lineWidth = 1.4; ctx.lineCap = 'round';
  1456. for (let i = 0; i < 5; i++) {
  1457. const a = (i / 5) * Math.PI * 2;
  1458. ctx.beginPath(); ctx.moveTo(0, 0);
  1459. ctx.lineTo(Math.cos(a) * radius * 0.5, Math.sin(a) * radius * 0.5); ctx.stroke();
  1460. }
  1461. ctx.beginPath(); ctx.arc(0, 0, radius * 0.16, 0, Math.PI * 2);
  1462. ctx.fillStyle = rim; ctx.fill();
  1463. ctx.strokeStyle = '#303840'; ctx.lineWidth = 1; ctx.stroke();
  1464. ctx.restore();
  1465. ctx.beginPath(); ctx.arc(wx - radius*0.35, y - radius*0.35, radius*0.18, 0, Math.PI * 2);
  1466. ctx.fillStyle = 'rgba(255,255,255,.10)'; ctx.fill();
  1467. });
  1468. }
  1469. function drawSedan(x, y, color, wheelAngle) {
  1470. ctx.fillStyle = 'rgba(0,0,0,.55)';
  1471. ctx.beginPath(); ctx.ellipse(x, y + 4, 92, 7, 0, 0, Math.PI * 2); ctx.fill();
  1472. const bg = ctx.createLinearGradient(0, y - 55, 0, y);
  1473. bg.addColorStop(0, color); bg.addColorStop(0.55, color); bg.addColorStop(1, '#050505');
  1474. ctx.fillStyle = bg; ctx.strokeStyle = '#000'; ctx.lineWidth = 2;
  1475. ctx.beginPath();
  1476. ctx.moveTo(x + 85, y - 8); ctx.lineTo(x + 78, y - 26); ctx.lineTo(x + 40, y - 30);
  1477. ctx.lineTo(x + 15, y - 52); ctx.lineTo(x - 25, y - 54); ctx.lineTo(x - 55, y - 42);
  1478. ctx.lineTo(x - 78, y - 24); ctx.lineTo(x - 85, y - 8); ctx.lineTo(x - 85, y);
  1479. ctx.lineTo(x + 85, y); ctx.closePath(); ctx.fill(); ctx.stroke();
  1480. ctx.fillStyle = 'rgba(255,255,255,.18)';
  1481. ctx.beginPath();
  1482. ctx.moveTo(x - 70, y - 22); ctx.lineTo(x + 70, y - 22);
  1483. ctx.lineTo(x + 65, y - 26); ctx.lineTo(x - 65, y - 26);
  1484. ctx.closePath(); ctx.fill();
  1485. const gk = ctx.createLinearGradient(0, y - 54, 0, y - 30);
  1486. gk.addColorStop(0, 'rgba(90,140,180,.85)');
  1487. gk.addColorStop(1, 'rgba(30,60,90,.75)');
  1488. ctx.fillStyle = gk;
  1489. ctx.beginPath();
  1490. ctx.moveTo(x + 12, y - 50); ctx.lineTo(x - 22, y - 52); ctx.lineTo(x - 48, y - 40);
  1491. ctx.lineTo(x - 48, y - 30); ctx.lineTo(x + 12, y - 30); ctx.closePath(); ctx.fill();
  1492. ctx.fillStyle = 'rgba(255,255,255,.20)';
  1493. ctx.beginPath();
  1494. ctx.moveTo(x + 8, y - 48); ctx.lineTo(x - 18, y - 50);
  1495. ctx.lineTo(x - 30, y - 44); ctx.lineTo(x - 20, y - 44);
  1496. ctx.closePath(); ctx.fill();
  1497. ctx.strokeStyle = 'rgba(0,0,0,.4)'; ctx.lineWidth = 1;
  1498. ctx.beginPath(); ctx.moveTo(x - 8, y - 30); ctx.lineTo(x - 8, y - 8); ctx.stroke();
  1499. ctx.fillStyle = '#fff8c0'; ctx.shadowColor = '#fff8c0'; ctx.shadowBlur = 18;
  1500. ctx.beginPath(); ctx.arc(x + 80, y - 14, 4.5, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0;
  1501. ctx.fillStyle = 'rgba(255,255,255,.9)';
  1502. ctx.beginPath(); ctx.arc(x + 79, y - 15, 1.8, 0, Math.PI * 2); ctx.fill();
  1503. ctx.fillStyle = '#c02020'; ctx.shadowColor = '#c02020'; ctx.shadowBlur = 10;
  1504. ctx.fillRect(x - 85, y - 22, 4, 10); ctx.shadowBlur = 0;
  1505. ctx.fillStyle = 'rgba(255,140,140,.7)';
  1506. ctx.fillRect(x - 84, y - 21, 2, 3);
  1507. drawWheels(x, y, 55, 55, 14, '#a0a8b0', wheelAngle);
  1508. }
  1509. function drawDarkCar(x, y, wheelAngle) {
  1510. ctx.fillStyle = 'rgba(0,0,0,.65)';
  1511. ctx.beginPath(); ctx.ellipse(x, y + 4, 108, 8, 0, 0, Math.PI * 2); ctx.fill();
  1512. const bg = ctx.createLinearGradient(0, y - 58, 0, y);
  1513. bg.addColorStop(0, '#3a3a3a');
  1514. bg.addColorStop(0.25, '#1a1a1a');
  1515. bg.addColorStop(0.7, '#050505');
  1516. bg.addColorStop(1, '#000');
  1517. ctx.fillStyle = bg; ctx.strokeStyle = '#000'; ctx.lineWidth = 2;
  1518. ctx.beginPath();
  1519. ctx.moveTo(x - 105, y - 10); ctx.lineTo(x - 100, y - 28);
  1520. ctx.lineTo(x - 80, y - 34); ctx.lineTo(x - 55, y - 42);
  1521. ctx.lineTo(x - 35, y - 54); ctx.lineTo(x + 25, y - 54);
  1522. ctx.lineTo(x + 45, y - 42); ctx.lineTo(x + 55, y - 34);
  1523. ctx.lineTo(x + 92, y - 30); ctx.lineTo(x + 105, y - 12);
  1524. ctx.lineTo(x + 105, y); ctx.lineTo(x - 105, y);
  1525. ctx.closePath(); ctx.fill(); ctx.stroke();
  1526. ctx.strokeStyle = 'rgba(220,230,240,.7)'; ctx.lineWidth = 1.5;
  1527. ctx.beginPath(); ctx.moveTo(x - 100, y - 22); ctx.lineTo(x + 100, y - 22); ctx.stroke();
  1528. ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 1;
  1529. ctx.beginPath(); ctx.moveTo(x - 100, y - 23); ctx.lineTo(x + 100, y - 23); ctx.stroke();
  1530. const gk = ctx.createLinearGradient(0, y - 54, 0, y - 28);
  1531. gk.addColorStop(0, 'rgba(20,40,60,.95)');
  1532. gk.addColorStop(0.5, 'rgba(5,10,20,.95)');
  1533. gk.addColorStop(1, 'rgba(2,4,8,.95)');
  1534. ctx.fillStyle = gk;
  1535. ctx.beginPath();
  1536. ctx.moveTo(x + 45, y - 40); ctx.lineTo(x + 18, y - 40);
  1537. ctx.lineTo(x + 18, y - 30); ctx.lineTo(x + 52, y - 30);
  1538. ctx.closePath(); ctx.fill();
  1539. ctx.beginPath();
  1540. ctx.moveTo(x - 55, y - 40); ctx.lineTo(x - 35, y - 50);
  1541. ctx.lineTo(x - 10, y - 50); ctx.lineTo(x - 10, y - 30);
  1542. ctx.lineTo(x - 55, y - 30); ctx.closePath(); ctx.fill();
  1543. ctx.fillStyle = 'rgba(120,180,220,.35)';
  1544. ctx.beginPath();
  1545. ctx.moveTo(x - 50, y - 42); ctx.lineTo(x - 40, y - 48);
  1546. ctx.lineTo(x - 30, y - 48); ctx.lineTo(x - 40, y - 42);
  1547. ctx.closePath(); ctx.fill();
  1548. ctx.fillStyle = 'rgba(120,180,220,.30)';
  1549. ctx.beginPath();
  1550. ctx.moveTo(x + 22, y - 38); ctx.lineTo(x + 30, y - 38);
  1551. ctx.lineTo(x + 30, y - 32); ctx.lineTo(x + 22, y - 32);
  1552. ctx.closePath(); ctx.fill();
  1553. ctx.fillStyle = '#0a0a0a'; ctx.fillRect(x + 15, y - 50, 4, 20);
  1554. ctx.fillStyle = '#050505';
  1555. ctx.strokeStyle = '#404040';
  1556. ctx.lineWidth = 1;
  1557. ctx.fillRect(x + 82, y - 26, 20, 12);
  1558. ctx.strokeRect(x + 82, y - 26, 20, 12);
  1559. for (let i = 0; i < 3; i++) {
  1560. ctx.strokeStyle = '#505860';
  1561. ctx.beginPath();
  1562. ctx.moveTo(x + 83, y - 24 + i * 4);
  1563. ctx.lineTo(x + 101, y - 24 + i * 4);
  1564. ctx.stroke();
  1565. }
  1566. ctx.fillStyle = '#fff8c0'; ctx.shadowColor = '#fff8c0'; ctx.shadowBlur = 22;
  1567. ctx.beginPath();
  1568. ctx.moveTo(x + 84, y - 22); ctx.lineTo(x + 100, y - 22);
  1569. ctx.lineTo(x + 100, y - 12); ctx.lineTo(x + 84, y - 12);
  1570. ctx.closePath(); ctx.fill(); ctx.shadowBlur = 0;
  1571. ctx.fillStyle = '#c0e8ff'; ctx.fillRect(x + 86, y - 20, 12, 2);
  1572. ctx.fillStyle = 'rgba(255,255,255,.7)';
  1573. ctx.fillRect(x + 90, y - 21, 8, 1.5);
  1574. ctx.fillStyle = '#c02020'; ctx.shadowColor = '#c02020'; ctx.shadowBlur = 18;
  1575. ctx.fillRect(x - 105, y - 24, 6, 14); ctx.shadowBlur = 0;
  1576. ctx.fillStyle = 'rgba(255,160,160,.6)';
  1577. ctx.fillRect(x - 104, y - 23, 3, 4);
  1578. drawWheels(x, y, 62, 58, 17, '#c8d0d8', wheelAngle);
  1579. }
  1580. function drawPoliceCar(x, y, wheelAngle) {
  1581. ctx.fillStyle = 'rgba(0,0,0,.6)';
  1582. ctx.beginPath(); ctx.ellipse(x, y + 4, 105, 8, 0, 0, Math.PI * 2); ctx.fill();
  1583. const bg = ctx.createLinearGradient(0, y - 58, 0, y);
  1584. bg.addColorStop(0, '#f04444');
  1585. bg.addColorStop(0.35, '#c82020');
  1586. bg.addColorStop(0.75, '#700a0a');
  1587. bg.addColorStop(1, '#400404');
  1588. ctx.fillStyle = bg; ctx.strokeStyle = '#000'; ctx.lineWidth = 2;
  1589. ctx.beginPath();
  1590. ctx.moveTo(x + 105, y - 10); ctx.lineTo(x + 100, y - 28);
  1591. ctx.lineTo(x + 80, y - 34); ctx.lineTo(x + 55, y - 42);
  1592. ctx.lineTo(x + 35, y - 54); ctx.lineTo(x - 25, y - 54);
  1593. ctx.lineTo(x - 45, y - 42); ctx.lineTo(x - 55, y - 34);
  1594. ctx.lineTo(x - 92, y - 30); ctx.lineTo(x - 105, y - 12);
  1595. ctx.lineTo(x - 105, y); ctx.lineTo(x + 105, y);
  1596. ctx.closePath(); ctx.fill(); ctx.stroke();
  1597. ctx.fillStyle = 'rgba(255,255,255,.20)';
  1598. ctx.beginPath();
  1599. ctx.moveTo(x - 90, y - 20); ctx.lineTo(x + 90, y - 20);
  1600. ctx.lineTo(x + 85, y - 24); ctx.lineTo(x - 85, y - 24);
  1601. ctx.closePath(); ctx.fill();
  1602. const gk = ctx.createLinearGradient(0, y - 54, 0, y - 30);
  1603. gk.addColorStop(0, 'rgba(70,120,170,.85)');
  1604. gk.addColorStop(1, 'rgba(20,40,70,.85)');
  1605. ctx.fillStyle = gk;
  1606. ctx.beginPath();
  1607. ctx.moveTo(x - 45, y - 40); ctx.lineTo(x - 18, y - 40);
  1608. ctx.lineTo(x - 18, y - 30); ctx.lineTo(x - 52, y - 30);
  1609. ctx.closePath(); ctx.fill();
  1610. ctx.beginPath();
  1611. ctx.moveTo(x + 55, y - 40); ctx.lineTo(x + 35, y - 50);
  1612. ctx.lineTo(x + 10, y - 50); ctx.lineTo(x + 10, y - 30);
  1613. ctx.lineTo(x + 55, y - 30); ctx.closePath(); ctx.fill();
  1614. ctx.fillRect(x - 12, y - 30, 25, 10);
  1615. ctx.save();
  1616. ctx.fillStyle = '#ffffff';
  1617. ctx.font = 'bold 11px "Courier New"';
  1618. ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  1619. ctx.shadowColor = 'rgba(0,0,0,.9)'; ctx.shadowBlur = 3;
  1620. ctx.fillText('ПОЛИЦИЯ', x - 40, y - 12);
  1621. ctx.fillText('ПОЛИЦИЯ', x + 30, y - 12);
  1622. ctx.restore();
  1623. ctx.fillStyle = '#1a3a80'; ctx.fillRect(x - 95, y - 6, 190, 3);
  1624. ctx.fillStyle = 'rgba(120,180,255,.4)'; ctx.fillRect(x - 95, y - 6, 190, 1);
  1625. ctx.fillStyle = '#fff8c0'; ctx.shadowColor = '#fff8c0'; ctx.shadowBlur = 20;
  1626. ctx.fillRect(x - 100, y - 20, 14, 6); ctx.shadowBlur = 0;
  1627. ctx.fillStyle = '#c02020'; ctx.shadowColor = '#c02020'; ctx.shadowBlur = 14;
  1628. ctx.fillRect(x + 100, y - 24, 5, 14); ctx.shadowBlur = 0;
  1629. const blink = Math.sin(state.time * 12) > 0;
  1630. ctx.fillStyle = '#1a1a1a';
  1631. ctx.fillRect(x - 30, y - 60, 60, 5);
  1632. if (blink) {
  1633. const rg = ctx.createRadialGradient(x - 15, y - 68, 4, x - 15, y - 68, 50);
  1634. rg.addColorStop(0, 'rgba(255,50,50,.9)');
  1635. rg.addColorStop(1, 'rgba(255,50,50,0)');
  1636. ctx.fillStyle = rg; ctx.fillRect(x - 65, y - 118, 100, 100);
  1637. }
  1638. ctx.fillStyle = blink ? '#ff2020' : '#401010';
  1639. ctx.beginPath();
  1640. ctx.moveTo(x - 30, y - 60); ctx.lineTo(x, y - 60);
  1641. ctx.lineTo(x, y - 70); ctx.lineTo(x - 30, y - 70);
  1642. ctx.closePath(); ctx.fill();
  1643. ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.stroke();
  1644. if (!blink) {
  1645. const bg2 = ctx.createRadialGradient(x + 15, y - 68, 4, x + 15, y - 68, 50);
  1646. bg2.addColorStop(0, 'rgba(50,120,255,.9)');
  1647. bg2.addColorStop(1, 'rgba(50,120,255,0)');
  1648. ctx.fillStyle = bg2; ctx.fillRect(x - 35, y - 118, 100, 100);
  1649. }
  1650. ctx.fillStyle = !blink ? '#2060ff' : '#101040';
  1651. ctx.beginPath();
  1652. ctx.moveTo(x, y - 60); ctx.lineTo(x + 30, y - 60);
  1653. ctx.lineTo(x + 30, y - 70); ctx.lineTo(x, y - 70);
  1654. ctx.closePath(); ctx.fill();
  1655. ctx.strokeStyle = '#000'; ctx.stroke();
  1656. const beamGrad = ctx.createLinearGradient(x - 105, y - 14, x - 300, y + 10);
  1657. beamGrad.addColorStop(0, 'rgba(255,240,180,.5)');
  1658. beamGrad.addColorStop(1, 'rgba(255,240,180,0)');
  1659. ctx.fillStyle = beamGrad;
  1660. ctx.beginPath();
  1661. ctx.moveTo(x - 105, y - 20); ctx.lineTo(x - 300, y - 28);
  1662. ctx.lineTo(x - 300, y + 20); ctx.lineTo(x - 105, y - 4);
  1663. ctx.closePath(); ctx.fill();
  1664. drawWheels(x, y, 65, 60, 15, '#606060', wheelAngle);
  1665. }
  1666. function drawTowTruck(x, y, wheelAngle, doorOpen) {
  1667. ctx.fillStyle = 'rgba(0,0,0,.55)';
  1668. ctx.beginPath(); ctx.ellipse(x, y + 4, 105, 7, 0, 0, Math.PI * 2); ctx.fill();
  1669. const bg = ctx.createLinearGradient(0, y - 60, 0, y);
  1670. bg.addColorStop(0, '#e8c840'); bg.addColorStop(0.4, '#c8a020'); bg.addColorStop(1, '#705010');
  1671. ctx.fillStyle = bg; ctx.strokeStyle = '#000'; ctx.lineWidth = 2;
  1672. ctx.beginPath();
  1673. ctx.moveTo(x - 100, y - 8); ctx.lineTo(x - 100, y - 55);
  1674. ctx.lineTo(x - 55, y - 55); ctx.lineTo(x - 40, y - 25);
  1675. ctx.lineTo(x - 20, y - 25); ctx.lineTo(x - 20, y - 8);
  1676. ctx.lineTo(x - 20, y); ctx.lineTo(x - 100, y);
  1677. ctx.closePath(); ctx.fill(); ctx.stroke();
  1678. const gk = ctx.createLinearGradient(0, y - 50, 0, y - 28);
  1679. gk.addColorStop(0, 'rgba(90,140,180,.85)');
  1680. gk.addColorStop(1, 'rgba(30,60,90,.8)');
  1681. ctx.fillStyle = gk;
  1682. ctx.beginPath();
  1683. ctx.moveTo(x - 95, y - 50); ctx.lineTo(x - 60, y - 50);
  1684. ctx.lineTo(x - 48, y - 28); ctx.lineTo(x - 95, y - 28);
  1685. ctx.closePath(); ctx.fill();
  1686. ctx.fillStyle = 'rgba(255,255,255,.20)';
  1687. ctx.beginPath();
  1688. ctx.moveTo(x - 90, y - 48); ctx.lineTo(x - 75, y - 48);
  1689. ctx.lineTo(x - 80, y - 32); ctx.lineTo(x - 90, y - 32);
  1690. ctx.closePath(); ctx.fill();
  1691. const pg = ctx.createLinearGradient(0, y - 20, 0, y - 8);
  1692. pg.addColorStop(0, '#d0b040'); pg.addColorStop(1, '#705010');
  1693. ctx.fillStyle = pg; ctx.fillRect(x + 20, y - 18, 85, 10);
  1694. ctx.strokeStyle = '#000'; ctx.strokeRect(x + 20, y - 18, 85, 10);
  1695. ctx.fillStyle = 'rgba(255,255,255,.15)';
  1696. ctx.fillRect(x + 20, y - 18, 85, 2);
  1697. ctx.strokeStyle = '#806010'; ctx.lineWidth = 5;
  1698. ctx.beginPath();
  1699. ctx.moveTo(x + 5, y - 18); ctx.lineTo(x + 40, y - 55); ctx.lineTo(x + 80, y - 55); ctx.stroke();
  1700. ctx.strokeStyle = '#c0a020'; ctx.lineWidth = 2;
  1701. ctx.beginPath(); ctx.moveTo(x + 80, y - 55); ctx.lineTo(x + 90, y - 32); ctx.stroke();
  1702. ctx.strokeStyle = '#a0a8b0';
  1703. ctx.beginPath(); ctx.arc(x + 90, y - 28, 4, 0, Math.PI * 1.5); ctx.stroke();
  1704. const blink = Math.sin(state.time * 8) > 0;
  1705. if (blink) {
  1706. const mg = ctx.createRadialGradient(x - 59, y - 59, 3, x - 59, y - 59, 40);
  1707. mg.addColorStop(0, 'rgba(255,180,70,.9)');
  1708. mg.addColorStop(1, 'rgba(255,180,70,0)');
  1709. ctx.fillStyle = mg;
  1710. ctx.fillRect(x - 100, y - 100, 80, 80);
  1711. }
  1712. ctx.fillStyle = blink ? '#ffb040' : '#604010';
  1713. ctx.fillRect(x - 65, y - 62, 12, 6);
  1714. ctx.fillStyle = '#fff8c0'; ctx.shadowColor = '#fff8c0'; ctx.shadowBlur = 15;
  1715. ctx.beginPath(); ctx.arc(x - 95, y - 14, 4, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0;
  1716. ctx.fillStyle = 'rgba(255,255,255,.7)';
  1717. ctx.beginPath(); ctx.arc(x - 96, y - 15, 1.5, 0, Math.PI * 2); ctx.fill();
  1718. drawWheels(x, y, 70, 25, 14, '#606060', wheelAngle);
  1719. const wx = x + 60;
  1720. ctx.beginPath(); ctx.arc(wx, y, 14, 0, Math.PI * 2);
  1721. ctx.fillStyle = '#0a0a0a'; ctx.fill();
  1722. ctx.beginPath(); ctx.arc(wx, y, 5, 0, Math.PI * 2);
  1723. ctx.fillStyle = '#606060'; ctx.fill();
  1724. }
  1725. function drawTowDriver(x, y, walking, alpha) {
  1726. ctx.save();
  1727. ctx.globalAlpha = alpha !== undefined ? Math.max(0, Math.min(1, alpha)) : 1;
  1728. const wobble = walking ? Math.sin(state.time * 10) * 2 : 0;
  1729. ctx.fillStyle = 'rgba(0,0,0,.5)';
  1730. ctx.beginPath(); ctx.ellipse(x, y + 2, 13, 4, 0, 0, Math.PI * 2); ctx.fill();
  1731. ctx.fillStyle = '#252c38';
  1732. ctx.fillRect(x - 8, y - 33, 7, 29 + wobble);
  1733. ctx.fillRect(x + 1, y - 33, 7, 29 - wobble);
  1734. ctx.fillStyle = '#0a0a0a';
  1735. ctx.fillRect(x - 10, y + 1 + wobble, 11, 3);
  1736. ctx.fillRect(x, y + 1 - wobble, 11, 3);
  1737. const jg = ctx.createLinearGradient(x - 11, 0, x + 11, 0);
  1738. jg.addColorStop(0, '#b06818'); jg.addColorStop(0.4, '#f09030'); jg.addColorStop(1, '#a85810');
  1739. ctx.fillStyle = jg; ctx.fillRect(x - 10, y - 63, 20, 33);
  1740. ctx.strokeStyle = '#703810'; ctx.lineWidth = 1; ctx.strokeRect(x - 10, y - 63, 20, 33);
  1741. ctx.fillStyle = '#f0f0f0';
  1742. ctx.fillRect(x - 10, y - 56, 20, 3);
  1743. ctx.fillRect(x - 10, y - 44, 20, 3);
  1744. ctx.fillStyle = '#d0a880';
  1745. ctx.fillRect(x - 16, y - 60, 6, 23);
  1746. ctx.fillRect(x + 10, y - 60, 6, 23);
  1747. ctx.fillStyle = '#d0a880'; ctx.fillRect(x - 3, y - 67, 6, 5);
  1748. ctx.beginPath(); ctx.arc(x, y - 76, 9, 0, Math.PI * 2);
  1749. const fg = ctx.createRadialGradient(x - 3, y - 78, 2, x, y - 76, 9);
  1750. fg.addColorStop(0, '#e8b890'); fg.addColorStop(1, '#c09870');
  1751. ctx.fillStyle = fg; ctx.fill();
  1752. ctx.fillStyle = '#e8c020';
  1753. ctx.beginPath(); ctx.arc(x, y - 82, 11, Math.PI, 0); ctx.fill();
  1754. ctx.fillRect(x - 11, y - 83, 22, 4);
  1755. ctx.strokeStyle = '#8a6a10'; ctx.lineWidth = 1;
  1756. ctx.beginPath(); ctx.arc(x, y - 82, 11, Math.PI, 0); ctx.stroke();
  1757. ctx.fillStyle = '#c0a010';
  1758. ctx.fillRect(x - 1.5, y - 93, 3, 12);
  1759. ctx.restore();
  1760. }
  1761. function drawSenior(x, y, walking, alpha) {
  1762. ctx.save();
  1763. ctx.globalAlpha = alpha !== undefined ? Math.max(0, Math.min(1, alpha)) : 1;
  1764. const wobble = walking ? Math.sin(state.time * 9) * 2 : 0;
  1765. ctx.fillStyle = 'rgba(0,0,0,.55)';
  1766. ctx.beginPath(); ctx.ellipse(x, y + 2, 15, 5, 0, 0, Math.PI * 2); ctx.fill();
  1767. ctx.fillStyle = '#1a2340';
  1768. ctx.fillRect(x - 8, y - 35, 7, 31 + wobble);
  1769. ctx.fillRect(x + 1, y - 35, 7, 31 - wobble);
  1770. ctx.fillStyle = '#0a0a0a';
  1771. ctx.fillRect(x - 10, y - 3 + wobble, 11, 7);
  1772. ctx.fillRect(x + 1, y - 3 - wobble, 11, 7);
  1773. const un = ctx.createLinearGradient(x - 12, 0, x + 12, 0);
  1774. un.addColorStop(0, '#1a2a4a'); un.addColorStop(0.4, '#2a3a5a'); un.addColorStop(1, '#141e38');
  1775. ctx.fillStyle = un; ctx.fillRect(x - 12, y - 70, 24, 38);
  1776. ctx.strokeStyle = '#0a1428'; ctx.lineWidth = 1; ctx.strokeRect(x - 12, y - 70, 24, 38);
  1777. ctx.fillStyle = '#c0a040';
  1778. for (let i = 0; i < 3; i++) {
  1779. ctx.beginPath(); ctx.arc(x, y - 60 + i * 10, 1.2, 0, Math.PI * 2); ctx.fill();
  1780. }
  1781. ctx.fillStyle = '#e0e0f0';
  1782. ctx.fillRect(x - 13, y - 70, 6, 3);
  1783. ctx.fillRect(x + 7, y - 70, 6, 3);
  1784. ctx.fillStyle = '#ffd060';
  1785. ctx.beginPath(); ctx.arc(x - 10, y - 71, 1.5, 0, Math.PI * 2); ctx.fill();
  1786. ctx.beginPath(); ctx.arc(x + 10, y - 71, 1.5, 0, Math.PI * 2); ctx.fill();
  1787. ctx.fillStyle = '#2a3a5a';
  1788. ctx.fillRect(x - 18, y - 68, 6, 32);
  1789. ctx.fillRect(x + 12, y - 68, 6, 32);
  1790. ctx.fillStyle = '#0a0a0a';
  1791. ctx.fillRect(x - 18, y - 38, 7, 8);
  1792. ctx.fillRect(x + 12, y - 38, 7, 8);
  1793. ctx.fillStyle = '#d0a880'; ctx.fillRect(x - 3, y - 74, 6, 5);
  1794. ctx.beginPath(); ctx.arc(x, y - 83, 10, 0, Math.PI * 2);
  1795. const fg = ctx.createRadialGradient(x - 3, y - 85, 2, x, y - 83, 10);
  1796. fg.addColorStop(0, '#e8b890'); fg.addColorStop(1, '#c09870');
  1797. ctx.fillStyle = fg; ctx.fill();
  1798. ctx.fillStyle = '#1a1a1a';
  1799. ctx.beginPath(); ctx.arc(x - 3, y - 84, 1.3, 0, Math.PI * 2); ctx.fill();
  1800. ctx.beginPath(); ctx.arc(x + 3, y - 84, 1.3, 0, Math.PI * 2); ctx.fill();
  1801. const cap = ctx.createLinearGradient(x - 13, 0, x + 13, 0);
  1802. cap.addColorStop(0, '#0f1a35'); cap.addColorStop(0.5, '#1a2c52'); cap.addColorStop(1, '#0a1428');
  1803. ctx.fillStyle = cap;
  1804. ctx.beginPath(); ctx.arc(x, y - 90, 13, Math.PI, 0); ctx.fill();
  1805. ctx.fillRect(x - 13, y - 90, 26, 5);
  1806. ctx.strokeStyle = '#0a1428'; ctx.lineWidth = 1.5;
  1807. ctx.beginPath(); ctx.arc(x, y - 90, 13, Math.PI, 0); ctx.stroke();
  1808. ctx.beginPath(); ctx.moveTo(x - 13, y - 85); ctx.lineTo(x + 13, y - 85); ctx.stroke();
  1809. ctx.fillStyle = '#05080f';
  1810. ctx.beginPath();
  1811. ctx.moveTo(x - 14, y - 85); ctx.lineTo(x + 14, y - 85);
  1812. ctx.lineTo(x + 12, y - 81); ctx.lineTo(x - 12, y - 81);
  1813. ctx.closePath(); ctx.fill();
  1814. ctx.strokeStyle = '#000'; ctx.stroke();
  1815. ctx.fillStyle = '#c0a040';
  1816. ctx.beginPath(); ctx.arc(x, y - 96, 2.5, 0, Math.PI * 2); ctx.fill();
  1817. ctx.fillStyle = 'rgba(255,220,140,.7)';
  1818. ctx.beginPath(); ctx.arc(x - 0.8, y - 96.8, 1, 0, Math.PI * 2); ctx.fill();
  1819. ctx.restore();
  1820. }
  1821. const CAR_SCALE = 1.56;
  1822. function drawCarScaled(drawFn, x, y, ...args) {
  1823. ctx.save();
  1824. ctx.translate(x, y);
  1825. ctx.scale(CAR_SCALE, CAR_SCALE);
  1826. ctx.translate(-x, -y);
  1827. drawFn(x, y, ...args);
  1828. ctx.restore();
  1829. }
  1830. /* ===== ПОГОДА ===== */
  1831. function updateWeatherParticles(dt) {
  1832. if (state.weather === 'clear' || state.weather === 'fog') {
  1833. state.weatherParticles = []; return;
  1834. }
  1835. const targetCount = state.weather === 'rain' ? 240 : 140;
  1836. while (state.weatherParticles.length < targetCount) {
  1837. state.weatherParticles.push({
  1838. x: Math.random() * W, y: Math.random() * H,
  1839. vx: state.weather === 'rain' ? 60 + Math.random() * 40 : -10 + Math.random() * 20,
  1840. vy: state.weather === 'rain' ? 600 + Math.random() * 200 : 30 + Math.random() * 40,
  1841. size: state.weather === 'rain' ? 1 : 1.5 + Math.random() * 1.5,
  1842. alpha: state.weather === 'rain' ? 0.5 + Math.random() * 0.4 : 0.7 + Math.random() * 0.3
  1843. });
  1844. }
  1845. if (state.weatherParticles.length > targetCount) state.weatherParticles.length = targetCount;
  1846. state.weatherParticles.forEach(p => {
  1847. p.x += p.vx * dt; p.y += p.vy * dt;
  1848. if (p.y > H) { p.y = -10; p.x = Math.random() * W; }
  1849. });
  1850. }
  1851. function drawWeatherParticles() {
  1852. if (state.weather === 'rain') {
  1853. ctx.strokeStyle = 'rgba(180,200,220,.55)';
  1854. ctx.lineWidth = 1;
  1855. state.weatherParticles.forEach(p => {
  1856. ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - 1, p.y - 12); ctx.stroke();
  1857. });
  1858. } else if (state.weather === 'snow') {
  1859. state.weatherParticles.forEach(p => {
  1860. ctx.fillStyle = `rgba(240,248,255,${p.alpha})`;
  1861. ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
  1862. });
  1863. } else if (state.weather === 'fog') {
  1864. const fogGrad = ctx.createLinearGradient(0, 0, 0, H);
  1865. fogGrad.addColorStop(0, 'rgba(200,205,215,.30)');
  1866. fogGrad.addColorStop(1, 'rgba(200,205,215,.35)');
  1867. ctx.fillStyle = fogGrad; ctx.fillRect(0, 0, W, H);
  1868. }
  1869. }
  1870. /* ===== ЛОГИКА ===== */
  1871. let lastTs = 0;
  1872. function updateScene(dt) {
  1873. const l = L();
  1874. if (!state.model) return;
  1875. if (state.alarm && state.time > state.alarmEnd) state.alarm = false;
  1876. const diff = state.barrier.target - state.barrier.level;
  1877. if (Math.abs(diff) > 0.001) {
  1878. const speedUp = 1 / state.model.openTime;
  1879. const speedDown = 1 / state.model.closeTime;
  1880. const spd = diff > 0 ? speedUp : speedDown;
  1881. const step = Math.min(Math.abs(diff), dt * spd);
  1882. state.barrier.level += Math.sign(diff) * step;
  1883. state.barrier.moving = true;
  1884. if (Math.abs(diff - Math.sign(diff)*step) < 0.005) {
  1885. state.barrier.level = state.barrier.target;
  1886. state.barrier.moving = false;
  1887. }
  1888. } else {
  1889. state.barrier.moving = false;
  1890. }
  1891. state.skud.kw1 = state.barrier.level < 0.05;
  1892. state.skud.kw2 = state.barrier.level > 0.95;
  1893. if (state.skud.kw1) { state.skud.relayClose = false; }
  1894. if (state.skud.kw2) { state.skud.relayOpen = false; }
  1895. if (state.barrier.level > 0.95 && !state.holdMode && state.model.autoClose > 0 && !state.manualMode) {
  1896. state.autoCloseTimer += dt;
  1897. if (state.autoCloseTimer >= state.model.autoClose && !state.skud.photo && !state.skud.loop1 && !state.skud.loop2) {
  1898. closeBarrier();
  1899. state.autoCloseTimer = 0;
  1900. }
  1901. } else if (state.barrier.level < 0.95) {
  1902. state.autoCloseTimer = 0;
  1903. }
  1904. const stopX = l.pivotX - (state.model.armLen) - 60;
  1905. if (state.car) {
  1906. const c = state.car;
  1907. if (state.phase === 'approaching') {
  1908. if (c.x < stopX) {
  1909. c.x += c.speed * dt; c.wheelAngle += c.speed * dt * 0.06;
  1910. } else if (c.isPriora) {
  1911. c.x += c.speed * dt; c.wheelAngle += c.speed * dt * 0.06;
  1912. if (c.x >= l.pivotX - state.model.armLen - 20 && state.barrier.level < 0.5) {
  1913. c.x = l.pivotX - state.model.armLen - 20; crash();
  1914. } else if (c.x > W + 300) {
  1915. state.car = null; state.phase = 'idle';
  1916. }
  1917. } else {
  1918. state.phase = 'waiting';
  1919. setStatus('ОЖИДАНИЕ РЕШЕНИЯ', 'wait');
  1920. setHint('Сравните пропуска и примите решение.');
  1921. if (state.model.hasLoop) {
  1922. state.skud.loop1 = true;
  1923. logSkud('ПЕТЛЯ 1: занята', 'blu');
  1924. state.stats.sensors++;
  1925. }
  1926. renderPasses();
  1927. }
  1928. }
  1929. else if (state.phase === 'resolved') {
  1930. if (c.action === 'pass') {
  1931. if (state.barrier.level > 0.9) {
  1932. c.x += c.speed * dt; c.wheelAngle += c.speed * dt * 0.06;
  1933. if (state.model.hasPhoto) {
  1934. if (!state.skud.photo && c.x > l.pivotX - state.model.armLen - 40 && c.x < l.pivotX + 40) {
  1935. state.skud.photo = true;
  1936. logSkud('ФОТОЭЛЕМЕНТ: разрыв луча', 'yel');
  1937. state.stats.sensors++;
  1938. }
  1939. if (state.skud.photo && (c.x < l.pivotX - state.model.armLen - 60 || c.x > l.pivotX + 60)) {
  1940. state.skud.photo = false;
  1941. }
  1942. }
  1943. if (c.x > W + 300) {
  1944. logEvent('Автомобиль проехал КПП', 'ok');
  1945. state.stats.in++;
  1946. state.car = null; state.phase = 'idle';
  1947. if (state.model.hasLoop) { state.skud.loop1 = false; logSkud('ПЕТЛЯ 1: свободна'); }
  1948. if (state.skud.photo) state.skud.photo = false;
  1949. state.barrier.target = 0;
  1950. setStatus('ПОСТ СВОБОДЕН');
  1951. setHint('Нажмите <b>«Следующая машина»</b>.');
  1952. state.senior = null;
  1953. }
  1954. }
  1955. } else if (c.action === 'turn') {
  1956. c.x -= c.speed * dt; c.wheelAngle -= c.speed * dt * 0.06;
  1957. if (c.x < -350) {
  1958. logEvent('Автомобиль уехал', 'info');
  1959. state.car = null; state.phase = 'idle';
  1960. if (state.model.hasLoop) { state.skud.loop1 = false; logSkud('ПЕТЛЯ 1: свободна'); }
  1961. setStatus('ПОСТ СВОБОДЕН');
  1962. setHint('Нажмите <b>«Следующая машина»</b>.');
  1963. state.senior = null;
  1964. }
  1965. }
  1966. }
  1967. }
  1968. if (state.senior) {
  1969. const s = state.senior;
  1970. const SPEED = 90;
  1971. if (s.state === 'walk_to_lane') {
  1972. s.alpha = Math.min(1, (s.alpha || 0) + dt * 1.5);
  1973. s.walking = true; s.y = l.botLaneY;
  1974. const targetX = (state.car ? state.car.x : 0) + 140;
  1975. if (s.x > targetX) s.x -= SPEED * dt;
  1976. else s.state = 'walk_up';
  1977. } else if (s.state === 'walk_up') {
  1978. s.walking = true;
  1979. if (s.y > l.topLaneY + 2) s.y -= SPEED * 0.7 * dt;
  1980. else {
  1981. s.y = l.topLaneY; s.walking = false; s.state = 'review'; s.decideTimer = 0;
  1982. setStatus('СТАРШИЙ У МАШИНЫ', 'senior');
  1983. setHint('Старший смены проверяет документы...');
  1984. logEvent('Старший смены подошёл к машине', 'senior');
  1985. }
  1986. } else if (s.state === 'review') {
  1987. s.decideTimer += dt; s.walking = false;
  1988. if (s.decideTimer > 2.0) {
  1989. const allow = (state.correctDecision === 'allow');
  1990. if (allow) {
  1991. logEvent('Старший смены: пропуск действителен — пропускаем', 'senior');
  1992. setStatus('СТАРШИЙ ПРОПУСКАЕТ', 'ok');
  1993. state.car.action = 'pass';
  1994. openBarrier();
  1995. state.phase = 'resolved';
  1996. state.userDecision = 'allow';
  1997. renderPasses();
  1998. } else {
  1999. logEvent('Старший смены: пропуск недействителен — отказ', 'senior');
  2000. setStatus('СТАРШИЙ ОТКАЗЫВАЕТ', 'wait');
  2001. state.car.action = 'turn';
  2002. state.phase = 'resolved';
  2003. state.userDecision = 'deny';
  2004. state.stats.out++;
  2005. renderPasses();
  2006. }
  2007. s.state = 'returning';
  2008. }
  2009. } else if (s.state === 'returning') {
  2010. s.walking = true;
  2011. if (s.y < l.botLaneY - 2) s.y += SPEED * 0.7 * dt;
  2012. else {
  2013. s.y = l.botLaneY; s.x += SPEED * dt;
  2014. if (s.x > W + 60) state.senior = null;
  2015. }
  2016. }
  2017. }
  2018. if (state.phase === 'crash_scene') {
  2019. if (state.police) {
  2020. const p = state.police;
  2021. if (p.state === 'arriving') {
  2022. p.x -= p.speed * dt; p.wheelAngle -= p.speed * dt * 0.06;
  2023. if (p.x <= p.targetX) { p.x = p.targetX; p.state = 'stopped'; }
  2024. } else if (p.state === 'leaving') {
  2025. p.x += p.speed * dt; p.wheelAngle += p.speed * dt * 0.06;
  2026. }
  2027. }
  2028. if (state.tow) {
  2029. const t2 = state.tow;
  2030. if (t2.state === 'arriving') {
  2031. t2.x += t2.speed * dt; t2.wheelAngle -= t2.speed * dt * 0.06;
  2032. if (t2.x >= t2.targetX) { t2.x = t2.targetX; t2.state = 'stopped'; t2.stopTimer = 0; }
  2033. } else if (t2.state === 'stopped') {
  2034. t2.stopTimer = (t2.stopTimer || 0) + dt;
  2035. } else if (t2.state === 'leaving') {
  2036. t2.x -= t2.speed * dt; t2.wheelAngle += t2.speed * dt * 0.06;
  2037. }
  2038. }
  2039. if (state.car && state.car.towed && state.tow) {
  2040. state.car.x = state.tow.x + 220;
  2041. state.car.wheelAngle += state.tow.speed * dt * 0.06;
  2042. }
  2043. if (state.tow && state.tow.state === 'stopped' && state.tow.stopTimer > 1.2 && !state.towDriver && !state.leaving && state.car) {
  2044. const doorX = state.tow.x - 150;
  2045. state.towDriver = { x: doorX, y: l.topLaneY, doorX: doorX,
  2046. carApproachX: state.car.x - 160,
  2047. state: 'exiting', exitTimer: 0, alpha: 0, walking: false, attachTimer: 0, boardTimer: 0 };
  2048. state.tow.doorOpen = true;
  2049. logEvent('Водитель выходит из кабины эвакуатора', 'info');
  2050. }
  2051. if (state.towDriver) {
  2052. const td = state.towDriver;
  2053. const underY = l.topLaneBot + 25;
  2054. const SPD = 55;
  2055. if (td.state === 'exiting') {
  2056. td.exitTimer += dt; td.alpha = Math.min(1, td.exitTimer / 0.8);
  2057. if (td.exitTimer > 0.8) { td.alpha = 1; td.state = 'walking_out_down'; }
  2058. } else if (td.state === 'walking_out_down') {
  2059. if (td.y < underY - 2) { td.y += SPD * dt; td.walking = true; }
  2060. else { td.y = underY; td.state = 'walking_right'; }
  2061. } else if (td.state === 'walking_right') {
  2062. if (td.x < td.carApproachX - 2) { td.x += SPD * dt; td.walking = true; }
  2063. else { td.x = td.carApproachX; td.state = 'walking_up'; }
  2064. } else if (td.state === 'walking_up') {
  2065. if (td.y > l.topLaneY + 2) { td.y -= SPD * dt; td.walking = true; }
  2066. else { td.y = l.topLaneY; td.state = 'attaching'; td.attachTimer = 0; }
  2067. } else if (td.state === 'attaching') {
  2068. td.attachTimer += dt; td.walking = false;
  2069. if (td.attachTimer > 0.6 && !state.towCable) { state.towCable = true; logEvent('Автомобиль зацеплен', 'info'); }
  2070. if (td.attachTimer > 1.4) td.state = 'returning_down';
  2071. } else if (td.state === 'returning_down') {
  2072. if (td.y < underY - 2) { td.y += SPD * dt; td.walking = true; }
  2073. else { td.y = underY; td.state = 'returning_left'; }
  2074. } else if (td.state === 'returning_left') {
  2075. if (td.x > td.doorX + 2) { td.x -= SPD * dt; td.walking = true; }
  2076. else { td.x = td.doorX; td.state = 'returning_up'; }
  2077. } else if (td.state === 'returning_up') {
  2078. if (td.y > l.topLaneY + 2) { td.y -= SPD * dt; td.walking = true; }
  2079. else { td.y = l.topLaneY; td.state = 'boarding'; td.boardTimer = 0; td.walking = false; }
  2080. } else if (td.state === 'boarding') {
  2081. td.boardTimer += dt; td.walking = false;
  2082. td.alpha = Math.max(0, 1 - td.boardTimer / 0.8);
  2083. td.x += 20 * dt;
  2084. if (td.boardTimer > 0.8) {
  2085. td.state = 'gone'; td.alpha = 0;
  2086. if (state.tow) state.tow.doorOpen = false;
  2087. if (!state.leaving) {
  2088. state.leaving = true;
  2089. setTimeout(() => {
  2090. logEvent('Эвакуатор увозит автомобиль', 'info');
  2091. if (state.car) state.car.towed = true;
  2092. if (state.police) state.police.state = 'leaving';
  2093. if (state.tow) state.tow.state = 'leaving';
  2094. }, 600);
  2095. }
  2096. }
  2097. }
  2098. }
  2099. if (state.tow && state.tow.state === 'leaving' && state.tow.x < -600) {
  2100. state.car = null; state.police = null; state.tow = null;
  2101. state.towDriver = null; state.towCable = false; state.leaving = false;
  2102. state.phase = 'idle'; state.barrier.target = 0;
  2103. state.alarm = false; state.skud.alarm = false;
  2104. setStatus('ПОСТ СВОБОДЕН');
  2105. setHint('Инцидент исчерпан. Нажмите «Следующая машина».');
  2106. logEvent('Инцидент закрыт', 'ok');
  2107. }
  2108. }
  2109. updateWeatherParticles(dt);
  2110. updateSkudLeds();
  2111. }
  2112. function crash() {
  2113. logEvent('!!! СТОЛКНОВЕНИЕ СО ШЛАГБАУМОМ !!!', 'alarm');
  2114. setStatus('ТРЕВОГА !', 'alarm');
  2115. setHint('Нарушитель врезался. Вызваны полиция и эвакуатор.');
  2116. state.alarm = true;
  2117. state.alarmEnd = state.time + 5;
  2118. state.phase = 'crash_scene';
  2119. state.leaving = false;
  2120. state.stats.alarms++;
  2121. state.stats.breaches++;
  2122. state.skud.alarm = true;
  2123. if (state.model.hasImpact) {
  2124. state.skud.impact = true;
  2125. logSkud('ДАТЧИК УДАРА: сработал', 'alarm');
  2126. state.stats.sensors++;
  2127. }
  2128. if (state.car) { state.car.action = 'crashed'; state.car.speed = 0; }
  2129. const l = L();
  2130. for (let i = 0; i < 40; i++) {
  2131. state.sparks.push({
  2132. x: l.pivotX - state.model.armLen - 30, y: l.topLaneY - 40,
  2133. vx: (Math.random() - .5) * 400, vy: (Math.random() - .5) * 300 - 80,
  2134. life: 1
  2135. });
  2136. }
  2137. setTimeout(() => {
  2138. state.police = { x: W + 300, targetX: l.pivotX - 100, speed: 260, state: 'arriving', wheelAngle: 0 };
  2139. logEvent('Прибывает полицейский автомобиль', 'warn');
  2140. }, 700);
  2141. setTimeout(() => {
  2142. const carX = state.car ? state.car.x : (l.pivotX - state.model.armLen - 20);
  2143. state.tow = { x: -300, targetX: carX - 340, speed: 200, state: 'arriving',
  2144. wheelAngle: 0, stopTimer: 0, doorOpen: false };
  2145. logEvent('Прибывает эвакуатор', 'warn');
  2146. }, 1400);
  2147. }
  2148. /* ===== ОТРИСОВКА ===== */
  2149. function drawScene() {
  2150. const l = L();
  2151. const t = state.time;
  2152. ctx.fillStyle = '#02040a'; ctx.fillRect(0, 0, W, H);
  2153. if (!state.model) return;
  2154. drawSky(); drawGround(); drawRoads();
  2155. drawBooth();
  2156. drawGuard(l.boothX + l.boothW + 25, l.boothBaseY);
  2157. if (state.towCable && state.tow && state.car) {
  2158. const fromX = state.tow.x + 140;
  2159. const fromY = l.topLaneY - 20;
  2160. const toX = state.car.x - 140;
  2161. const toY = l.topLaneY - 15;
  2162. ctx.strokeStyle = 'rgba(200,200,200,.85)'; ctx.lineWidth = 2;
  2163. ctx.beginPath(); ctx.moveTo(fromX, fromY);
  2164. ctx.quadraticCurveTo((fromX + toX) / 2, Math.max(fromY, toY) + 22, toX, toY); ctx.stroke();
  2165. }
  2166. drawBarrier();
  2167. if (state.car) {
  2168. if (state.car.isPriora) drawCarScaled(drawDarkCar, state.car.x, l.topLaneY, state.car.wheelAngle);
  2169. else drawCarScaled(drawSedan, state.car.x, l.topLaneY, state.car.color, state.car.wheelAngle);
  2170. }
  2171. if (state.police) drawCarScaled(drawPoliceCar, state.police.x, l.botLaneY, state.police.wheelAngle);
  2172. if (state.tow) drawCarScaled(drawTowTruck, state.tow.x, l.topLaneY, state.tow.wheelAngle, state.tow.doorOpen);
  2173. if (state.towDriver && state.towDriver.state !== 'gone') {
  2174. drawTowDriver(state.towDriver.x, state.towDriver.y, state.towDriver.walking, state.towDriver.alpha);
  2175. }
  2176. if (state.senior) {
  2177. drawSenior(state.senior.x, state.senior.y, state.senior.walking, state.senior.alpha);
  2178. }
  2179. state.sparks = state.sparks.filter(s => s.life > 0);
  2180. state.sparks.forEach(s => {
  2181. s.x += s.vx / 60; s.y += s.vy / 60; s.vy += 500 / 60; s.life -= 1.2 / 60;
  2182. ctx.fillStyle = `rgba(255,${Math.floor(160 + Math.random()*90)},60,${Math.max(0, s.life)})`;
  2183. ctx.beginPath(); ctx.arc(s.x, s.y, 2 + s.life * 3, 0, Math.PI * 2); ctx.fill();
  2184. });
  2185. drawWeatherParticles();
  2186. if (state.alarm) {
  2187. const pulse = 0.5 + Math.sin(t * 8) * 0.5;
  2188. ctx.strokeStyle = `rgba(255,40,40,${pulse * 0.5})`; ctx.lineWidth = 12;
  2189. ctx.strokeRect(6, 6, W - 12, H - 12);
  2190. ctx.fillStyle = `rgba(255,60,60,${0.5 + 0.3 * Math.abs(Math.sin(t * 5))})`;
  2191. ctx.font = 'bold 36px "Courier New"'; ctx.textAlign = 'center';
  2192. ctx.fillText('!!! ТРЕВОГА !!!', W / 2, 60); ctx.textAlign = 'left';
  2193. }
  2194. const hintText = {
  2195. idle: 'ПОСТ СВОБОДЕН · НАЖМИТЕ «СЛЕДУЮЩАЯ МАШИНА»',
  2196. approaching: 'АВТОМОБИЛЬ ПРИБЛИЖАЕТСЯ',
  2197. waiting: 'ОЖИДАНИЕ РЕШЕНИЯ ОПЕРАТОРА',
  2198. refuse_wait: 'МАШИНА ЖДЁТ · ОБЪЯСНИТЕ ПРИЧИНУ ИЛИ ВЫЗОВИТЕ СТАРШЕГО',
  2199. senior_walk: 'СТАРШИЙ СМЕНЫ ИДЁТ К МАШИНЕ',
  2200. resolved: 'ОБРАБОТКА РЕЗУЛЬТАТА',
  2201. crash_scene: 'НА МЕСТЕ ПРОИСШЕСТВИЯ'
  2202. }[state.phase] || '';
  2203. if (hintText) {
  2204. ctx.font = 'bold 12px "Courier New"';
  2205. const tw = ctx.measureText(hintText).width + 40;
  2206. ctx.fillStyle = 'rgba(10,14,20,.78)';
  2207. ctx.fillRect(W / 2 - tw / 2, 20, tw, 28);
  2208. ctx.strokeStyle = 'rgba(96,160,224,.5)'; ctx.lineWidth = 1;
  2209. ctx.strokeRect(W / 2 - tw / 2, 20, tw, 28);
  2210. ctx.fillStyle = '#a0c0e0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  2211. ctx.fillText(hintText, W / 2, 34); ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
  2212. }
  2213. }
  2214. function loop(ts) {
  2215. if (!lastTs) lastTs = ts;
  2216. const dt = Math.min(0.05, (ts - lastTs) / 1000);
  2217. lastTs = ts;
  2218. state.time += dt;
  2219. updateScene(dt);
  2220. drawScene();
  2221. drawScope();
  2222. pushScopeSample();
  2223. requestAnimationFrame(loop);
  2224. }
  2225. function updateClock() {
  2226. const d = new Date();
  2227. document.getElementById('clock').textContent =
  2228. String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0');
  2229. }
  2230. setInterval(updateClock, 1000); updateClock();
  2231. /* ===== СТАРТ ===== */
  2232. resizeCanvas(); renderPasses(); renderLog();
  2233. renderModelSwitcher();
  2234. switchModel('B');
  2235. renderModelPassport();
  2236. drawScope();
  2237. logSkud('Запуск СКУД КПП-3...', 'ok');
  2238. logSkud('Контроллер инициализирован, клеммы опрошены');
  2239. requestAnimationFrame(loop);
  2240. </script>
  2241. </body>
  2242. </html>