04 / Canvas / один HTML-файл

Маятник аудитории

Помогает показать точность попадания сообщения в сегмент аудитории.

Маятник аудитории — JavaScript и Canvas
игра маятник JavaScript CanvasБез библиотек

Live preview

Попробуйте механику

Адаптировать
02

Что показывает эта механика

Помогает показать точность попадания сообщения в сегмент аудитории.

Где использовать

B2B, сегментация, рекламные кампании. Для коммерческого проекта меняются правила, визуальный язык, тексты, аналитические события и действие после завершения.

Технологии

Семантический HTML, адаптивный CSS, Canvas API, Pointer Events и requestAnimationFrame. Внешние библиотеки и графические ассеты не требуются.

Полный исходный код / 14 строк
<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f3f0e9"><meta name="robots" content="noindex, nofollow"><title>Маятник аудитории — Lavrushin Interactive</title><style>
:root{--bg:#f3f0e9;--ink:#111;--accent:#ff5a1f;--muted:#6f6b64;--line:rgba(17,17,17,.16)}
*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:var(--bg);color:var(--ink);font-family:Arial,Helvetica,sans-serif;-webkit-text-size-adjust:100%}
body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}main{width:100%;max-width:760px;margin:0 auto;padding:14px}.card{border:1px solid var(--ink);overflow:hidden;background:var(--bg)}
.head{position:relative;padding:20px 18px 16px;border-bottom:1px solid var(--ink)}.kicker{margin:0 0 10px;font-size:10px;letter-spacing:.16em;text-transform:uppercase}.num{position:absolute;right:16px;top:16px;font-size:58px;line-height:.8;font-weight:900;color:var(--accent);opacity:.17;letter-spacing:-.08em}
h1{margin:0;max-width:82%;font-size:clamp(36px,11vw,64px);line-height:.9;letter-spacing:-.055em}.copy{margin:14px 0 0;max-width:620px;font-size:14px;line-height:1.5;color:#34312d}.stage{padding:12px}.canvas-wrap{position:relative;border:1px solid var(--ink);overflow:hidden;background:#fff9}
canvas{display:block;width:100%;height:clamp(360px,58svh,500px);touch-action:none;-webkit-user-select:none;user-select:none}.hud{position:absolute;left:10px;right:10px;top:10px;display:flex;justify-content:space-between;gap:8px;pointer-events:none}.chip{background:var(--bg);border:1px solid var(--ink);padding:7px 9px;font-size:9px;letter-spacing:.08em;text-transform:uppercase;font-weight:800}.status{min-height:62px;padding:13px 2px 8px;font-size:14px;line-height:1.45}.actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.actions .wide{grid-column:1/-1}
button,a.btn{min-height:46px;border:1px solid var(--ink);border-radius:0;background:var(--ink);color:var(--bg);padding:12px 14px;font:800 11px/1 Arial,sans-serif;letter-spacing:.07em;text-transform:uppercase;text-decoration:none;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent}button.secondary{background:transparent;color:var(--ink)}.help{padding:13px 18px;border-top:1px solid var(--ink);font-size:11px;line-height:1.45;color:var(--muted)}.help strong{color:var(--ink)}
@media(min-width:700px){main{padding:24px}.head{padding:24px}.stage{padding:18px}.actions{display:flex;justify-content:flex-end}.actions .wide{grid-column:auto}.status{min-height:48px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
</style></head><body><main><section class="card" aria-labelledby="title"><header class="head"><p class="kicker">Interactive demo / Targeting</p><h1 id="title">Маятник аудитории</h1><div class="num" aria-hidden="true">03</div><p class="copy">Отпустите поисковый запрос в правильный момент. Даже сильная страница бесполезна, если она обращается не к своей аудитории.</p></header><div class="stage"><div class="canvas-wrap"><canvas id="game" aria-label="Игра Маятник аудитории"></canvas><div class="hud"><span class="chip" id="round">Раунд 1 / 5</span><span class="chip" id="score">0 попаданий</span></div></div><div class="status" id="status" aria-live="polite">Отпустите запрос в момент, когда траектория приведёт его в нужный сегмент аудитории.</div><div class="actions"><button id="release">Отпустить</button><button class="secondary" id="reset">Сначала</button><a class="btn wide" href="https://t.me/DMITRIY_LAVRUSHIN" target="_blank" rel="noopener">Обсудить сайт</a></div></div><footer class="help"><strong>Mobile-first:</strong> touch, Pointer Events, safe-area. Без библиотек и внешних ассетов.</footer></section></main><script>(()=>{'use strict';const c=document.getElementById('game'),x=c.getContext('2d'),roundEl=document.getElementById('round'),scoreEl=document.getElementById('score'),status=document.getElementById('status');let W=320,H=420,dpr=1,raf=0,last=0,phase=0,round=0,score=0,state='swing',ball={x:0,y:0,vx:0,vy:0};const rs=[['B2B',.22],['E-COM',.67],['EXPERT',.42],['PRODUCTION',.74],['LOCAL',.31]];let target={x:0,w:82,label:'B2B'};
function track(action,d={}){const p={event:'interactive_game',game:'pendulum',action,...d};window.dataLayer=window.dataLayer||[];window.dataLayer.push(p)}function fit(){const r=c.getBoundingClientRect();W=Math.max(280,Math.round(r.width));H=Math.max(340,Math.round(r.height));dpr=Math.min(2,devicePixelRatio||1);c.width=W*dpr;c.height=H*dpr;x.setTransform(dpr,0,0,dpr,0,0);setTarget();draw()}function setTarget(){const rr=rs[round]||rs[0];target.label=rr[0];target.w=Math.max(76,W*.2);target.x=Math.max(8,Math.min(W-target.w-8,W*rr[1]-target.w/2));roundEl.textContent='Раунд '+(round+1)+' / '+rs.length}function pos(){const L=Math.min(H*.34,W*.36),a=Math.sin(phase)*1.02;return{x:W/2+Math.sin(a)*L,y:55+Math.cos(a)*L,a,L}}
function release(){if(state!=='swing')return;const p=pos(),omega=Math.cos(phase)*1.6;ball={x:p.x,y:p.y,vx:Math.cos(p.a)*p.L*omega,vy:-Math.sin(p.a)*p.L*omega};state='fly';track('release',{round:round+1,audience:target.label})}function next(hit){if(hit)score++;scoreEl.textContent=score+' попаданий';round++;if(round>=rs.length){state='done';status.innerHTML='<strong>'+(score>=4?'Аудитория поймана.':'Нужно точнее.')+'</strong> '+score+' из '+rs.length+' запросов попали в правильный сегмент.';track('complete',{score});return}state='swing';phase=0;setTarget()}
function loop(t){const dt=Math.min(.032,(t-last)/1000||.016);last=t;if(state==='swing')phase+=dt*1.55;if(state==='fly'){ball.vy+=450*dt;ball.x+=ball.vx*dt;ball.y+=ball.vy*dt;if(ball.y>H-30){const hit=ball.x>target.x&&ball.x<target.x+target.w;status.innerHTML=hit?'<strong>Точно.</strong> Запрос попал в свою аудиторию.':'<strong>Мимо.</strong> Даже хороший контент не работает, если он адресован не тому сегменту.';track(hit?'hit':'miss',{audience:target.label});next(hit)}}draw();raf=requestAnimationFrame(loop)}function draw(){x.clearRect(0,0,W,H);x.fillStyle='#f7f4ed';x.fillRect(0,0,W,H);x.strokeStyle='rgba(17,17,17,.1)';for(let q=0;q<W;q+=32){x.beginPath();x.moveTo(q,0);x.lineTo(q,H);x.stroke()}x.fillStyle='#111';x.fillRect(target.x,H-36,target.w,22);x.fillStyle='#f3f0e9';x.font='800 9px Arial';x.textAlign='center';x.fillText(target.label,target.x+target.w/2,H-22);if(state==='swing'){const p=pos();x.strokeStyle='#111';x.lineWidth=2;x.beginPath();x.moveTo(W/2,38);x.lineTo(p.x,p.y);x.stroke();x.fillStyle='#ff5a1f';x.beginPath();x.arc(p.x,p.y,16,0,Math.PI*2);x.fill();x.fillStyle='#fff';x.font='800 8px Arial';x.fillText('QUERY',p.x,p.y+3)}if(state==='fly'){x.fillStyle='#ff5a1f';x.beginPath();x.arc(ball.x,ball.y,16,0,Math.PI*2);x.fill()}if(state==='done'){x.fillStyle='rgba(17,17,17,.84)';x.fillRect(12,H/2-42,W-24,84);x.fillStyle='#f3f0e9';x.font='800 22px Arial';x.fillText(score+' / '+rs.length,W/2,H/2+7)}}function reset(){round=0;score=0;phase=0;state='swing';scoreEl.textContent='0 попаданий';status.textContent='Отпустите запрос в момент, когда траектория приведёт его в нужный сегмент аудитории.';setTarget();track('reset')}
document.getElementById('release').onclick=release;c.addEventListener('pointerdown',e=>{e.preventDefault();release()},{passive:false});document.getElementById('reset').onclick=reset;if('ResizeObserver'in window)new ResizeObserver(fit).observe(c);else addEventListener('resize',fit);requestAnimationFrame(fit);last=performance.now();raf=requestAnimationFrame(loop)})();</script></body></html>