Интерактивный веб-проект — симулятор поста охраны КПП с моделью работы шлагбаума.
Запись 1. Общая концепция проекта Задача. Разработать одностраничное интерактивное приложение по теме «Моделирование работы шлагбаума». Пользователь должен выступать в роли оператора КПП: сравнивать пропуска, принимать решение о пропуске или отказе, управлять шлагбаумом. Оформление — служебная система контроля доступа, тёмный интерфейс, без внешних библиотек. Всё в одном HTML-файле.
Решение. Создан каркас: HTML-структура с шапкой (логотип, часы, системный индикатор), основной сценой на Canvas и правой панелью управления. Сцена — псевдо-3D вид на КПП: небо с градиентом, здания, две полосы движения, будка охраны, охранник, шлагбаум с красно-белой стрелой. Стилистика выдержана в тёмных тонах с янтарными и голубыми акцентами, шрифт Courier New.
Запись 2. Системные часы и индикатор состояния Задача. Добавить часы реального времени в шапке и цветной индикатор состояния поста, который меняет цвет и текст в зависимости от ситуации.
Решение. Часы обновляются через setInterval каждую секунду. Индикатор реализован как переключаемый класс: «ПОСТ СВОБОДЕН» (зелёный), «ПРИБЛИЖАЕТСЯ АВТО» / «ОЖИДАНИЕ РЕШЕНИЯ» (жёлтый), «МАШИНА ЖДЁТ» (синий), «СТАРШИЙ ВЫЗВАН» (голубой), «НАРУШИТЕЛЬ !» / «ТРЕВОГА !» (красный, мигающий).
function updateClock() { const d = new Date(); document.getElementById('clock').textContent =
String(d.getHours()).padStart(2,'0') + ':' +
String(d.getMinutes()).padStart(2,'0') + ':' +
String(d.getSeconds()).padStart(2,'0');
} setInterval(updateClock, 1000);
function setStatus(text, cls) { document.getElementById('sysStatusText').textContent = text; document.getElementById('sysStatus').className = 'sys-status' + (cls ? ' ' + cls : ''); }
Запись 3. Сравнение пропусков Задача. Сделать две карточки для сравнения: эталонный пропуск «В БАЗЕ» и предъявленный водителем. Пропуска генерируются случайно: иногда совпадают, иногда отличаются номером, иногда предъявленный отозван.
Решение. Функция randomPass() генерирует случайный пропуск (номер, ФИО, подразделение, признак valid). Функция generateScenario() определяет, совпадает ли предъявленный с эталонным, и должно ли решение оператора быть «разрешить» или «запретить». Карточки рисуются в правой панели.
function randomPass() { return {
num: series[Math.floor(Math.random()*series.length)] + '-' +
String(Math.floor(Math.random()*900)+100),
name: firstNames[Math.floor(Math.random()*firstNames.length)] + ' ' +
initials[Math.floor(Math.random()*initials.length)],
dept: departments[Math.floor(Math.random()*departments.length)],
valid: Math.random() > 0.25
}; }
Запись 4. Анимация шлагбаума Задача. Шлагбаум должен плавно открываться и закрываться, с реалистичной красно-белой стрелой, лампой-индикатором и вращающейся осью. Угол стрелы — от 0 (закрыт) до 90° (открыт).
Решение. Уровень шлагбаума хранится в state.barrier.level от 0 до 1 и плавно интерполируется к target. Стрела рисуется через ctx.translate/rotate — угол равен Math.PI + level * Math.PI/2. Лампа на верху столба меняет цвет: красный при закрытом, жёлтый при движении, зелёный при открытом. Стрела состоит из 14 чередующихся сегментов.
const angle = Math.PI + level * Math.PI / 2; ctx.save(); ctx.translate(pivotX, pivotY); ctx.rotate(angle); const seg = 14; for (let i = 0; i < seg; i++) { ctx.fillStyle = i % 2 === 0 ? '#e04040' : '#e8e8e8'; ctx.fillRect(i * (armLen / seg), -armH/2, armLen / seg, armH); } ctx.restore();
Запись 5. Движение автомобилей Задача. Реализовать несколько типов машин: обычный легковой автомобиль и тёмный тонированный автомобиль для сценария «силовой проезд». Машины подъезжают к шлагбауму, останавливаются, ждут решения оператора, потом проезжают или разворачиваются.
Решение. Нарисованы две процедурные машины на Canvas. Легковой автомобиль — цветной, с градиентом, фарами и стёклами. Тонированный автомобиль — тёмный, с угловатыми фарами, решёткой радиатора, серебряными пятиспицевыми дисками. Колёса вращаются вместе с движением.
function drawCarScaled(drawFn, x, y, ...args) { ctx.save(); ctx.translate(x, y); ctx.scale(CAR_SCALE, CAR_SCALE); ctx.translate(-x, -y); drawFn(x, y, ...args); ctx.restore(); }
Запись 6. Сценарий силового проезда и столкновения Задача. Добавить сценарий, в котором машина не сбрасывает скорость и врезается в закрытый шлагбаум. При столкновении — искры, тревога, приезд полиции и эвакуатора.
Решение. Кнопка «Силовой проезд» запускает тонированный автомобиль со скоростью в два раза выше обычной. Если шлагбаум закрыт — срабатывает функция crash(): красная пульсирующая рамка, надпись «!!! ТРЕВОГА !!!», 40 частиц искр. Через 700 мс приезжает полицейский автомобиль с мигалками, через 1400 мс — эвакуатор с водителем.
function crash() { logEvent('!!! СТОЛКНОВЕНИЕ СО ШЛАГБАУМОМ !!!', 'alarm'); setStatus('ТРЕВОГА !', 'alarm'); state.alarm = true; state.alarmEnd = state.time + 5; state.phase = 'crash_scene'; state.stats.alarms++; state.stats.breaches++; for (let i = 0; i < 40; i++) {
state.sparks.push({
x: l.pivotX - l.armLen - 30, y: l.topLaneY - 40,
vx: (Math.random() - .5) * 400, vy: (Math.random() - .5) * 300 - 80,
life: 1
});
} }
Запись 7. Работа водителя эвакуатора Задача. Водитель эвакуатора должен выйти из кабины, дойти до машины, прицепить её тросом, вернуться в кабину и увезти. Всё это — с анимацией ходьбы.
Решение. Реализован конечный автомат из 9 состояний: exiting → walking_out_down → walking_right → walking_up → attaching → returning_down → returning_left → returning_up → boarding → gone. Персонаж нарисован детально: оранжевый жилет со светоотражающими полосами, каска, трос крепится квадратичной кривой Безье.
if (td.state === 'walking_out_down') { if (td.y < underY - 2) { td.y += SPD * dt; td.walking = true; } else { td.y = underY; td.state = 'walking_right'; } } else if (td.state === 'attaching') { td.attachTimer += dt; td.walking = false; if (td.attachTimer > 0.6 && !state.towCable) {
state.towCable = true;
logEvent('Автомобиль зацеплен', 'info');
} }
Запись 8. Старший смены Задача. При отказе в проезде оператор может вызвать старшего смены. Старший должен прийти к машине, проверить документы и самостоятельно принять верное решение.
Решение. По кнопке «Вызвать старшего» создаётся объект state.senior с координатами за правым краем экрана. Он идёт к машине по нижней полосе, затем поднимается к верхней, стоит 2 секунды, «проверяет документы» и выносит верный вердикт. Отдельная функция drawSenior() рисует фигуру в форме охраны — тёмно-синий китель, фуражка, погоны.
Запись 9. Погода и время суток Задача. Добавить переключение времени суток (день/ночь) и погоды (ясно, дождь, снег, туман). Освещение, цвета неба, поведение машин и видимость должны меняться.
Решение. Каждой комбинации соответствует свой режим освещения: day, dawn, dusk, overcast, night, wetnight, snownight. Функция skyColors() возвращает цвет неба, drawWeatherParticles() — рисует дождь, снег или туман. Дождь и снег снижают скорость машин через speedMult(): дождь — 0.75, снег — 0.6, туман — 0.85. Ночью включается ИК-режим камеры.
function speedMult() { if (state.weather === 'rain') return 0.75; if (state.weather === 'snow') return 0.6; if (state.weather === 'fog') return 0.85; return 1; }
Запись 10. Журнал событий Задача. Все действия в системе должны фиксироваться в журнале с отметкой времени и цветовой кодировкой.
Решение. Массив logEntries хранит до 80 последних записей. Каждая запись имеет тип: info (голубой), ok (зелёный), warn (жёлтый), alarm (красный), senior (голубой жирный). Журнал автоматически прокручивается вниз.
function logEvent(text, cls) { const now = new Date(); const t = String(now.getHours()).padStart(2,'0') + ':' +
String(now.getMinutes()).padStart(2,'0') + ':' +
String(now.getSeconds()).padStart(2,'0');
logEntries.push({ t, text, cls: cls || 'info' }); if (logEntries.length > 80) logEntries.shift(); renderLog(); }
Запись 11. Отчёт о смене Задача. В конце работы оператор нажимает «Закончить смену» — должно открыться модальное окно с итогами: сколько впущено, отказано, верных решений, ошибок, тревог, силовых проездов, вызовов старшего. Оценка — по проценту верных решений.
Решение. Собраны все счётчики в state.stats. Оценка рассчитывается: ≥ 95% — «ОТЛИЧНО», ≥ 80% — «ХОРОШО», ≥ 60% — «УДОВЛЕТВОРИТЕЛЬНО», иначе — «ТРЕБУЕТСЯ ПОДГОТОВКА».
const pct = s.correct / total; if (pct >= 0.95) grade = 'ОТЛИЧНО'; else if (pct >= 0.8) grade = 'ХОРОШО'; else if (pct >= 0.6) grade = 'УДОВЛЕТВОРИТЕЛЬНО'; else grade = 'ТРЕБУЕТСЯ ПОДГОТОВКА';
Запись 12. Отметка в журнале вручную Задача. Дать оператору возможность добавить собственную запись в журнал.
Решение. Кнопка «✎ Отметить в журнале» вызывает prompt, введённый текст добавляется в журнал с пометкой [ОПЕРАТОР].
document.getElementById('btnNote').onclick = () => { const txt = prompt('Введите отметку для журнала:', ''); if (txt && txt.trim()) logEvent('[ОПЕРАТОР] ' + txt.trim(), 'info'); };
Запись 13. Чёткость Canvas Задача. Картинка на экране размытая, особенно на Retina-дисплеях. Сделать рендер чётким.
Решение. Внутренний размер канваса увеличивается пропорционально devicePixelRatio, а рендер идёт в логических координатах через ctx.setTransform(). Это убирает размытие и сохраняет единую систему координат.
function resizeCanvas() { const stage = cv.parentElement; const dpr = window.devicePixelRatio || 1; W = stage.clientWidth; H = stage.clientHeight; cv.width = Math.floor(W * dpr); cv.height = Math.floor(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener('resize', resizeCanvas);
Запись 14. Сброс состояния Задача. Добавить кнопку полного сброса — вернуть систему в исходное состояние, очистить журнал, убрать все машины и объекты.
Решение. Функция resetAll() обнуляет машину, шлагбаум, полицию, эвакуатор, старшего, очищает журнал, перерисовывает карточки пропусков.
function resetAll() { state.car = null; state.correctPass = null; state.presentedPass = null; state.userDecision = null; state.correctDecision = null; state.phase = 'idle'; state.barrier.target = 0; state.barrier.level = 0; state.alarm = false; state.sparks = []; state.police = null; state.tow = null; state.towDriver = null; state.towCable = false; state.senior = null; logEntries.length = 0; renderLog(); renderPasses(); setStatus('ПОСТ СВОБОДЕН'); setHint('Нажмите «Следующая машина».'); }
Запись 15. Единый игровой цикл Задача. Обеспечить плавную работу всех анимаций с привязкой к реальному времени.
Решение. Один requestAnimationFrame с delta-time (не более 0.05 с на кадр). Отдельно обновляется сцена (updateScene) и отрисовывается (drawScene). Состояние игры хранится в одном объекте state.
function loop(ts) { if (!lastTs) lastTs = ts; const dt = Math.min(0.05, (ts - lastTs) / 1000); lastTs = ts; state.time += dt; updateScene(dt); drawScene(); requestAnimationFrame(loop); } requestAnimationFrame(loop); новые правки:
html
html
css .model-switcher { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 10px 12px; } .model-btn { background: linear-gradient(180deg, #10161e, #0a0e14); border: 1px solid #2a3442; border-radius: 3px; padding: 8px 8px; cursor: pointer; transition: all .15s; font-family: inherit; color: #8090a8; text-align: left; font-size: 10.5px; line-height: 1.35; } .model-btn:hover { border-color: #4080c0; background: #0f1822; color: #c0d0e0; } .model-btn.active { border-color: #40d080; color: #c0f0d0; background: linear-gradient(180deg, #10201a, #0a1a14); box-shadow: 0 0 12px rgba(64,208,128,.25), inset 0 0 8px rgba(64,208,128,.08); } .model-btn .mb-code { display: block; color: #ffd060; font-weight: bold; font-size: 12px; letter-spacing: 1.5px; margin-bottom: 2px; } .model-btn .mb-name { display: block; color: #80e0ff; font-size: 10px; margin-bottom: 3px; } .model-btn .mb-spec { display: block; color: #506080; font-size: 9.5px; } .model-btn.active .mb-spec { color: #80a0c0; } 1.3. Логика переключения js function renderModelSwitcher() { const el = document.getElementById('modelSwitcher'); el.innerHTML = Object.values(BARRIER_MODELS).map(m => `
<button class="model-btn ${m.code===selectedModelCode?'active':''}" data-model="${m.code}">
<span class="mb-code">МОДЕЛЬ ${m.code}</span>
<span class="mb-name">${m.name}</span>
<span class="mb-spec">▸ ${m.openTime}с/${m.closeTime}с · стрела ${m.armLen}</span>
</button>`).join('');
el.querySelectorAll('.model-btn').forEach(b => {
b.onclick = () => switchModel(b.dataset.model);
}); }
function switchModel(code) { if (!BARRIER_MODELS[code]) return; selectedModelCode = code; state.model = BARRIER_MODELS[code]; document.getElementById('modelBadge').textContent = 'МОДЕЛЬ: ' + state.model.code + ' · ' + state.model.name; document.getElementById('btnHold').style.display = state.model.hasHold ? '' : 'none'; document.getElementById('btnHold').classList.remove('active'); state.holdMode = false; state.skud.kw1 = state.barrier.level < 0.05; state.skud.kw2 = state.barrier.level > 0.95; if (!state.model.hasLoop) state.skud.loop1 = false; if (!state.model.hasPhoto) state.skud.photo = false; if (!state.model.hasImpact) state.skud.impact = false; renderModelSwitcher(); logSkud('▸ ПЕРЕКЛЮЧЕНИЕ МОДЕЛИ: ' + state.model.code + ' · ' + state.model.name, 'warn'); logSkud('Открытие/закрытие: ' + state.model.openTime + 'с / ' + state.model.closeTime + 'с'); logSkud('Автозакрытие: ' + (state.model.autoClose ? state.model.autoClose + ' с' : 'отключено')); setHint('Модель ' + state.model.code + ' активна. Нажмите «Следующая машина».'); } 1.4. Запуск Было: renderModelList(); + ожидание кнопки «ПРИНЯТЬ КОНФИГУРАЦИЮ». Стало:
js renderModelSwitcher(); switchModel('B');
// Звёзды + луна if (isStarVisible()) {
for (let i = 0; i < 90; i++) {
const x = (i * 173.5) % W, y = (i * 97.3) % (l.horizonY - 15);
const a = (0.15 + 0.4 * Math.abs(Math.sin(state.time * 0.5 + i))) *
(state.weather === 'clear' ? 1 : 0.35);
ctx.fillStyle = `rgba(200,220,255,${a})`;
ctx.fillRect(x, y, 1.5, 1.5);
}
const mx = W * 0.78, my = l.horizonY * 0.30;
const moonG = ctx.createRadialGradient(mx, my, 3, mx, my, 55);
moonG.addColorStop(0, 'rgba(220,230,255,.55)');
moonG.addColorStop(0.4, 'rgba(180,200,230,.10)');
moonG.addColorStop(1, 'rgba(180,200,230,0)');
ctx.fillStyle = moonG; ctx.fillRect(mx - 60, my - 60, 120, 120);
ctx.beginPath(); ctx.arc(mx, my, 12, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(240,240,220,.85)'; ctx.fill();
ctx.beginPath(); ctx.arc(mx - 4, my - 3, 10, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(230,235,220,.75)'; ctx.fill();
}
// Солнце + божественные лучи if (isSunVisible()) {
const isDawn = lighting().mode === 'dawn';
const sy = l.horizonY * (isDawn ? 0.55 : 0.32);
const sx = W * 0.22;
const sg = ctx.createRadialGradient(sx, sy, 6, sx, sy, 130);
sg.addColorStop(0, isDawn ? 'rgba(255,220,180,.9)' : 'rgba(255,250,210,.95)');
sg.addColorStop(0.3, isDawn ? 'rgba(255,200,140,.25)' : 'rgba(255,240,180,.25)');
sg.addColorStop(1, 'rgba(255,240,180,0)');
ctx.fillStyle = sg; ctx.fillRect(sx - 140, sy - 140, 280, 280);
ctx.beginPath(); ctx.arc(sx, sy, 22, 0, Math.PI * 2);
ctx.fillStyle = isDawn ? 'rgba(255,220,180,.95)' : 'rgba(255,252,220,.98)'; ctx.fill();
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < 8; i++) {
const a = -0.4 + i * 0.1 + Math.sin(state.time * 0.3 + i) * 0.02;
const len = 180 + (i % 3) * 40;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + Math.cos(a) * len, sy + Math.sin(a) * len);
ctx.lineTo(sx + Math.cos(a + 0.04) * len, sy + Math.sin(a + 0.04) * len);
ctx.closePath();
ctx.fillStyle = isDawn ? 'rgba(255,220,180,.06)' : 'rgba(255,250,220,.05)';
ctx.fill();
}
ctx.restore();
}
// Силуэты зданий в 2 слоя + окна const m = lighting().mode; let bcol = '#0a1220'; if (m === 'day') bcol = '#3a4a5a'; else if (m === 'overcast') bcol = '#454e58'; else if (m === 'dawn') bcol = '#3a4050'; else if (m === 'dusk') bcol = '#1c1a28'; ctx.fillStyle = bcol; ctx.globalAlpha = 0.55; for (let i = 0; i < 30; i++) {
const bx = i * (W / 30), bw = 20 + (i % 5) * 14, bh = 20 + (i % 7) * 12;
ctx.fillRect(bx, l.horizonY - bh, bw, bh);
} ctx.globalAlpha = 1; for (let i = 0; i < 24; i++) {
const bx = i * (W / 24), bw = 18 + (i % 4) * 12, bh = 30 + (i % 5) * 12;
ctx.fillRect(bx, l.horizonY - bh, bw, bh);
if (isStarVisible() && (i * 13) % 5 === 0) {
ctx.fillStyle = 'rgba(255,200,120,.35)';
ctx.fillRect(bx + 4, l.horizonY - bh + 8, 3, 3);
ctx.fillRect(bx + 12, l.horizonY - bh + 20, 3, 3);
ctx.fillStyle = bcol;
}
} } 2.2. Дымка у горизонта В конец drawGround():
js const haze = ctx.createLinearGradient(0, l.horizonY - 4, 0, l.horizonY + 30); haze.addColorStop(0, 'rgba(80,110,140,.35)'); haze.addColorStop(1, 'rgba(80,110,140,0)'); ctx.fillStyle = haze; ctx.fillRect(0, l.horizonY - 4, W, 34);
js function isWet() { return state.weather === 'rain'; } В конец drawRoads():
js if (isWet()) { ctx.fillStyle = 'rgba(120,150,200,.06)'; ctx.fillRect(0, l.topLaneTop, W, l.topLaneBot - l.topLaneTop); ctx.fillRect(0, l.botLaneTop, W, l.botLaneBot - l.botLaneTop); if (state.car) {
const cx = state.car.x;
const refl = ctx.createLinearGradient(cx, 0, cx, l.topLaneBot);
refl.addColorStop(0, 'rgba(255,240,180,.22)');
refl.addColorStop(1, 'rgba(255,240,180,0)');
ctx.fillStyle = refl;
ctx.fillRect(cx - 90, l.topLaneTop, 180, l.topLaneBot - l.topLaneTop);
} }
js // корпус с градиентом const wg = ctx.createLinearGradient(bx, 0, bx + bw, 0); if (bright) { wg.addColorStop(0, '#6a7482'); wg.addColorStop(0.5, '#8a94a4'); wg.addColorStop(1, '#4a5462'); } else { wg.addColorStop(0, '#2a3442'); wg.addColorStop(0.5, '#4a5a6a'); wg.addColorStop(1, '#182028'); } ctx.fillStyle = wg; ctx.fillRect(bx, by - bh, bw, bh); ctx.strokeStyle = '#4a5a70'; ctx.lineWidth = 2; ctx.strokeRect(bx, by - bh, bw, bh);
// блик на козырьке ctx.fillStyle = 'rgba(255,255,255,.08)'; ctx.fillRect(bx - 12, by - bh - 14, bw + 24, 4);
// ОКНО с тёплым светом + блик + переплёт
const glow = bright ? 0.4 : 0.8 + 0.15 * Math.sin(state.time * 1.5);
ctx.fillStyle = rgba(255,220,140,${glow});
ctx.fillRect(bx + 15, by - bh + 25, 60, 60);
const wgl = ctx.createLinearGradient(bx + 15, by - bh + 25, bx + 75, by - bh + 85);
wgl.addColorStop(0, 'rgba(255,255,255,.28)');
wgl.addColorStop(0.5, 'rgba(255,255,255,0)');
wgl.addColorStop(1, 'rgba(255,255,255,.10)');
ctx.fillStyle = wgl; ctx.fillRect(bx + 15, by - bh + 25, 60, 60);
ctx.strokeStyle = '#4a5a70'; ctx.strokeRect(bx + 15, by - bh + 25, 60, 60);
ctx.beginPath(); ctx.moveTo(bx + 45, by - bh + 25); ctx.lineTo(bx + 45, by - bh + 85); ctx.stroke();
ctx.beginPath(); ctx.moveTo(bx + 15, by - bh + 55); ctx.lineTo(bx + 75, by - bh + 55); ctx.stroke();
const oGlow = ctx.createRadialGradient(bx + 45, by - bh + 55, 8, bx + 45, by - bh + 55, 60);
oGlow.addColorStop(0, rgba(255,220,140,${0.18 * glow + 0.08}));
oGlow.addColorStop(1, 'rgba(255,220,140,0)');
ctx.fillStyle = oGlow; ctx.fillRect(bx + 15 - 60, by - bh + 25 - 60, 180, 180);
// ручка с бликом ctx.fillStyle = '#e8c070'; ctx.beginPath(); ctx.arc(bx + bw - 40, by - bh/2 - 5, 2.6, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,.5)'; ctx.beginPath(); ctx.arc(bx + bw - 41, by - bh/2 - 6, 1.1, 0, Math.PI * 2); ctx.fill();
js // основание с бликом ctx.fillStyle = 'rgba(255,255,255,.06)'; ctx.fillRect(pillarX - 10, groundBase - 10, pillarW + 20, 3);
// столб — металл const g = ctx.createLinearGradient(pillarX, 0, pillarX + pillarW, 0); if (bright) { g.addColorStop(0, '#3a4858'); g.addColorStop(0.35, '#7a8898'); g.addColorStop(0.6, '#5a6878'); g.addColorStop(1, '#2a3442'); } else { g.addColorStop(0, '#1a2028'); g.addColorStop(0.35, '#4a5a6a'); g.addColorStop(0.6, '#2a3442'); g.addColorStop(1, '#0a1018'); } ctx.fillStyle = g; ctx.fillRect(pillarX, pillarTopY, pillarW, pillarH); ctx.strokeStyle = '#000'; ctx.lineWidth = 1.5; ctx.strokeRect(pillarX, pillarTopY, pillarW, pillarH);
// вертикальный блик const shine = ctx.createLinearGradient(pillarX + 4, 0, pillarX + 12, 0); shine.addColorStop(0, 'rgba(255,255,255,.18)'); shine.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = shine; ctx.fillRect(pillarX + 4, pillarTopY + 4, 8, pillarH - 8);
// блик на полосах ctx.fillStyle = 'rgba(255,220,140,.12)'; ctx.fillRect(pillarX, pillarTopY + pillarH - 90, pillarW, 80);
// блик на козырьке ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(pillarX, pillarTopY - 12, pillarW, 3);
// ЛАМПА с радиальным свечением const blink = Math.sin(state.time * 4) > 0; const lampColor = level > 0.9 ? '#40d080' : (level > 0.1 ? '#ffb040' : '#ff2020'); const lampX = pillarX + pillarW/2, lampY = pillarTopY - 26; if (blink) { const lampGlow = ctx.createRadialGradient(lampX, lampY, 3, lampX, lampY, 40); lampGlow.addColorStop(0, lampColor + 'cc'); lampGlow.addColorStop(0.4, lampColor + '44'); lampGlow.addColorStop(1, lampColor + '00'); ctx.fillStyle = lampGlow; ctx.fillRect(lampX - 44, lampY - 44, 88, 88); } ctx.beginPath(); ctx.arc(lampX, lampY, 9, 0, Math.PI * 2); const lampGrad = ctx.createRadialGradient(lampX - 3, lampY - 3, 2, lampX, lampY, 9); if (blink) { lampGrad.addColorStop(0, '#ffffff'); lampGrad.addColorStop(0.4, lampColor); lampGrad.addColorStop(1, lampColor); } else { lampGrad.addColorStop(0, '#3a3a3a'); lampGrad.addColorStop(1, '#1a1a1a'); } ctx.fillStyle = lampGrad; ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.arc(lampX - 3, lampY - 3, 2.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,.6)'; ctx.fill();
// ось с бликом const hg = ctx.createRadialGradient(pivotX - 2, pivotY - 2, 1, pivotX, pivotY, 6); hg.addColorStop(0, '#c8d4e0'); hg.addColorStop(1, '#506070'); ctx.fillStyle = hg; ctx.fill(); ctx.beginPath(); ctx.arc(pivotX - 3, pivotY - 3, 2, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,.5)'; ctx.fill();
// СТРЕЛА const angle = Math.PI + level * Math.PI / 2; ctx.save(); ctx.translate(pivotX, pivotY); ctx.rotate(angle); const seg = 14; for (let i = 0; i < seg; i++) { ctx.fillStyle = i % 2 === 0 ? '#e04040' : '#e8e8e8'; ctx.fillRect(i * (armLen / seg), -armH/2, armLen / seg, armH); } ctx.fillStyle = 'rgba(0,0,0,.35)'; ctx.fillRect(0, armH/2 - 2, armLen, 2); ctx.fillStyle = 'rgba(255,255,255,.4)'; ctx.fillRect(0, -armH/2, armLen, 2); ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.strokeRect(0, -armH/2, armLen, armH); ctx.beginPath(); ctx.arc(armLen, 0, 12, 0, Math.PI * 2); const ng = ctx.createRadialGradient(armLen - 3, -3, 2, armLen, 0, 12); ng.addColorStop(0, '#ffffff'); ng.addColorStop(1, '#a8b0b8'); ctx.fillStyle = ng; ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.stroke(); ctx.restore();
// светодиод на конце стрелы if (level < 0.9) { const endX = pivotX + Math.cos(angle) * armLen; const endY = pivotY + Math.sin(angle) * armLen; const blinkE = Math.sin(state.time * 6) > 0; if (blinkE) {
const eGlow = ctx.createRadialGradient(endX, endY, 2, endX, endY, 26);
eGlow.addColorStop(0, 'rgba(255,64,64,.55)');
eGlow.addColorStop(1, 'rgba(255,64,64,0)');
ctx.fillStyle = eGlow; ctx.fillRect(endX - 28, endY - 28, 56, 56);
} ctx.beginPath(); ctx.arc(endX, endY, 6, 0, Math.PI * 2); ctx.fillStyle = blinkE ? '#ff4040' : '#601010'; ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.stroke(); }
js if (state.model && state.model.hasLoop) { const loopX = l.pivotX - 280; const loopY = l.topLaneY + 10; const loopW = 200, loopH = 18; ctx.strokeStyle = state.skud.loop1 ? 'rgba(96,180,255,.85)' : 'rgba(96,160,224,.35)'; ctx.lineWidth = 2; ctx.strokeRect(loopX, loopY, loopW, loopH); if (state.skud.loop1) {
ctx.fillStyle = 'rgba(96,180,255,.18)';
ctx.fillRect(loopX, loopY, loopW, loopH);
const lg = ctx.createRadialGradient(loopX + loopW/2, loopY + loopH/2, 5, loopX + loopW/2, loopY + loopH/2, loopW/2);
lg.addColorStop(0, 'rgba(120,200,255,.18)');
lg.addColorStop(1, 'rgba(120,200,255,0)');
ctx.fillStyle = lg;
ctx.fillRect(loopX - 30, loopY - 30, loopW + 60, loopH + 60);
} } if (state.model && state.model.hasPhoto) { const px1 = l.pivotX - state.model.armLen - 5; const px2 = l.pivotX + 55; const py = l.topLaneY - 4; ctx.fillStyle = '#304050'; ctx.fillRect(px1 - 3, py - 44, 6, 44); ctx.fillRect(px2 - 3, py - 44, 6, 44); ctx.strokeStyle = '#506070'; ctx.lineWidth = 1; ctx.strokeRect(px1 - 3, py - 44, 6, 44); ctx.strokeRect(px2 - 3, py - 44, 6, 44); const on = !state.skud.photo; const dCol = on ? '#40d080' : '#ff4040'; if (on) {
const dg = ctx.createRadialGradient(px1, py - 36, 2, px1, py - 36, 16);
dg.addColorStop(0, dCol + 'aa'); dg.addColorStop(1, dCol + '00');
ctx.fillStyle = dg;
ctx.fillRect(px1 - 18, py - 54, 36, 36);
ctx.fillRect(px2 - 18, py - 54, 36, 36);
} ctx.fillStyle = dCol; ctx.beginPath(); ctx.arc(px1, py - 36, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(px2, py - 36, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(px1, py - 36, 3.5, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(px2, py - 36, 3.5, 0, Math.PI * 2); ctx.stroke(); if (on) {
ctx.strokeStyle = 'rgba(64,208,128,.6)';
ctx.setLineDash([4,4]); ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(px1, py - 36); ctx.lineTo(px2, py - 36); ctx.stroke();
ctx.setLineDash([]);
} else {
ctx.strokeStyle = 'rgba(255,64,64,.5)';
ctx.setLineDash([2,6]);
ctx.beginPath(); ctx.moveTo(px1, py - 36); ctx.lineTo(px2, py - 36); ctx.stroke();
ctx.setLineDash([]);
} }
js function drawWheels(x, y, dxRear, dxFront, radius, rim, angle) { [x - dxRear, x + dxFront].forEach(wx => {
ctx.beginPath(); ctx.arc(wx, y, radius, 0, Math.PI * 2);
const tg = ctx.createRadialGradient(wx - radius*0.3, y - radius*0.3, 1, wx, y, radius);
tg.addColorStop(0, '#2a2a2a'); tg.addColorStop(1, '#000');
ctx.fillStyle = tg; ctx.fill();
ctx.beginPath(); ctx.arc(wx, y, radius * 0.62, 0, Math.PI * 2);
ctx.fillStyle = '#1a2028'; ctx.fill();
ctx.strokeStyle = rim; ctx.lineWidth = 1.5; ctx.stroke();
ctx.save(); ctx.translate(wx, y); ctx.rotate(angle);
ctx.strokeStyle = rim; ctx.lineWidth = 1.4; ctx.lineCap = 'round';
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2;
ctx.beginPath(); ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a) * radius * 0.5, Math.sin(a) * radius * 0.5); ctx.stroke();
}
ctx.beginPath(); ctx.arc(0, 0, radius * 0.16, 0, Math.PI * 2);
ctx.fillStyle = rim; ctx.fill();
ctx.strokeStyle = '#303840'; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
ctx.beginPath(); ctx.arc(wx - radius*0.35, y - radius*0.35, radius*0.18, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,.10)'; ctx.fill();
}); } 7.2. Седан — блики, градиентные стёкла, линия двери Заменить drawSedan() на:
js function drawSedan(x, y, color, wheelAngle) { ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.beginPath(); ctx.ellipse(x, y + 4, 92, 7, 0, 0, Math.PI * 2); ctx.fill();
const bg = ctx.createLinearGradient(0, y - 55, 0, y); bg.addColorStop(0, color); bg.addColorStop(0.55, color); bg.addColorStop(1, '#050505'); ctx.fillStyle = bg; ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x + 85, y - 8); ctx.lineTo(x + 78, y - 26); ctx.lineTo(x + 40, y - 30); ctx.lineTo(x + 15, y - 52); ctx.lineTo(x - 25, y - 54); ctx.lineTo(x - 55, y - 42); ctx.lineTo(x - 78, y - 24); ctx.lineTo(x - 85, y - 8); ctx.lineTo(x - 85, y); ctx.lineTo(x + 85, y); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,.18)'; ctx.beginPath(); ctx.moveTo(x - 70, y - 22); ctx.lineTo(x + 70, y - 22); ctx.lineTo(x + 65, y - 26); ctx.lineTo(x - 65, y - 26); ctx.closePath(); ctx.fill();
const gk = ctx.createLinearGradient(0, y - 54, 0, y - 30); gk.addColorStop(0, 'rgba(90,140,180,.85)'); gk.addColorStop(1, 'rgba(30,60,90,.75)'); ctx.fillStyle = gk; ctx.beginPath(); ctx.moveTo(x + 12, y - 50); ctx.lineTo(x - 22, y - 52); ctx.lineTo(x - 48, y - 40); ctx.lineTo(x - 48, y - 30); ctx.lineTo(x + 12, y - 30); ctx.closePath(); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,.20)'; ctx.beginPath(); ctx.moveTo(x + 8, y - 48); ctx.lineTo(x - 18, y - 50); ctx.lineTo(x - 30, y - 44); ctx.lineTo(x - 20, y - 44); ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,.4)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - 8, y - 30); ctx.lineTo(x - 8, y - 8); ctx.stroke();
ctx.fillStyle = '#fff8c0'; ctx.shadowColor = '#fff8c0'; ctx.shadowBlur = 18; ctx.beginPath(); ctx.arc(x + 80, y - 14, 4.5, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = 'rgba(255,255,255,.9)'; ctx.beginPath(); ctx.arc(x + 79, y - 15, 1.8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#c02020'; ctx.shadowColor = '#c02020'; ctx.shadowBlur = 10; ctx.fillRect(x - 85, y - 22, 4, 10); ctx.shadowBlur = 0; ctx.fillStyle = 'rgba(255,140,140,.7)'; ctx.fillRect(x - 84, y - 21, 2, 3);
drawWheels(x, y, 55, 55, 14, '#a0a8b0', wheelAngle); } 7.3. Тёмный автомобиль — хром, двойной блик, решётка Заменить drawDarkCar() — ключевые вставки:
js const bg = ctx.createLinearGradient(0, y - 58, 0, y); bg.addColorStop(0, '#3a3a3a'); bg.addColorStop(0.25, '#1a1a1a'); bg.addColorStop(0.7, '#050505'); bg.addColorStop(1, '#000'); ctx.fillStyle = bg; ctx.strokeStyle = '#000'; ctx.lineWidth = 2; // ... полигон без изменений ...
// хромированная линия ctx.strokeStyle = 'rgba(220,230,240,.7)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x - 100, y - 22); ctx.lineTo(x + 100, y - 22); ctx.stroke(); ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - 100, y - 23); ctx.lineTo(x + 100, y - 23); ctx.stroke();
// тонированные стёкла с двойным бликом const gk = ctx.createLinearGradient(0, y - 54, 0, y - 28); gk.addColorStop(0, 'rgba(20,40,60,.95)'); gk.addColorStop(0.5, 'rgba(5,10,20,.95)'); gk.addColorStop(1, 'rgba(2,4,8,.95)'); ctx.fillStyle = gk; /* ... полигоны стёкол ... / ctx.fillStyle = 'rgba(120,180,220,.35)'; / ... блик на стекле ... */ 7.4. Полицейский — реалистичное свечение мигалок Заменить блок мигалок в drawPoliceCar() на:
js const blink = Math.sin(state.time * 12) > 0; ctx.fillStyle = '#1a1a1a'; ctx.fillRect(x - 30, y - 60, 60, 5); if (blink) { const rg = ctx.createRadialGradient(x - 15, y - 68, 4, x - 15, y - 68, 50); rg.addColorStop(0, 'rgba(255,50,50,.9)'); rg.addColorStop(1, 'rgba(255,50,50,0)'); ctx.fillStyle = rg; ctx.fillRect(x - 65, y - 118, 100, 100); } ctx.fillStyle = blink ? '#ff2020' : '#401010'; ctx.beginPath(); ctx.moveTo(x - 30, y - 60); ctx.lineTo(x, y - 60); ctx.lineTo(x, y - 70); ctx.lineTo(x - 30, y - 70); ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.stroke();
if (!blink) { const bg2 = ctx.createRadialGradient(x + 15, y - 68, 4, x + 15, y - 68, 50); bg2.addColorStop(0, 'rgba(50,120,255,.9)'); bg2.addColorStop(1, 'rgba(50,120,255,0)'); ctx.fillStyle = bg2; ctx.fillRect(x - 35, y - 118, 100, 100); } ctx.fillStyle = !blink ? '#2060ff' : '#101040'; ctx.beginPath(); ctx.moveTo(x, y - 60); ctx.lineTo(x + 30, y - 60); ctx.lineTo(x + 30, y - 70); ctx.lineTo(x, y - 70); ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#000'; ctx.stroke();
js const fg = ctx.createRadialGradient(x - 3, y - 83, 2, x, y - 81, 9); fg.addColorStop(0, '#e8b890'); fg.addColorStop(1, '#b08868'); ctx.fillStyle = fg; ctx.fill(); Градиент на кителе старшего:
js const un = ctx.createLinearGradient(x - 12, 0, x + 12, 0); un.addColorStop(0, '#1a2a4a'); un.addColorStop(0.4, '#2a3a5a'); un.addColorStop(1, '#141e38'); ctx.fillStyle = un; ctx.fillRect(x - 12, y - 70, 24, 38); Кокарда с бликом:
js ctx.fillStyle = '#c0a040'; ctx.beginPath(); ctx.arc(x, y - 96, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,220,140,.7)'; ctx.beginPath(); ctx.arc(x - 0.8, y - 96.8, 1, 0, Math.PI * 2); ctx.fill();
Убрана функция renderModelList() и обработчик btnApplyConfig.
В стартовом блоке добавлено switchModel('B') — модель по умолчанию.
Как запустить Просто открыть index.html в браузере. Все зависимости встроены, внешних библиотек нет.