1075 lines
43 KiB
HTML
1075 lines
43 KiB
HTML
<title>Бинго «Баланс 2.0»</title>
|
||
<meta name="claude:viewport-fit-sides" content="cover">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<meta name="referrer" content="no-referrer">
|
||
<meta name="theme-color" content="#12233D">
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2312233D'/%3E%3Crect x='6' y='10' width='17' height='12' rx='3' fill='none' stroke='%23C6F432' stroke-width='2.2'/%3E%3Crect x='24' y='13.5' width='2.5' height='5' rx='1' fill='%23C6F432'/%3E%3Crect x='9' y='13' width='7' height='6' rx='1' fill='%23C6F432'/%3E%3C/svg%3E">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400..800&family=Unbounded:wght@500..900&display=swap">
|
||
<style>
|
||
/* Layout: одна колонка — заголовок и правила, строка недели, опрос, карточка 4×4 с подписями рядов слева, батарейка внизу. */
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #12233D;
|
||
--cell: #1B3150;
|
||
--line: #2A4468;
|
||
--fg: #F2F5FA;
|
||
--muted: #A9B8CC;
|
||
--ink: #12233D;
|
||
--accent: #C6F432;
|
||
--c-body: #FF8A65;
|
||
--c-focus: #7CC4FF;
|
||
--c-bounds: #B9A6FF;
|
||
--c-people: #C6F432;
|
||
--danger: #FF8A8A;
|
||
--display: 'Unbounded', 'Arial Black', 'Segoe UI', system-ui, sans-serif;
|
||
--text: 'Manrope', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
--radius: 20px;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
[hidden] { display: none !important; }
|
||
html { -webkit-text-size-adjust: 100%; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
font-family: var(--text);
|
||
font-size: 16px;
|
||
line-height: 1.45;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
button { font: inherit; color: inherit; }
|
||
:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||
|
||
.page {
|
||
max-width: 1100px;
|
||
margin: 0 auto;
|
||
padding-block: 40px 56px;
|
||
padding-left: max(16px, env(safe-area-inset-left, 0px));
|
||
padding-right: max(16px, env(safe-area-inset-right, 0px));
|
||
display: grid;
|
||
gap: 28px;
|
||
}
|
||
@media (min-width: 720px) {
|
||
.page {
|
||
padding-left: max(32px, env(safe-area-inset-left, 0px));
|
||
padding-right: max(32px, env(safe-area-inset-right, 0px));
|
||
padding-block: 56px 72px;
|
||
gap: 32px;
|
||
}
|
||
}
|
||
|
||
/* ---------- шапка ---------- */
|
||
.hero { display: grid; gap: 18px; }
|
||
.eyebrow {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.14em;
|
||
color: var(--muted);
|
||
}
|
||
h1 {
|
||
margin: 0;
|
||
font-family: var(--display);
|
||
font-weight: 800;
|
||
font-size: clamp(34px, 7vw, 68px);
|
||
line-height: 1.02;
|
||
letter-spacing: -0.02em;
|
||
text-wrap: balance;
|
||
}
|
||
h1 .ver { color: var(--accent); }
|
||
.rules {
|
||
list-style: none;
|
||
margin: 4px 0 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 26px;
|
||
font-weight: 600;
|
||
font-size: 15px;
|
||
}
|
||
.rules li { display: flex; align-items: center; gap: 10px; }
|
||
.rules .n {
|
||
flex: none;
|
||
width: 28px; height: 28px;
|
||
border-radius: 50%;
|
||
display: grid; place-items: center;
|
||
background: var(--accent);
|
||
color: var(--ink);
|
||
font-family: var(--display);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* ---------- строка недели и уведомления ---------- */
|
||
.status {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px 20px;
|
||
font-size: 14px;
|
||
color: var(--muted);
|
||
}
|
||
.status-left { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
|
||
.chip {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 5px 12px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
color: var(--fg);
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
letter-spacing: 0.02em;
|
||
white-space: nowrap;
|
||
}
|
||
.my-steps { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
.my-steps b { font-family: var(--display); font-weight: 700; }
|
||
|
||
.notice {
|
||
margin: 0;
|
||
padding: 12px 16px;
|
||
border-radius: 14px;
|
||
border: 1px dashed var(--line);
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
.notice strong { color: var(--fg); }
|
||
|
||
/* ---------- мини-опрос ---------- */
|
||
.pulse {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 12px 18px;
|
||
padding: 14px 16px 14px 20px;
|
||
border-radius: var(--radius);
|
||
background: var(--cell);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.pulse-q { margin: 0; font-weight: 700; flex: 1 1 220px; min-width: 0; }
|
||
.pulse-q small { display: block; font-weight: 500; color: var(--muted); font-size: 13px; margin-top: 2px; }
|
||
.pulse-opts { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.pulse-opt {
|
||
cursor: pointer;
|
||
padding: 9px 14px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--line);
|
||
background: transparent;
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
transition: background-color .15s, border-color .15s, color .15s;
|
||
}
|
||
.pulse-opt:hover { border-color: var(--muted); }
|
||
.pulse-opt[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--ink); }
|
||
|
||
/* ---------- карточка ---------- */
|
||
.card { display: grid; gap: 16px; }
|
||
.row {
|
||
--row: var(--c-body);
|
||
display: grid;
|
||
grid-template-columns: 164px minmax(0, 1fr);
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
padding: 8px;
|
||
margin: -8px;
|
||
border-radius: calc(var(--radius) + 8px);
|
||
transition: background-color .3s, box-shadow .3s;
|
||
}
|
||
.row.is-complete {
|
||
background: color-mix(in srgb, var(--row) 12%, transparent);
|
||
box-shadow: inset 0 0 0 1.5px var(--row), 0 0 36px -6px color-mix(in srgb, var(--row) 45%, transparent);
|
||
}
|
||
.row.just-completed { animation: row-pop .7s cubic-bezier(.3,1.6,.5,1); }
|
||
@keyframes row-pop { 0% { transform: scale(1); } 35% { transform: scale(1.015); } 100% { transform: scale(1); } }
|
||
|
||
.row-head {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
padding: 4px 4px 4px 8px;
|
||
color: var(--row);
|
||
min-width: 0;
|
||
}
|
||
.row-icon { width: 30px; height: 30px; flex: none; }
|
||
.row-name {
|
||
font-family: var(--display);
|
||
font-weight: 700;
|
||
font-size: 15px;
|
||
line-height: 1.2;
|
||
text-wrap: balance;
|
||
}
|
||
.row-bonus {
|
||
align-self: flex-start;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.04em;
|
||
padding: 3px 9px;
|
||
border-radius: 999px;
|
||
background: var(--row);
|
||
color: var(--ink);
|
||
white-space: nowrap;
|
||
}
|
||
.row:not(.is-complete) .row-bonus { visibility: hidden; }
|
||
|
||
.row-cells { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||
|
||
.cell {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
min-height: 132px;
|
||
padding: 16px 16px 18px;
|
||
text-align: left;
|
||
background: var(--cell);
|
||
border: 1.5px solid var(--line);
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
-webkit-tap-highlight-color: transparent;
|
||
transition: background-color .18s, border-color .18s, color .18s, transform .12s;
|
||
}
|
||
.cell:hover { border-color: color-mix(in srgb, var(--row) 70%, var(--line)); }
|
||
.cell:active { transform: scale(.975); }
|
||
.cell-num {
|
||
font-family: var(--display);
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
color: var(--row);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.cell-text { font-weight: 700; font-size: 15.5px; line-height: 1.28; padding-right: 2px; overflow-wrap: anywhere; hyphens: auto; }
|
||
.cell-check {
|
||
position: absolute;
|
||
top: 13px; right: 13px;
|
||
width: 28px; height: 28px;
|
||
border-radius: 50%;
|
||
border: 1.5px solid var(--line);
|
||
display: grid; place-items: center;
|
||
transition: background-color .18s, border-color .18s;
|
||
}
|
||
.cell-check svg { width: 16px; height: 16px; opacity: 0; transform: scale(.4); transition: opacity .18s, transform .25s cubic-bezier(.3,1.7,.5,1); }
|
||
|
||
.cell[aria-pressed="true"] { background: var(--row); border-color: var(--row); color: var(--ink); }
|
||
.cell[aria-pressed="true"] .cell-num { color: var(--ink); opacity: .72; }
|
||
.cell[aria-pressed="true"] .cell-check { background: var(--ink); border-color: var(--ink); color: var(--row); }
|
||
.cell[aria-pressed="true"] .cell-check svg { opacity: 1; transform: scale(1); }
|
||
.cell[aria-busy="true"] { cursor: progress; }
|
||
|
||
/* Клетка собранного столбца или диагонали: светлое кольцо поверх заливки ряда. */
|
||
.cell.on-line {
|
||
box-shadow: 0 0 0 3px var(--bg), 0 0 0 5.5px var(--fg), 0 0 28px 2px color-mix(in srgb, var(--fg) 30%, transparent);
|
||
}
|
||
.cell.just-line { animation: diag-pop .7s cubic-bezier(.3,1.6,.5,1); }
|
||
@keyframes diag-pop { 0% { transform: scale(1); } 35% { transform: scale(1.04); } 100% { transform: scale(1); } }
|
||
|
||
/* ---------- батарейка ---------- */
|
||
.battery-wrap {
|
||
display: grid;
|
||
gap: 18px;
|
||
padding: 24px;
|
||
border-radius: calc(var(--radius) + 6px);
|
||
background: var(--cell);
|
||
border: 1px solid var(--line);
|
||
}
|
||
.battery-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 24px; }
|
||
.battery-title { display: flex; align-items: center; gap: 10px; margin: 0; font-family: var(--display); font-weight: 700; font-size: clamp(18px, 2.6vw, 22px); }
|
||
.battery-title svg { width: 24px; height: 24px; color: var(--accent); flex: none; }
|
||
.battery-sub { margin: 6px 0 0; color: var(--muted); font-size: 15px; font-variant-numeric: tabular-nums; }
|
||
.battery-sub b { color: var(--fg); font-weight: 800; }
|
||
.battery-pct {
|
||
display: flex; align-items: baseline; gap: 12px;
|
||
font-family: var(--display);
|
||
font-weight: 800;
|
||
font-size: clamp(40px, 7vw, 60px);
|
||
line-height: 1;
|
||
color: var(--accent);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.battery-pct small { font-size: .45em; margin-left: 2px; }
|
||
.overdrive {
|
||
font-family: var(--display);
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
background: var(--accent);
|
||
color: var(--ink);
|
||
align-self: center;
|
||
}
|
||
|
||
.battery { display: flex; align-items: center; gap: 0; }
|
||
.battery-body {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
||
gap: 6px;
|
||
padding: 6px;
|
||
height: 76px;
|
||
border: 3px solid var(--accent);
|
||
border-radius: 18px;
|
||
}
|
||
.seg { position: relative; border-radius: 8px; background: color-mix(in srgb, var(--accent) 9%, transparent); overflow: hidden; }
|
||
.seg i {
|
||
position: absolute; inset: 0;
|
||
background: var(--accent);
|
||
transform-origin: left center;
|
||
transform: scaleX(var(--fill, 0));
|
||
transition: transform .8s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
.battery-cap { width: 10px; height: 30px; margin-left: 4px; border-radius: 0 6px 6px 0; background: var(--accent); flex: none; }
|
||
.battery.is-overdrive .battery-body { animation: overdrive 1.8s ease-in-out infinite; }
|
||
@keyframes overdrive {
|
||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
|
||
50% { box-shadow: 0 0 34px 2px color-mix(in srgb, var(--accent) 45%, transparent); }
|
||
}
|
||
.battery-scale {
|
||
display: grid; grid-template-columns: repeat(10, minmax(0, 1fr));
|
||
gap: 6px; padding: 0 23px 0 9px;
|
||
margin-top: -8px;
|
||
font-size: 11px; font-weight: 700; color: var(--muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.battery-scale span { text-align: right; }
|
||
|
||
.foot { margin: 0; font-size: 13px; color: var(--muted); max-width: 70ch; }
|
||
|
||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||
|
||
.toast {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||
transform: translate(-50%, 20px);
|
||
max-width: min(520px, calc(100vw - 32px));
|
||
padding: 12px 18px;
|
||
border-radius: 14px;
|
||
background: var(--fg);
|
||
color: var(--ink);
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
box-shadow: 0 12px 40px rgba(0,0,0,.35);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity .2s, transform .2s;
|
||
z-index: 30;
|
||
}
|
||
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||
|
||
#confetti { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 20; }
|
||
|
||
/* ---------- телефон: подпись ряда сверху, клетки 2×2 ---------- */
|
||
@media (max-width: 760px) {
|
||
.card { gap: 22px; }
|
||
.row { grid-template-columns: minmax(0, 1fr); gap: 10px; }
|
||
.row-head { flex-direction: row; align-items: center; justify-content: flex-start; padding: 0 2px; gap: 10px; }
|
||
.row-icon { width: 24px; height: 24px; }
|
||
.row-bonus { align-self: center; margin-left: auto; }
|
||
.row-cells { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||
.pulse { padding: 16px; }
|
||
.pulse-opts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; gap: 6px; }
|
||
.pulse-opt { padding: 9px 6px; font-size: 13px; white-space: nowrap; }
|
||
.cell { min-height: 116px; padding: 14px 14px 16px; }
|
||
.cell-text { font-size: 15px; }
|
||
.battery-wrap { padding: 20px 16px; }
|
||
.battery-body { height: 60px; gap: 4px; padding: 5px; border-radius: 15px; }
|
||
.seg { border-radius: 6px; }
|
||
.battery-scale { gap: 4px; padding: 0 22px 0 8px; font-size: 10px; }
|
||
.battery-scale span:nth-child(odd) { visibility: hidden; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||
}
|
||
</style>
|
||
|
||
<div class="page">
|
||
<header class="hero">
|
||
<p class="eyebrow">PROJECT: BALANCE · СЕЗОН 2 · 2 НОЯБРЯ — 21 ДЕКАБРЯ</p>
|
||
<h1>Бинго «Баланс <span class="ver">2.0</span>»</h1>
|
||
<ol class="rules">
|
||
<li><span class="n">1</span>Делай маленькие шаги</li>
|
||
<li><span class="n">2</span>Отмечай клетки за неделю</li>
|
||
<li><span class="n">3</span>Собрал ряд, столбец или диагональ — +5 в батарейку</li>
|
||
</ol>
|
||
</header>
|
||
|
||
<div class="status">
|
||
<div class="status-left">
|
||
<span class="chip" id="week-chip">Неделя —</span>
|
||
<span id="reset-note">Карточка обнулится в понедельник в 00:00</span>
|
||
</div>
|
||
<span class="my-steps" id="my-steps"></span>
|
||
</div>
|
||
|
||
<p class="notice" id="notice" hidden></p>
|
||
|
||
<section class="pulse" id="pulse" aria-labelledby="pulse-q" hidden>
|
||
<p class="pulse-q" id="pulse-q">Твоя батарейка сейчас?<small id="pulse-hint">Один анонимный ответ в неделю — сравним «до» и «после» в финале.</small></p>
|
||
<div class="pulse-opts" role="group" aria-labelledby="pulse-q">
|
||
<button type="button" class="pulse-opt" data-answer="full" aria-pressed="false">⚡ Полная</button>
|
||
<button type="button" class="pulse-opt" data-answer="mid" aria-pressed="false">🔋 Средне</button>
|
||
<button type="button" class="pulse-opt" data-answer="low" aria-pressed="false">🪫 На нуле</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card" id="card" aria-label="Моя карточка на эту неделю"></section>
|
||
|
||
<section class="battery-wrap" aria-labelledby="battery-title">
|
||
<div class="battery-head">
|
||
<div>
|
||
<h2 class="battery-title" id="battery-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2.5 5 13.5h6l-1 8 8-11h-6z"/></svg>
|
||
Батарейка команды
|
||
</h2>
|
||
<p class="battery-sub" id="battery-sub"><b>…</b> из 500 шагов · заполняем вместе до 21 декабря</p>
|
||
</div>
|
||
<div class="battery-pct"><span id="battery-pct">0<small>%</small></span><span class="overdrive" id="overdrive" hidden>Овердрайв!</span></div>
|
||
</div>
|
||
<div class="battery" id="battery" role="progressbar" aria-labelledby="battery-title" aria-valuemin="0" aria-valuemax="500" aria-valuenow="0">
|
||
<div class="battery-body" id="battery-body"></div>
|
||
<div class="battery-cap"></div>
|
||
</div>
|
||
<div class="battery-scale" id="battery-scale" aria-hidden="true"></div>
|
||
</section>
|
||
|
||
<p class="foot">Анонимно: без имён, почты и логинов. Твоя карточка хранится только в этом браузере, на сервер уходит лишь «клетка отмечена или снята» со случайным номером устройства. Батарейка обновляется сама каждые 20 секунд.</p>
|
||
</div>
|
||
|
||
<div class="sr-only" aria-live="polite" id="live"></div>
|
||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||
<canvas id="confetti" aria-hidden="true"></canvas>
|
||
|
||
<script>window.BINGO_FORCE_DEMO = true;</script>
|
||
<script>
|
||
(() => {
|
||
'use strict';
|
||
|
||
// ---------- настройки ----------
|
||
const API = 'api'; // относительный путь: работает и на своём домене, и в подпапке за прокси
|
||
const STORE_KEY = 'balance2-bingo-v1';
|
||
const POLL_MS = 20000;
|
||
const LINE_BONUS = 5;
|
||
const DEFAULTS = { tz: 'Europe/Moscow', start: '2026-11-02', end: '2026-12-21', goal: 500 };
|
||
const FORCE_DEMO = window.BINGO_FORCE_DEMO === true || location.hash === '#demo';
|
||
|
||
const ICONS = {
|
||
heart: '<path d="M12 20s-7.5-4.6-7.5-10.1A4.2 4.2 0 0 1 12 7.4a4.2 4.2 0 0 1 7.5 2.5C19.5 15.4 12 20 12 20z"/>',
|
||
target: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.4"/>',
|
||
shield: '<path d="M12 3.2 19 5.9v5.7c0 4.4-3 7.9-7 9.3-4-1.4-7-4.9-7-9.3V5.9z"/><path d="m9 12.2 2.1 2.1L15.2 10"/>',
|
||
cup: '<path d="M4.5 10h11v3.6a5.5 5.5 0 0 1-11 0z"/><path d="M15.5 11.2h1.6a2.4 2.4 0 0 1 0 4.8h-1.9"/><path d="M8 3.6c-.7.9.7 1.6 0 2.6M11.8 3.6c-.7.9.7 1.6 0 2.6"/>',
|
||
};
|
||
const CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.3 4.3L19 7.2"/></svg>';
|
||
|
||
const ROWS = [
|
||
{ name: 'Тело', color: 'var(--c-body)', icon: 'heart',
|
||
cells: ['Обед без экрана', 'Разминка между созвонами', 'Гимнастика для глаз', 'Сон до полуночи'] },
|
||
{ name: 'Фокус', color: 'var(--c-focus)', icon: 'target',
|
||
cells: ['Фокус-блок на одну задачу', '3 главных дела на день', 'Встреча превратилась в сообщение', 'Время залогировано в тот же день'] },
|
||
{ name: 'Границы', color: 'var(--c-bounds)', icon: 'shield',
|
||
cells: ['Сказать «нет» или «не сейчас»', 'Вечер без рабочих сообщений', 'Попросить о помощи', 'Ноутбук закрыт вовремя'] },
|
||
{ name: 'Люди и радость', color: 'var(--c-people)', icon: 'cup',
|
||
cells: ['Офлайн с друзьями или семьёй', 'Хобби, спорт или что-то руками', '«Спасибо» или помощь коллеге', 'Мем в чат Мемопятницы'] },
|
||
];
|
||
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
|
||
|
||
// ---------- утилиты ----------
|
||
const $ = (id) => document.getElementById(id);
|
||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
|
||
function plural(n, one, few, many) {
|
||
const a = Math.abs(n) % 100, b = a % 10;
|
||
if (a > 10 && a < 20) return many;
|
||
if (b > 1 && b < 5) return few;
|
||
if (b === 1) return one;
|
||
return many;
|
||
}
|
||
const steps = (n) => plural(n, 'шаг', 'шага', 'шагов');
|
||
const fmtNum = (n) => n.toLocaleString('ru-RU');
|
||
const fmtDay = (iso) => `${+iso.slice(8, 10)} ${MONTHS[+iso.slice(5, 7) - 1]}`;
|
||
const tzLabel = (tz) => (tz === 'Europe/Moscow' ? 'МСК' : tz.split('/').pop().replace(/_/g, ' '));
|
||
|
||
function newId() {
|
||
if (crypto.randomUUID) return crypto.randomUUID();
|
||
const b = crypto.getRandomValues(new Uint8Array(16));
|
||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
// Даты: всё считается в часовом поясе сезона, как на сервере.
|
||
function todayIn(tz, now = new Date()) {
|
||
try {
|
||
const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now);
|
||
const get = (t) => parts.find((p) => p.type === t).value;
|
||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||
} catch {
|
||
return now.toISOString().slice(0, 10);
|
||
}
|
||
}
|
||
const dayNum = (iso) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)) / 864e5;
|
||
const isoOf = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
|
||
function mondayOf(iso) {
|
||
const n = dayNum(iso);
|
||
return isoOf(n - ((new Date(n * 864e5).getUTCDay() + 6) % 7));
|
||
}
|
||
const weekNoOf = (week, start) => Math.floor((dayNum(week) - dayNum(mondayOf(start))) / 7) + 1;
|
||
|
||
function localSeason(cfg) {
|
||
const today = todayIn(cfg.tz);
|
||
const week = mondayOf(today);
|
||
const phase = today < cfg.start ? 'pre' : today > cfg.end ? 'post' : 'active';
|
||
return {
|
||
...cfg, week, phase,
|
||
weekNo: phase === 'active' ? weekNoOf(week, cfg.start) : null,
|
||
weeks: weekNoOf(mondayOf(cfg.end), cfg.start),
|
||
};
|
||
}
|
||
|
||
// ---------- состояние ----------
|
||
function loadSaved() {
|
||
try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; } catch { return {}; }
|
||
}
|
||
const saved = loadSaved();
|
||
const S = {
|
||
device: typeof saved.device === 'string' && /^[A-Za-z0-9-]{16,64}$/.test(saved.device) ? saved.device : newId(),
|
||
cfg: { ...DEFAULTS, ...(saved.cfg || {}) },
|
||
week: saved.week || null,
|
||
cells: new Set(Array.isArray(saved.cells) ? saved.cells.filter((c) => Number.isInteger(c) && c >= 1 && c <= 16) : []),
|
||
pulse: saved.pulse && typeof saved.pulse === 'object' ? saved.pulse : {},
|
||
mode: FORCE_DEMO ? 'demo' : 'connecting', // 'live' | 'demo'
|
||
online: true,
|
||
phase: 'active', weekNo: null, weeks: 8,
|
||
total: null,
|
||
shownTotal: 0,
|
||
demoOthers: 0,
|
||
pending: new Set(),
|
||
};
|
||
|
||
function persist() {
|
||
try {
|
||
localStorage.setItem(STORE_KEY, JSON.stringify({
|
||
device: S.device, cfg: S.cfg, week: S.week, cells: [...S.cells], pulse: S.pulse,
|
||
}));
|
||
} catch { /* приватный режим или запрет хранения — карточка живёт до закрытия вкладки */ }
|
||
}
|
||
|
||
// Линии, за которые даётся бонус: 4 ряда, 4 столбца и 2 диагонали.
|
||
const LINES = [
|
||
...ROWS.map((row, r) => ({ kind: 'row', row: r, name: `Ряд «${row.name}»`, cells: [1, 2, 3, 4].map((k) => r * 4 + k) })),
|
||
...[1, 2, 3, 4].map((k) => ({ kind: 'col', name: `Столбец ${String(k).padStart(2, '0')}–${k + 12}`, cells: [k, k + 4, k + 8, k + 12] })),
|
||
{ kind: 'diag', name: 'Диагональ 01–16', cells: [1, 6, 11, 16] },
|
||
{ kind: 'diag', name: 'Диагональ 04–13', cells: [4, 7, 10, 13] },
|
||
];
|
||
const lineComplete = (i) => LINES[i].cells.every((c) => S.cells.has(c));
|
||
const rowComplete = (r) => lineComplete(r);
|
||
const completeLines = () => LINES.reduce((n, _, i) => n + (lineComplete(i) ? 1 : 0), 0);
|
||
const myScore = () => S.cells.size + LINE_BONUS * completeLines();
|
||
|
||
// Новая неделя (или первый запуск): карточка начинается заново. Батарейку это не трогает.
|
||
function setWeek(week) {
|
||
if (S.week === week) return false;
|
||
const hadMarks = S.week !== null && S.cells.size > 0;
|
||
S.week = week;
|
||
S.cells.clear();
|
||
persist();
|
||
return hadMarks;
|
||
}
|
||
|
||
function applySeason(info) {
|
||
S.cfg = {
|
||
tz: info.tz || S.cfg.tz,
|
||
start: info.start || S.cfg.start,
|
||
end: info.end || S.cfg.end,
|
||
goal: Number(info.goal) > 0 ? Number(info.goal) : S.cfg.goal,
|
||
};
|
||
S.phase = info.phase;
|
||
S.weekNo = info.weekNo;
|
||
S.weeks = info.weeks;
|
||
const rolled = setWeek(info.week);
|
||
persist();
|
||
return rolled;
|
||
}
|
||
|
||
// ---------- сеть ----------
|
||
class ApiError extends Error {
|
||
constructor(status, data) { super(data?.error || `HTTP ${status}`); this.status = status; this.data = data; }
|
||
}
|
||
async function api(path, body) {
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 8000);
|
||
try {
|
||
const res = await fetch(API + path, {
|
||
method: body ? 'POST' : 'GET',
|
||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
cache: 'no-store',
|
||
credentials: 'omit',
|
||
signal: ctrl.signal,
|
||
});
|
||
const isJson = (res.headers.get('Content-Type') || '').includes('application/json');
|
||
const data = isJson ? await res.json() : null;
|
||
if (!res.ok || !isJson) throw new ApiError(res.status, data);
|
||
return data;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function refreshTotal() {
|
||
if (S.mode !== 'live') return;
|
||
try {
|
||
const t = await api('/total');
|
||
S.online = true;
|
||
const rolled = applySeason(t);
|
||
S.total = t.total;
|
||
if (rolled) toast('Началась новая неделя — карточка чистая. Батарейка команды сохранилась.');
|
||
if (rolled || S.week !== t.week) await syncMine();
|
||
renderAll();
|
||
} catch {
|
||
S.online = false;
|
||
renderNotice();
|
||
}
|
||
}
|
||
|
||
// Сверяем карточку с сервером: если отметка не дошла, показываем как есть на сервере.
|
||
async function syncMine() {
|
||
if (S.mode !== 'live' || S.phase !== 'active') return;
|
||
try {
|
||
const me = await api(`/me?d=${encodeURIComponent(S.device)}`);
|
||
if (me.week !== S.week || S.pending.size) return;
|
||
S.cells = new Set(me.cells);
|
||
if (me.pulse) S.pulse[S.week] = me.pulse;
|
||
persist();
|
||
} catch { /* останемся с локальной карточкой */ }
|
||
}
|
||
|
||
// ---------- построение карточки ----------
|
||
const cellEls = new Map();
|
||
const rowEls = [];
|
||
|
||
function buildCard() {
|
||
const card = $('card');
|
||
ROWS.forEach((row, r) => {
|
||
const rowEl = document.createElement('div');
|
||
rowEl.className = 'row';
|
||
rowEl.style.setProperty('--row', row.color);
|
||
rowEl.setAttribute('role', 'group');
|
||
rowEl.setAttribute('aria-labelledby', `row-name-${r}`);
|
||
rowEl.innerHTML = `
|
||
<div class="row-head">
|
||
<svg class="row-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[row.icon]}</svg>
|
||
<span class="row-name" id="row-name-${r}">${row.name}</span>
|
||
<span class="row-bonus">Ряд собран · +5</span>
|
||
</div>
|
||
<div class="row-cells"></div>`;
|
||
const cellsWrap = rowEl.querySelector('.row-cells');
|
||
row.cells.forEach((label, k) => {
|
||
const n = r * 4 + k + 1;
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'cell';
|
||
btn.setAttribute('aria-pressed', 'false');
|
||
btn.dataset.cell = String(n);
|
||
btn.innerHTML = `<span class="cell-num">${String(n).padStart(2, '0')}</span><span class="cell-text">${label}</span><span class="cell-check" aria-hidden="true">${CHECK}</span>`;
|
||
btn.addEventListener('click', () => toggle(n));
|
||
cellsWrap.appendChild(btn);
|
||
cellEls.set(n, btn);
|
||
});
|
||
card.appendChild(rowEl);
|
||
rowEls.push(rowEl);
|
||
});
|
||
}
|
||
|
||
function buildBattery() {
|
||
const body = $('battery-body');
|
||
for (let i = 0; i < 10; i++) {
|
||
const seg = document.createElement('span');
|
||
seg.className = 'seg';
|
||
seg.innerHTML = '<i></i>';
|
||
body.appendChild(seg);
|
||
}
|
||
const scale = $('battery-scale');
|
||
for (let i = 1; i <= 10; i++) {
|
||
const s = document.createElement('span');
|
||
s.textContent = `${i * 10}%`;
|
||
scale.appendChild(s);
|
||
}
|
||
}
|
||
|
||
// ---------- отрисовка ----------
|
||
function renderCard() {
|
||
const lineCells = new Set();
|
||
LINES.forEach((line, i) => { if (line.kind !== 'row' && lineComplete(i)) line.cells.forEach((c) => lineCells.add(c)); });
|
||
cellEls.forEach((btn, n) => {
|
||
btn.setAttribute('aria-pressed', S.cells.has(n) ? 'true' : 'false');
|
||
btn.classList.toggle('on-line', lineCells.has(n));
|
||
});
|
||
rowEls.forEach((el, r) => el.classList.toggle('is-complete', rowComplete(r)));
|
||
}
|
||
|
||
function renderStatus() {
|
||
const chip = $('week-chip');
|
||
if (S.mode === 'demo') chip.textContent = 'Демо';
|
||
else if (S.phase === 'pre') chip.textContent = `Старт ${fmtDay(S.cfg.start)}`;
|
||
else if (S.phase === 'post') chip.textContent = 'Сезон завершён';
|
||
else chip.textContent = S.weekNo ? `Неделя ${S.weekNo} из ${S.weeks}` : 'Неделя —';
|
||
|
||
$('reset-note').textContent = `Карточка обнулится в понедельник в 00:00 (${tzLabel(S.cfg.tz)})`;
|
||
|
||
const score = myScore();
|
||
const got = { row: 0, col: 0, diag: 0 };
|
||
LINES.forEach((line, i) => { if (lineComplete(i)) got[line.kind]++; });
|
||
const bonus = LINE_BONUS * (got.row + got.col + got.diag);
|
||
const part = (n, f) => (n ? `${n > 1 ? n + ' ' : ''}${plural(n, ...f)}` : '');
|
||
const parts = [
|
||
part(got.row, ['ряд', 'ряда', 'рядов']),
|
||
part(got.col, ['столбец', 'столбца', 'столбцов']),
|
||
part(got.diag, ['диагональ', 'диагонали', 'диагоналей']),
|
||
].filter(Boolean);
|
||
const what = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} и ${parts.at(-1)}` : parts[0];
|
||
$('my-steps').innerHTML = `Мои шаги за неделю: <b>${score}</b>${bonus ? ` <span style="color:var(--muted);font-weight:600">(из них +${bonus} за ${what})</span>` : ''}`;
|
||
}
|
||
|
||
function renderNotice() {
|
||
const el = $('notice');
|
||
let html = '';
|
||
if (S.mode === 'demo') {
|
||
html = '<strong>Демо-режим.</strong> Сервер не подключён: батарейка показывает пример, а отметки сохраняются только в этом браузере.';
|
||
} else if (S.mode === 'live' && !S.online) {
|
||
html = '<strong>Нет связи с сервером.</strong> Батарейка обновится, когда связь вернётся.';
|
||
} else if (S.phase === 'pre') {
|
||
html = `<strong>Сезон стартует ${fmtDay(S.cfg.start)}.</strong> Пока можно потренироваться: отметки до старта не идут в батарейку и сотрутся в понедельник.`;
|
||
} else if (S.phase === 'post') {
|
||
html = '<strong>Сезон завершён.</strong> Батарейка команды зафиксирована — спасибо всем! Отметки больше не идут в общий счёт.';
|
||
}
|
||
el.innerHTML = html;
|
||
el.hidden = !html;
|
||
}
|
||
|
||
function renderPulse() {
|
||
const box = $('pulse');
|
||
const show = S.mode === 'demo' || (S.mode === 'live' && S.phase === 'active');
|
||
box.hidden = !show;
|
||
if (!show) return;
|
||
const answer = S.pulse[S.week] || null;
|
||
box.querySelectorAll('.pulse-opt').forEach((b) => b.setAttribute('aria-pressed', b.dataset.answer === answer ? 'true' : 'false'));
|
||
$('pulse-hint').textContent = answer
|
||
? 'Спасибо, ответ учтён анонимно. До конца недели можно поменять.'
|
||
: 'Один анонимный ответ в неделю — сравним «до» и «после» в финале.';
|
||
}
|
||
|
||
let countAnim = 0;
|
||
function renderBattery() {
|
||
const goal = S.cfg.goal;
|
||
const total = S.total ?? 0;
|
||
const ratio = total / goal;
|
||
const segs = $('battery-body').children;
|
||
for (let i = 0; i < segs.length; i++) {
|
||
segs[i].firstChild.style.setProperty('--fill', String(Math.max(0, Math.min(1, ratio * 10 - i))));
|
||
}
|
||
const over = total > goal;
|
||
$('battery').classList.toggle('is-overdrive', over);
|
||
$('overdrive').hidden = !over;
|
||
const bat = $('battery');
|
||
bat.setAttribute('aria-valuemax', String(goal));
|
||
bat.setAttribute('aria-valuenow', String(Math.min(total, goal)));
|
||
bat.setAttribute('aria-valuetext', `${total} из ${goal} ${steps(goal)}, ${Math.floor(ratio * 100)}%${over ? ', овердрайв' : ''}`);
|
||
|
||
// Плавный пересчёт числа
|
||
cancelAnimationFrame(countAnim);
|
||
const from = S.shownTotal;
|
||
const to = total;
|
||
const draw = (v) => {
|
||
const pct = Math.floor((v / goal) * 100);
|
||
$('battery-pct').innerHTML = `${pct}<small>%</small>`;
|
||
$('battery-sub').innerHTML = `<b>${S.total === null ? '…' : fmtNum(v)}</b> из ${fmtNum(goal)} ${steps(goal)} · заполняем вместе до ${fmtDay(S.cfg.end)}`;
|
||
};
|
||
if (from === to || reducedMotion.matches) { S.shownTotal = to; draw(to); return; }
|
||
const t0 = performance.now();
|
||
const dur = Math.min(900, 200 + Math.abs(to - from) * 12);
|
||
const step = (now) => {
|
||
const k = Math.min(1, (now - t0) / dur);
|
||
const v = Math.round(from + (to - from) * (1 - Math.pow(1 - k, 3)));
|
||
S.shownTotal = v;
|
||
draw(v);
|
||
if (k < 1) countAnim = requestAnimationFrame(step);
|
||
};
|
||
countAnim = requestAnimationFrame(step);
|
||
}
|
||
|
||
function renderAll() {
|
||
renderCard();
|
||
renderStatus();
|
||
renderNotice();
|
||
renderPulse();
|
||
renderBattery();
|
||
}
|
||
|
||
// ---------- действия ----------
|
||
async function toggle(cell) {
|
||
if (S.pending.has(cell)) return;
|
||
const wasOn = S.cells.has(cell);
|
||
const touched = LINES.map((line, i) => i).filter((i) => LINES[i].cells.includes(cell));
|
||
const wasComplete = touched.map(lineComplete);
|
||
const before = myScore();
|
||
|
||
if (wasOn) S.cells.delete(cell); else S.cells.add(cell);
|
||
persist();
|
||
const delta = myScore() - before;
|
||
renderCard();
|
||
renderStatus();
|
||
|
||
const made = touched.filter((i, k) => !wasComplete[k] && lineComplete(i));
|
||
const broken = touched.filter((i, k) => wasComplete[k] && !lineComplete(i));
|
||
if (made.length) celebrate(made);
|
||
else if (broken.length) announce(`${broken.map((i) => LINES[i].name).join(' и ')} — больше не собрано: −${LINE_BONUS * broken.length} из батарейки.`);
|
||
else announce(`${ROWS[Math.floor((cell - 1) / 4)].cells[(cell - 1) % 4]}: ${wasOn ? 'отметка снята' : 'отмечено'}.`);
|
||
|
||
if (S.mode === 'demo') {
|
||
S.total = S.demoOthers + myScore();
|
||
renderBattery();
|
||
return;
|
||
}
|
||
if (S.mode !== 'live' || S.phase !== 'active') return;
|
||
|
||
S.total = (S.total ?? 0) + delta;
|
||
renderBattery();
|
||
|
||
const btn = cellEls.get(cell);
|
||
S.pending.add(cell);
|
||
btn.setAttribute('aria-busy', 'true');
|
||
try {
|
||
const res = await api('/mark', { d: S.device, week: S.week, cell, on: !wasOn });
|
||
S.online = true;
|
||
S.total = res.total;
|
||
if (S.pending.size === 1 && res.week === S.week) {
|
||
S.cells = new Set(res.cells);
|
||
persist();
|
||
}
|
||
renderAll();
|
||
} catch (err) {
|
||
if (err.status === 409 && err.data?.week) {
|
||
await refreshTotal();
|
||
toast('Началась новая неделя — карточка обнулилась. Отметь заново, что сделал на этой неделе.');
|
||
} else if (err.status === 403) {
|
||
await refreshTotal();
|
||
toast('Сезон сейчас закрыт — отметки не идут в батарейку.');
|
||
} else {
|
||
if (wasOn) S.cells.add(cell); else S.cells.delete(cell);
|
||
persist();
|
||
S.total = (S.total ?? 0) - delta;
|
||
renderAll();
|
||
toast('Не получилось сохранить отметку. Проверь интернет и нажми ещё раз.');
|
||
}
|
||
} finally {
|
||
S.pending.delete(cell);
|
||
btn.removeAttribute('aria-busy');
|
||
}
|
||
}
|
||
|
||
async function answerPulse(answer) {
|
||
const prev = S.pulse[S.week] || null;
|
||
if (prev === answer) return;
|
||
S.pulse[S.week] = answer;
|
||
persist();
|
||
renderPulse();
|
||
if (S.mode !== 'live') return;
|
||
try {
|
||
await api('/pulse', { d: S.device, week: S.week, answer });
|
||
} catch (err) {
|
||
if (prev) S.pulse[S.week] = prev; else delete S.pulse[S.week];
|
||
persist();
|
||
renderPulse();
|
||
if (err.status === 409) await refreshTotal();
|
||
toast('Ответ не сохранился. Проверь интернет и попробуй ещё раз.');
|
||
}
|
||
}
|
||
|
||
// ---------- сообщения ----------
|
||
let toastTimer = 0;
|
||
function toast(text) {
|
||
const el = $('toast');
|
||
el.textContent = text;
|
||
el.classList.add('show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => el.classList.remove('show'), 4200);
|
||
}
|
||
function announce(text) {
|
||
const el = $('live');
|
||
el.textContent = '';
|
||
setTimeout(() => { el.textContent = text; }, 30);
|
||
}
|
||
|
||
// ---------- конфетти ----------
|
||
const canvas = $('confetti');
|
||
const ctx = canvas.getContext('2d');
|
||
let parts = [];
|
||
let confettiRaf = 0;
|
||
|
||
const replay = (el, cls) => { el.classList.remove(cls); void el.offsetWidth; el.classList.add(cls); };
|
||
|
||
function celebrate(lineIdxs) {
|
||
const total = LINE_BONUS * lineIdxs.length;
|
||
announce(`${lineIdxs.map((i) => LINES[i].name).join(' и ')} — собрано! +${total} в батарейку.`);
|
||
for (const i of lineIdxs) {
|
||
const line = LINES[i];
|
||
if (line.kind === 'row') replay(rowEls[line.row], 'just-completed');
|
||
else line.cells.forEach((c) => replay(cellEls.get(c), 'just-line'));
|
||
}
|
||
if (reducedMotion.matches) return;
|
||
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
canvas.width = innerWidth * dpr;
|
||
canvas.height = innerHeight * dpr;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
|
||
const css = getComputedStyle(document.documentElement);
|
||
const tok = (n) => css.getPropertyValue(n).trim();
|
||
for (const i of lineIdxs) {
|
||
const line = LINES[i];
|
||
if (line.kind === 'row') {
|
||
const rowEl = rowEls[line.row];
|
||
const own = tok(ROWS[line.row].color.slice(4, -1));
|
||
burst(rowEl.getBoundingClientRect(), [own, own, tok('--accent'), tok('--fg'), tok('--c-focus'), tok('--c-bounds'), tok('--c-body')]);
|
||
} else {
|
||
// Столбец или диагональ: по залпу из каждой клетки, во всех цветах рядов.
|
||
const pal = [tok('--fg'), tok('--accent'), tok('--c-body'), tok('--c-focus'), tok('--c-bounds')];
|
||
line.cells.forEach((c) => burst(cellEls.get(c).getBoundingClientRect(), pal, 0.5));
|
||
}
|
||
}
|
||
if (!confettiRaf) confettiRaf = requestAnimationFrame(tick);
|
||
}
|
||
|
||
function burst(box, palette, scale = 1) {
|
||
const count = Math.round(Math.min(170, 70 + box.width / 6) * scale);
|
||
for (let i = 0; i < count; i++) {
|
||
const x = box.left + Math.random() * box.width;
|
||
const y = box.top + box.height * (0.3 + Math.random() * 0.4);
|
||
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
|
||
const speed = 7 + Math.random() * 9;
|
||
parts.push({
|
||
x, y,
|
||
vx: Math.cos(angle) * speed + (x - (box.left + box.width / 2)) / box.width * 4,
|
||
vy: Math.sin(angle) * speed,
|
||
w: 6 + Math.random() * 6,
|
||
h: 8 + Math.random() * 10,
|
||
rot: Math.random() * Math.PI,
|
||
vr: (Math.random() - 0.5) * 0.35,
|
||
tilt: Math.random() * Math.PI,
|
||
color: palette[(Math.random() * palette.length) | 0],
|
||
round: Math.random() < 0.25,
|
||
life: 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
let last = 0;
|
||
function tick(now) {
|
||
const dt = last ? Math.min(2.5, (now - last) / 16.67) : 1;
|
||
last = now;
|
||
ctx.clearRect(0, 0, innerWidth, innerHeight);
|
||
parts = parts.filter((p) => p.y < innerHeight + 40 && p.life < 260);
|
||
for (const p of parts) {
|
||
p.life += dt;
|
||
p.vy += 0.32 * dt;
|
||
p.vx *= Math.pow(0.985, dt);
|
||
p.vy *= Math.pow(0.99, dt);
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
p.rot += p.vr * dt;
|
||
p.tilt += 0.12 * dt;
|
||
ctx.save();
|
||
ctx.globalAlpha = Math.max(0, Math.min(1, (260 - p.life) / 60));
|
||
ctx.translate(p.x, p.y);
|
||
ctx.rotate(p.rot);
|
||
ctx.fillStyle = p.color;
|
||
if (p.round) {
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
} else {
|
||
ctx.fillRect(-p.w / 2, (-p.h / 2) * Math.cos(p.tilt), p.w, p.h * Math.cos(p.tilt));
|
||
}
|
||
ctx.restore();
|
||
}
|
||
if (parts.length) {
|
||
confettiRaf = requestAnimationFrame(tick);
|
||
} else {
|
||
ctx.clearRect(0, 0, innerWidth, innerHeight);
|
||
confettiRaf = 0;
|
||
last = 0;
|
||
}
|
||
}
|
||
|
||
// ---------- демо ----------
|
||
function startDemo() {
|
||
const s = localSeason(S.cfg);
|
||
setWeek(s.week);
|
||
S.phase = 'active';
|
||
S.weekNo = 3;
|
||
S.weeks = s.weeks;
|
||
S.demoOthers = 212;
|
||
S.total = S.demoOthers + myScore();
|
||
// «Коллеги» иногда что-то отмечают, чтобы было видно, как батарейка растёт.
|
||
setInterval(() => {
|
||
if (document.hidden) return;
|
||
const r = Math.random();
|
||
if (r < 0.45) S.demoOthers += 1;
|
||
else if (r < 0.52) S.demoOthers += 1 + LINE_BONUS;
|
||
else return;
|
||
S.total = S.demoOthers + myScore();
|
||
renderBattery();
|
||
}, 7000);
|
||
}
|
||
|
||
// ---------- запуск ----------
|
||
async function start() {
|
||
document.documentElement.lang = 'ru';
|
||
buildCard();
|
||
buildBattery();
|
||
$('pulse').addEventListener('click', (e) => {
|
||
const b = e.target.closest('.pulse-opt');
|
||
if (b) answerPulse(b.dataset.answer);
|
||
});
|
||
|
||
// Пока сервер не ответил — показываем карточку по локальному календарю.
|
||
const s = localSeason(S.cfg);
|
||
S.phase = s.phase; S.weekNo = s.weekNo; S.weeks = s.weeks;
|
||
setWeek(s.week);
|
||
renderAll();
|
||
|
||
if (S.mode !== 'demo') {
|
||
try {
|
||
const t = await api('/total');
|
||
S.mode = 'live';
|
||
applySeason(t);
|
||
S.total = t.total;
|
||
await syncMine();
|
||
} catch {
|
||
S.mode = 'demo';
|
||
}
|
||
}
|
||
if (S.mode === 'demo') startDemo();
|
||
persist();
|
||
renderAll();
|
||
|
||
setInterval(() => {
|
||
if (document.hidden) return;
|
||
if (S.mode === 'live') refreshTotal();
|
||
else {
|
||
const ls = localSeason(S.cfg);
|
||
if (setWeek(ls.week)) { renderAll(); toast('Началась новая неделя — карточка чистая.'); }
|
||
}
|
||
}, POLL_MS);
|
||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshTotal(); });
|
||
window.addEventListener('online', refreshTotal);
|
||
}
|
||
|
||
start();
|
||
})();
|
||
</script>
|
||
|