commit 60991875c002599cf5eea6ae880b4c3fda680f68 Author: Freestyle-Play Date: Tue Aug 25 18:22:36 2026 +0300 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1d099e --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Дело № 260826. Исчезновение подарка — v2 + +Интерактивный веб-квест ко дню рождения. Чистые HTML/CSS/JS, без сборки и +серверной части. + +Это переработанная версия. Папка `../quest/` (v1) оставлена нетронутой +для сравнения. + +## Быстрый старт + +1. Откройте `data.js` и заполните блок `CONFIG` вверху файла: + + ```js + const CONFIG = { + name: "Алиса", // имя именинницы + caseNumber: "260826", // номер дела = дата в формате ДДММГГ + caseDateWords: "двадцать шестое августа две тысячи двадцать шестого года", + pvzAddress: "Москва, Кочновский пр-д, 4 к. 1. ПВЗ Озон", + pickupCode: "АРБУЗ", // кодовое слово для запроса штрихкода + pvzPageUrl: "", // необязательно: ссылка для QR + songSrc: "assets/song.mp3", // необязательно: аудио песни + }; + ``` + + **Важно:** `caseNumber` и `caseDateWords` должны совпадать — номер дела + выводится игроком из даты, записанной прописью. Если меняете дату, + поменяйте оба поля. + +2. (Необязательно) Положите mp3 в `assets/song.mp3`. Без файла плеер сам + скроется, текст песни останется — загадка решается по тексту. + +3. Откройте `index.html` или запустите статический сервер: + + ```bash + python -m http.server 8792 + ``` + +## Что изменилось по сравнению с v1 + +Главная претензия к v1: загадки выглядели как загадки, но не требовали +думать — ответ почти всегда лежал на том же экране. + +| Этап | Было (v1) | Стало (v2) | +|---|---|---| +| Номер дела | Просто перепечатать число из уведомления | Вывести из даты прописью по правилу ДДММГГ | +| Место происшествия | Пиксель-хант: клик по еле заметному овалу | Сверить инвентарные бирки на столе со списком в описи | +| Допрос | Свидетель дословно повторял текст улики | Построить таймлайн по журналу турникета и найти физически невозможное показание | +| Аудио | Ответ «сюжет» был напечатан в припеве дважды | Слова `ПАУЗА` в песне нет: акростих надо собрать, а припев подсовывает ложное «ожидание» | +| Ложный след | Тот же ответ, что и в филологии — повтор | Два независимых признака подделки: противоречие журналу и падеж; искать в сплошном тексте | +| Финал | Журнал улик прямо показывал П-О-Д-А-Р-О-К | Заполнить форму ордера из трёх граф, каждая — из своей главы | + +Ключевое изменение: **журнал дела из спойлера стал рабочим блокнотом**. +Он копит установленные факты (`09:08`, `ДС-04`, `ПАУЗА`), которые +понадобятся в финальной форме. + +## Ответы (шпаргалка) + +| Глава | Ответ | +|---|---| +| Допуск | `260826` (из даты прописью) | +| Опись | коробка с биркой `ДС-04` (единственный предмет вне описи) | +| Показания | Клавдия Ивановна Шлюз (вышла 09:02, вернулась 09:12, а «была на посту в 09:07») | +| Экспертиза | Документ № 3 («согласно приказа» вместо «приказу») | +| Запись | `ПАУЗА` (первые буквы пяти строк бриджа) | +| Ложный след | `в 09:05` (Каверза вошёл только в 09:15) и `согласно приказа` | +| Ордер | `09:08` · `ДС-04` · `ПАУЗА` | + +Ввод терпим к формату: время принимает `9:08`, `09.08`, `0908`; бирка — +`дс 04`, `ДС-04`; регистр везде не важен. + +## Финальный экран и выдача подарка + +Штрихкод намеренно **не показывается в квесте**. Последний шаг — +написать организатору: + +1. Именинница отправляет в личные сообщения кодовое слово `АРБУЗ`. +2. В ответ вы присылаете штрихкод заказа. +3. Она показывает штрихкод на ПВЗ по адресу с экрана. + +Это встроено в сюжет как последняя бюрократическая процедура: «Бюро не +хранит штрихкоды в открытом виде — они выдаются ответственному лицу под +расписку». Инструкция выведена нумерованным списком под ордером. + +QR-код кодирует адрес, кодовое слово и номер дела короткой строкой, +чтобы код оставался разреженным и уверенно ловился камерой. Если указан +`pvzPageUrl`, QR ведёт на эту страницу вместо текста. + +Адрес и кодовое слово продублированы обычным текстом, плюс кнопка +копирования — с запасным путём на случай, когда Clipboard API +недоступен (например, при открытии страницы файлом). + +## Структура + +``` +index.html — точка входа +styles.css — оформление +data.js — CONFIG, FACTS и весь текст (правьте здесь) +app.js — логика: состояния экранов, журнал, подсказки, звук +vendor/qrcode.js — MIT-библиотека генерации QR (kazuhikoarase/qrcode-generator) +assets/ — сюда song.mp3 (опционально) +``` + +## Механики помощи + +- Кнопка **«Журнал»** — все установленные факты, нужны в финале. +- Кнопка **«Подсказка»** — до трёх уровней на каждую загадку, от намёка + до прямого ответа. +- Подсказки открываются и сами: после 3, 5 и 8 неверных попыток. +- После 8 ошибок появляется ссылка «Следствие настаивает на правильном + ответе», которая подсвечивает или подставляет ответ. Застрять нельзя. +- Прогресс сохраняется автоматически; «Начать сначала» — внизу экрана, + требует двойного нажатия. + +## Проверено + +Полное прохождение в Chrome: все главы, неверные ответы, ловушки, +подсказки, журнал, финал с QR. Консоль чистая. Мобильная вёрстка +проверена на ширине 361px — переполнения нет, стол со сцены получает +горизонтальную прокрутку, чтобы бирки оставались читаемыми. diff --git a/app.js b/app.js new file mode 100644 index 0000000..f83d640 --- /dev/null +++ b/app.js @@ -0,0 +1,1080 @@ +/* ============================================================ + ДЕЛО № 260826 — движок v2 + Состояния экранов, журнал фактов, подсказки, звук. + ============================================================ */ + +(function () { + "use strict"; + + const STORAGE_KEY = "papka_v2_state"; + const MUTE_KEY = "papka_v2_muted"; + + /* Экраны без верхней панели и дока */ + const BARE = new Set(["intro", "loading", "twist", "congrats", "orderScreen"]); + + /* Экраны-загадки: на них доступна подсказка */ + const PUZZLES = { + caseNumber: CONTENT.caseNumber, + scene: CONTENT.scene, + witnesses: CONTENT.witnesses, + philology: CONTENT.philology, + audio: CONTENT.audio, + falseTrail: CONTENT.falseTrail, + orderForm: CONTENT.order, + }; + + const NEXT = { + caseNumber: "caseOpened", + caseOpened: "scene", + scene: "witnesses", + witnesses: "philology", + philology: "audio", + audio: "falseTrail", + falseTrail: "orderForm", + orderForm: "twist", + twist: "congrats", + congrats: "orderScreen", + }; + + /* ---------------- состояние ---------------- */ + + const defaultState = () => ({ + stage: "intro", + solved: {}, // stageId -> true + journal: [], // накопленные факты + mistakes: {}, + hintRequested: {}, + tellsFound: [], // найденные признаки подделки в рапорте + orderFilled: {}, // подтверждённые графы ордера + easter: {}, + muted: localStorage.getItem(MUTE_KEY) === "1", + }); + + let state = load(); + + function load() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? Object.assign(defaultState(), JSON.parse(raw)) : defaultState(); + } catch (e) { + return defaultState(); + } + } + function save() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} + } + function resetAll() { + localStorage.removeItem(STORAGE_KEY); + state = defaultState(); + render(); + } + + /* ---------------- звук ---------------- */ + + const Sound = (function () { + let ctx = null; + function ensure() { + if (!ctx) { + const AC = window.AudioContext || window.webkitAudioContext; + if (AC) ctx = new AC(); + } + if (ctx && ctx.state === "suspended") ctx.resume(); + return ctx; + } + function tone(freq, dur, type, peak, delay) { + if (state.muted) return; + const c = ensure(); + if (!c) return; + const t0 = c.currentTime + (delay || 0); + const osc = c.createOscillator(); + const g = c.createGain(); + osc.type = type || "sine"; + osc.frequency.setValueAtTime(freq, t0); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(peak || 0.07, t0 + 0.015); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + osc.connect(g); g.connect(c.destination); + osc.start(t0); osc.stop(t0 + dur + 0.02); + } + return { + click() { tone(620, 0.045, "square", 0.025); }, + success() { tone(523, 0.09, "sine", 0.06); tone(784, 0.14, "sine", 0.05, 0.09); }, + error() { tone(150, 0.15, "sawtooth", 0.04); }, + stamp() { tone(90, 0.09, "square", 0.085); tone(68, 0.13, "square", 0.06, 0.03); }, + warm() { tone(392, 0.3, "sine", 0.045); tone(523, 0.35, "sine", 0.035, 0.12); tone(659, 0.5, "sine", 0.035, 0.24); }, + }; + })(); + + /* ---------------- утилиты ---------------- */ + + const digits = (s) => ((s || "").match(/\d+/g) || []).join(""); + const word = (s) => (s || "").trim().toLowerCase().replace(/ё/g, "е"); + const bare = (s) => (s || "").toUpperCase().replace(/[\s\-_.·№]/g, "").replace(/Ё/g, "Е"); + + /* Время приводим к чч:мм, чтобы «9:08», «09.08» и «0908» считались + тем же ответом, что и «09:08». */ + function normTime(s) { + const m = (s || "").trim().match(/^(\d{1,2})\s*[:.\-\s]?\s*(\d{2})$/); + if (!m) return null; + const h = +m[1], min = +m[2]; + if (h > 23 || min > 59) return null; + return String(h).padStart(2, "0") + ":" + m[2]; + } + + /* Сравнение ответа для конкретной графы ордера. */ + function matchesFact(fieldId, value) { + if (fieldId === "exitTime") { + const t = normTime(value); + return t !== null && t === FACTS.exitTime; + } + return bare(value) === bare(FACTS[fieldId]); + } + + function mistakesFor(id) { return state.mistakes[id] || 0; } + function addMistake(id) { state.mistakes[id] = mistakesFor(id) + 1; save(); } + function autoHint(id) { + const m = mistakesFor(id); + return m >= 8 ? 3 : m >= 5 ? 2 : m >= 3 ? 1 : 0; + } + function hintLevel(id) { return Math.max(autoHint(id), state.hintRequested[id] || 0); } + function bumpHint(id, max) { + const cur = state.hintRequested[id] || 0; + if (cur < max) state.hintRequested[id] = cur + 1; + save(); + } + function canReveal(id) { return mistakesFor(id) >= 8; } + function pick(arr, i) { return arr[i % arr.length]; } + + function addFact(ev) { + if (state.journal.some((j) => j.name === ev.name)) return; + state.journal.push({ name: ev.name, fact: ev.fact, factLabel: ev.factLabel, desc: ev.desc }); + save(); + } + function markSolved(id) { state.solved[id] = true; save(); } + + function go(stage) { + state.stage = stage; + save(); + render(); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + function esc(s) { + const d = document.createElement("div"); + d.textContent = s == null ? "" : String(s); + return d.innerHTML; + } + function paras(lines) { + return (Array.isArray(lines) ? lines : [lines]) + .map((l) => (l === "" ? '
' : `

${esc(l)}

`)) + .join(""); + } + + /* Фидбек переживает перерисовку: render() пересобирает DOM целиком, + поэтому текст ошибки кладём сюда и забираем на следующем рендере. */ + let pending = {}; + /* То же для граф ордера: у каждой свой текст ошибки и своё введённое + значение, которые обязаны пережить перерисовку. */ + let pendingOrder = {}; + function setFeedback(id, text, cls, value) { pending[id] = { text, cls, value }; } + function takeFeedback(fbEl, id, inputEl) { + const f = pending[id]; + if (!f) return; + delete pending[id]; + fbEl.innerHTML = `${esc(f.text)}`; + fbEl.className = "feedback " + f.cls; + if (inputEl && f.value != null) { + inputEl.value = f.value; + inputEl.focus(); + try { inputEl.setSelectionRange(f.value.length, f.value.length); } catch (e) {} + } + } + + /* ---------------- каркас ---------------- */ + + const app = document.getElementById("app"); + + function render() { + app.innerHTML = ""; + document.body.className = ["twist", "congrats", "orderScreen"].includes(state.stage) ? "warm" : ""; + + const wrap = document.createElement("div"); + wrap.className = "shell"; + + if (!BARE.has(state.stage)) { + wrap.appendChild(topbar()); + wrap.appendChild(progressRail()); + } + + const main = document.createElement("main"); + main.className = "stage"; + main.appendChild(stageView(state.stage)); + wrap.appendChild(main); + + if (!BARE.has(state.stage)) { + wrap.appendChild(dock()); + wrap.appendChild(footer()); + } + app.appendChild(wrap); + } + + function topbar() { + const el = document.createElement("header"); + el.className = "topbar"; + const hintable = !!PUZZLES[state.stage] && !state.solved[state.stage]; + /* Номер дела — ответ первой загадки, до её решения его не показываем. */ + const caseLabel = state.solved.caseNumber ? "дело № " + CONFIG.caseNumber : "дело № ——————"; + el.innerHTML = ` +
+ + ${esc(caseLabel)} +
+
+ + + +
`; + el.querySelector('[data-a="journal"]').onclick = openJournal; + el.querySelector('[data-a="hint"]').onclick = () => openHint(state.stage); + el.querySelector('[data-a="sound"]').onclick = () => { + state.muted = !state.muted; + localStorage.setItem(MUTE_KEY, state.muted ? "1" : "0"); + render(); + }; + return el; + } + + function progressRail() { + const el = document.createElement("div"); + el.className = "rail"; + const curIdx = CHAPTERS.findIndex((c) => c.stage === state.stage); + el.innerHTML = CHAPTERS.map((c, i) => { + const done = state.solved[c.stage]; + const cur = i === curIdx; + return `
+ ${done ? "✓" : i + 1} + ${esc(c.label)} +
`; + }).join(""); + return el; + } + + function dock() { + const e = CONTENT.easter; + const el = document.createElement("div"); + el.className = "dock"; + el.innerHTML = ` + + + + `; + el.querySelector('[data-a="speed"]').onclick = () => { + const n = (state.easter.speed = (state.easter.speed || 0) + 1); + Sound.click(); save(); toast(n === 1 ? e.speedFirst : e.speedSecond); + }; + el.querySelector('[data-a="confused"]').onclick = () => { + const n = (state.easter.confused = (state.easter.confused || 0) + 1); + Sound.click(); save(); toast(n === 1 ? e.confusedFirst : e.confusedSecond); + }; + el.querySelector('[data-a="complaint"]').onclick = () => { Sound.click(); toast(e.complaintResult); }; + el.querySelector('[data-a="reset"]').onclick = () => { + const now = Date.now(); + if (resetArmed && now - resetArmed < 4000) { Sound.stamp(); resetArmed = 0; resetAll(); } + else { resetArmed = now; Sound.click(); toast(e.resetArm); } + }; + return el; + } + let resetArmed = 0; + + function footer() { + const f = document.createElement("div"); + f.className = "footer"; + f.textContent = CONTENT.easter.footer; + return f; + } + + /* Clipboard API недоступен при открытии страницы файлом (file://) и + в ряде мобильных браузеров, а writeText умеет отклоняться. Поэтому + запасной путь через execCommand и честное сообщение, если не вышло. */ + function copyText(text) { + const fallback = () => { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.cssText = "position:fixed;top:0;left:0;opacity:0"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + toast(ok ? "Скопировано." : "Скопировать не удалось — выделите текст выше вручную."); + } catch (e) { + toast("Скопировать не удалось — выделите текст выше вручную."); + } + }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => toast("Скопировано."), fallback); + } else { + fallback(); + } + } + + let toastT = null; + function toast(text) { + let t = document.getElementById("toast"); + if (!t) { t = document.createElement("div"); t.id = "toast"; document.body.appendChild(t); } + t.textContent = text; + t.classList.add("on"); + clearTimeout(toastT); + toastT = setTimeout(() => t.classList.remove("on"), 3800); + } + + /* ---------------- панели ---------------- */ + + function closePanels() { document.querySelectorAll(".backdrop").forEach((n) => n.remove()); } + + function panel(title, bodyHtml, cls) { + closePanels(); + const b = document.createElement("div"); + b.className = "backdrop"; + b.innerHTML = ` + `; + b.onclick = (e) => { if (e.target === b) closePanels(); }; + b.querySelector('[data-a="x"]').onclick = closePanels; + document.body.appendChild(b); + return b; + } + + function openJournal() { + const body = state.journal.length === 0 + ? '

Журнал пуст. Следствие в самом начале.

' + : `

Установленные факты. В финале понадобятся все.

` + + state.journal.map((j) => ` +
+
${esc(j.name)}
+
${esc(j.factLabel)}${esc(j.fact)}
+
${esc(j.desc)}
+
`).join(""); + panel("Журнал дела", body); + } + + function openHint(stageId) { + const cfg = PUZZLES[stageId]; + if (!cfg || !cfg.hints) return; + bumpHint(stageId, cfg.hints.length); + const lvl = hintLevel(stageId); + const body = ` +

${esc(pick(COMMON.hintIntro, lvl))}

+ ${cfg.hints.slice(0, lvl).map((h, i) => `

${i + 1}. ${esc(h)}

`).join("")} + ${lvl === 0 ? '

Подсказка ещё не раскрыта. Нажмите ещё раз.

' : ""}`; + panel("Подсказка", body, "panel-hint"); + } + + /* ---------------- строительные блоки ---------------- */ + + function sheet(html, cls) { + const d = document.createElement("section"); + d.className = "sheet" + (cls ? " " + cls : ""); + d.innerHTML = html; + return d; + } + function btn(label, cls) { + const b = document.createElement("button"); + b.className = "btn " + (cls || "primary"); + b.textContent = label; + return b; + } + function row(nodes) { + const r = document.createElement("div"); + r.className = "row"; + nodes.forEach((n) => r.appendChild(n)); + return r; + } + function head(chapter, title) { + return `
+
${esc(chapter)}
+

${esc(title)}

+
`; + } + /* Блок успеха с карточкой добытого факта */ + function successBlock(title, text, ev) { + return `
+
✓${esc(title)}
+

${esc(text)}

+ ${ev ? `
+
${esc(ev.name)}
+
${esc(ev.factLabel)}${esc(ev.fact)}
+
` : ""} +
`; + } + function revealLink(stageId, onClick) { + if (!canReveal(stageId)) return null; + const b = document.createElement("button"); + b.className = "linkbtn"; + b.textContent = "Следствие настаивает на правильном ответе"; + b.onclick = onClick; + return b; + } + + /* ============================================================ + ЭКРАНЫ + ============================================================ */ + + function stageView(s) { + switch (s) { + case "intro": return vIntro(); + case "loading": return vLoading(); + case "caseNumber": return vCaseNumber(); + case "caseOpened": return vCaseOpened(); + case "scene": return vScene(); + case "witnesses": return vWitnesses(); + case "philology": return vPhilology(); + case "audio": return vAudio(); + case "falseTrail": return vFalseTrail(); + case "orderForm": return vOrderForm(); + case "twist": return vTwist(); + case "congrats": return vCongrats(); + case "orderScreen": return vOrderScreen(); + default: return sheet("

Раздел ушёл в отпуск по сюжетным обстоятельствам.

"); + } + } + + /* ---- интро ---- */ + function vIntro() { + const c = CONTENT.intro; + const el = document.createElement("div"); + el.className = "center-wrap"; + el.innerHTML = ` +
+
${esc(c.tag)}
+

${esc(c.title)}

+ ${paras(c.body)} +

${esc(c.footnote)}

+
`; + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("loading"); }; + el.querySelector(".intro").insertBefore(row([b]), el.querySelector(".note")); + return el; + } + + /* ---- загрузка ---- */ + function vLoading() { + const c = CONTENT.loadingScreen; + const el = document.createElement("div"); + el.className = "boot"; + el.innerHTML = ` +

${esc(c.system)}

+

${esc(c.subtitle)}

+
`; + const box = el.querySelector(".boot-lines"); + c.lines.forEach((l, i) => { + const p = document.createElement("p"); + p.textContent = l; + p.style.animationDelay = i * 0.5 + "s"; + box.appendChild(p); + }); + setTimeout(() => { + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("caseNumber"); }; + const r = row([b]); + r.classList.add("center"); + el.appendChild(r); + }, c.lines.length * 500 + 400); + return el; + } + + /* ---- глава 0: номер дела ---- */ + function vCaseNumber() { + const c = CONTENT.caseNumber; + const id = "caseNumber"; + const el = sheet(` + ${head(c.chapter, c.title)} + ${paras(c.body)} +
+
${esc(c.ruleTitle)}
+

${esc(c.ruleText)}

+
+
+
${esc(c.registryTitle)}
+ ${c.registryRows.map(([k, v]) => `
${esc(k)}${esc(v)}
`).join("")} +
+
+ + +
+
`); + const input = el.querySelector("#in"); + const fb = el.querySelector("#fb"); + takeFeedback(fb, id, input); + const submit = () => { + const raw = input.value; + if (digits(raw) === CONFIG.caseNumber) { + Sound.success(); markSolved(id); go("caseOpened"); + } else { + Sound.error(); addMistake(id); + setFeedback(id, pick(c.wrong, mistakesFor(id) - 1), "bad shake", raw); + render(); + } + }; + el.querySelector("#go").onclick = submit; + input.onkeydown = (e) => { if (e.key === "Enter") submit(); }; + const rv = revealLink(id, () => { input.value = CONFIG.caseNumber; input.focus(); }); + if (rv) el.appendChild(rv); + return el; + } + + /* ---- дело открыто ---- */ + function vCaseOpened() { + const c = CONTENT.caseOpened; + const el = sheet(`${head("Допуск получен", c.title)}${paras(c.body)}`); + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("scene"); }; + el.appendChild(row([b])); + return el; + } + + /* ---- глава 1: опись ---- */ + function vScene() { + const c = CONTENT.scene; + const id = "scene"; + const done = !!state.solved[id]; + const el = sheet(` + ${head(c.chapter, c.title)} + ${paras(c.body)} +
+
+
${esc(c.protocolTitle)}
+ ${c.inventory.map(([n, d]) => `
${esc(n)}${esc(d)}
`).join("")} +
${esc(c.protocolNote)}
+
+
${desk()}
+
+
+
`, "wide"); + + const fb = el.querySelector("#fb"); + const after = el.querySelector("#after"); + + if (done) { + after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence); + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("witnesses"); }; + after.appendChild(row([b])); + el.querySelector('[data-id="box"]').classList.add("found"); + } else { + takeFeedback(fb, id); + el.querySelectorAll(".hit").forEach((n) => { + n.onclick = () => { + const hid = n.getAttribute("data-id"); + if (hid === c.correctId) { + Sound.success(); addFact(c.evidence); markSolved(id); render(); + } else { + Sound.error(); addMistake(id); + setFeedback(id, c.reactions[hid] || "Предмет осмотрен. Предмет молчит.", "bad"); + render(); + } + }; + }); + const rv = revealLink(id, () => el.querySelector('[data-id="box"]').classList.add("pulse")); + if (rv) el.appendChild(rv); + } + return el; + } + + /* Стол с предметами и читаемыми бирками */ + function desk() { + const item = (id, x, y, w, h, tag, inner) => ` + + ${inner} + + + ${tag} + + `; + + return ` + + + + + + + + + ${Array.from({ length: 14 }, (_, i) => ``).join("")} + + + ${item("folder", 26, 26, 128, 82, "БКТО-14", + ` + + ДЕЛО № + ${esc(CONFIG.caseNumber)}`)} + + ${item("mug", 196, 40, 54, 54, "БКТО-12", + ` + + НЕ + ТРОГАТЬ`)} + + ${item("calendar", 292, 30, 84, 68, "БКТО-13", + ` + + 26 + `)} + + ${item("lamp", 424, 22, 76, 96, "БКТО-11", + ` + + + `)} + + ${item("pencil", 34, 190, 116, 12, "БКТО-15", + ` + `)} + + ${item("paper", 176, 168, 170, 92, "БКТО-17", + ` + ПЕЧАТЬ + НЕ ТРОГАТЬ + (печать отсутствует)`)} + + ${item("cactus", 424, 176, 54, 84, "БКТО-16", + ` + + `)} + + ${item("box", 246, 306, 104, 66, "ДС-04", + ` + + + хрупкое. сюжет внутри`)} + `; + } + + /* ---- глава 2: журнал и показания ---- */ + function vWitnesses() { + const c = CONTENT.witnesses; + const id = "witnesses"; + const done = !!state.solved[id]; + const el = sheet(` + ${head(c.chapter, c.title)} + ${paras(c.body)} +
+
${esc(c.logTitle)}
+
+ ${c.logRows.map(([t, n, d]) => ` +
+ ${esc(t)} + ${esc(n)} + ${esc(d)} +
`).join("")} +
+
+
+
+
`); + + const cards = el.querySelector("#cards"); + c.cards.forEach((w) => { + const b = document.createElement("button"); + b.className = "pcard"; + b.disabled = done; + if (w.id === c.correct) b.dataset.correct = "1"; + if (done && w.id === c.correct) b.classList.add("found"); + b.innerHTML = ` +
${esc(w.name)}${esc(w.role)}
+
${esc(w.text)}
`; + b.onclick = () => { + if (w.id === c.correct) { Sound.success(); addFact(c.evidence); markSolved(id); render(); } + else { + Sound.error(); addMistake(id); + setFeedback(id, c.wrongReactions[w.id], "bad"); + render(); + } + }; + cards.appendChild(b); + }); + + const after = el.querySelector("#after"); + if (done) { + after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence); + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("philology"); }; + after.appendChild(row([b])); + } else { + takeFeedback(el.querySelector("#fb"), id); + const rv = revealLink(id, () => el.querySelector('[data-correct="1"]').classList.add("pulse")); + if (rv) el.appendChild(rv); + } + return el; + } + + /* ---- глава 3: филология ---- */ + function vPhilology() { + const c = CONTENT.philology; + const id = "philology"; + const done = !!state.solved[id]; + const el = sheet(` + ${head(c.chapter, c.title)} + ${paras(c.body)} +
+
${esc(c.memoTitle)}
+

${esc(c.memoText)}

+
+
+
+
`); + + const docs = el.querySelector("#docs"); + c.docs.forEach((d) => { + const b = document.createElement("button"); + b.className = "doc"; + b.disabled = done; + if (d.id === c.correct) b.dataset.correct = "1"; + if (done && d.id === c.correct) b.classList.add("found"); + b.innerHTML = ` +
Документ № ${d.id}${esc(d.head)}
+
${esc(d.text)}
+
${esc(d.sign)}
`; + b.onclick = () => { + if (d.id === c.correct) { Sound.success(); addFact(c.evidence); markSolved(id); render(); } + else { + Sound.error(); addMistake(id); + setFeedback(id, c.wrongReactions[d.id], "bad"); + render(); + } + }; + docs.appendChild(b); + }); + + const after = el.querySelector("#after"); + if (done) { + after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence); + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("audio"); }; + after.appendChild(row([b])); + } else { + takeFeedback(el.querySelector("#fb"), id); + const rv = revealLink(id, () => el.querySelector('[data-correct="1"]').classList.add("pulse")); + if (rv) el.appendChild(rv); + } + return el; + } + + /* ---- глава 4: аудио ---- */ + function vAudio() { + const c = CONTENT.audio; + const id = "audio"; + const done = !!state.solved[id]; + const el = sheet(` + ${head(c.chapter, c.title)} + ${paras(c.body)} +
+
+
${esc(c.songTitle)}
${esc(c.songAuthor)}
+
+ + +
+
+
+ + +
+
+
`); + + const ly = el.querySelector("#lyrics"); + c.lyrics.forEach((bl) => { + const d = document.createElement("div"); + d.className = "lblock " + bl.type; + const isBridge = bl.type === "bridge"; + /* Первые буквы бриджа намеренно НЕ подсвечиваем: найти акростих — + и есть загадка. Блок выделен визуально («куда смотреть»), + а «как читать» объясняют подсказки. */ + d.innerHTML = `
${esc(bl.label)}${isBridge ? ` ${esc(c.bridgeLabel)}` : ""}
` + + bl.lines.map((l) => `

${esc(l)}

`).join(""); + ly.appendChild(d); + }); + + const player = el.querySelector("#player"); + player.addEventListener("error", () => { + player.hidden = true; + el.querySelector("#sfb").hidden = false; + }); + + const after = el.querySelector("#after"); + if (done) { + after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence); + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("falseTrail"); }; + after.appendChild(row([b])); + } else { + const input = el.querySelector("#in"); + const fb = el.querySelector("#fb"); + takeFeedback(fb, id, input); + const submit = () => { + const v = word(input.value); + if (v === word(c.correct)) { + Sound.success(); addFact(c.evidence); markSolved(id); render(); return; + } + Sound.error(); addMistake(id); + let msg; + if (v === "ожидание" || v === "ожиданье") msg = c.wrongHints.ozhidanie; + else if (v === "сюжет") msg = c.wrongHints.syuzhet; + else msg = pick(c.wrong, mistakesFor(id) - 1); + setFeedback(id, msg, "bad shake", input.value); + render(); + }; + el.querySelector("#go").onclick = submit; + input.onkeydown = (e) => { if (e.key === "Enter") submit(); }; + const rv = revealLink(id, () => { input.value = c.correct; input.focus(); }); + if (rv) el.appendChild(rv); + } + return el; + } + + /* ---- глава 5: ложный след ---- */ + function vFalseTrail() { + const c = CONTENT.falseTrail; + const id = "falseTrail"; + const done = !!state.solved[id]; + const total = c.reportParts.filter((p) => p.tell).length; + + const el = sheet(` + ${head(c.chapter, c.title)} +
${paras(c.alertBody)}
+

${esc(c.intro)}

+
+
${esc(c.reportTitle)}
+

+
печать имеется
+
+ ${done ? "" : `

${esc(c.task)}

+
${esc(c.counter)}: ${state.tellsFound.length} / ${total}
`} +
+
`); + + const rb = el.querySelector("#rbody"); + c.reportParts.forEach((p) => { + if (p.tell) { + const sp = document.createElement("button"); + sp.className = "tell" + (state.tellsFound.includes(p.id) ? " got" : ""); + sp.textContent = p.t; + sp.disabled = done || state.tellsFound.includes(p.id); + sp.onclick = () => { + if (state.tellsFound.includes(p.id)) return; + state.tellsFound.push(p.id); + save(); + const r = c.tellReactions[p.id]; + Sound.success(); + if (state.tellsFound.length >= total) { + addFact(c.evidence); markSolved(id); + } + setFeedback(id, r.title + " — " + r.text, "good"); + render(); + }; + rb.appendChild(sp); + } else { + const sp = document.createElement("span"); + sp.className = "plain"; + sp.textContent = p.t; + if (!done) { + sp.onclick = () => { + Sound.error(); addMistake(id); + setFeedback(id, c.plainReactions[p.id] || "Ничего примечательного.", "bad"); + render(); + }; + } + rb.appendChild(sp); + } + }); + + const after = el.querySelector("#after"); + if (done) { + after.innerHTML = successBlock(c.successTitle, c.successBody, c.evidence) + + `

${esc(c.kaverzaQuote)}

`; + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("orderForm"); }; + after.appendChild(row([b])); + } else { + takeFeedback(el.querySelector("#fb"), id); + const rv = revealLink(id, () => el.querySelectorAll(".tell").forEach((t) => t.classList.add("pulse"))); + if (rv) el.appendChild(rv); + } + return el; + } + + /* ---- глава 6: форма ордера ---- */ + function vOrderForm() { + const c = CONTENT.order; + const id = "orderForm"; + const el = sheet(` + ${head(c.chapter, c.title)} + ${paras(c.body)} +
+
`); + + const form = el.querySelector("#form"); + c.fields.forEach((f) => { + const ok = !!state.orderFilled[f.id]; + const prev = pendingOrder[f.id] || {}; + const shown = ok ? FACTS[f.id] : (prev.value || ""); + const g = document.createElement("div"); + g.className = "fgroup" + (ok ? " ok" : ""); + g.innerHTML = ` + +
+ + ${ok ? "✓" : ""} +
+
источник: ${esc(f.hintSource)}
+
${ok ? "" : esc(prev.error || "")}
`; + form.appendChild(g); + }); + pendingOrder = {}; + + const submitAll = () => { + let anyWrong = false; + const carry = {}; + c.fields.forEach((f) => { + if (state.orderFilled[f.id]) return; + const inp = el.querySelector("#f_" + f.id); + const v = inp.value.trim(); + if (v === "") return; + if (matchesFact(f.id, v)) { + state.orderFilled[f.id] = true; + save(); + } else { + anyWrong = true; + carry[f.id] = { value: inp.value, error: f.wrong }; + } + }); + const filled = c.fields.every((f) => state.orderFilled[f.id]); + if (filled) { + Sound.stamp(); markSolved(id); go("twist"); return; + } + pendingOrder = carry; + if (anyWrong) { Sound.error(); addMistake(id); } + else { Sound.click(); } + render(); + if (!anyWrong) toast(c.partial); + }; + + const b = btn(c.button); + b.onclick = submitAll; + el.appendChild(row([b])); + el.querySelectorAll(".fgroup input").forEach((i) => { + i.onkeydown = (e) => { if (e.key === "Enter") submitAll(); }; + }); + const rv = revealLink(id, () => { + c.fields.forEach((f) => { + const inp = el.querySelector("#f_" + f.id); + if (inp && !inp.disabled) inp.value = FACTS[f.id]; + }); + }); + if (rv) el.appendChild(rv); + return el; + } + + /* ---- твист ---- */ + function vTwist() { + const c = CONTENT.twist; + const el = document.createElement("div"); + el.className = "twist"; + const box = document.createElement("div"); + box.className = "twist-lines"; + el.appendChild(box); + c.lines.forEach((l, i) => { + const p = document.createElement("p"); + p.textContent = l; + p.style.animationDelay = i * 1.05 + "s"; + box.appendChild(p); + }); + const t = c.lines.length * 1050; + setTimeout(() => Sound.stamp(), t + 200); + setTimeout(() => { + const st = document.createElement("div"); + st.className = "bigstamp"; + st.textContent = c.stamp; + el.appendChild(st); + const b = btn(c.button); + b.onclick = () => { Sound.warm(); go("congrats"); }; + const r = row([b]); r.classList.add("center"); + el.appendChild(r); + }, t + 450); + return el; + } + + /* ---- поздравление ---- */ + function vCongrats() { + const c = CONTENT.congrats; + const el = document.createElement("div"); + el.className = "center-wrap"; + el.innerHTML = `

${esc(c.title)}

${paras(c.body)}
`; + const b = btn(c.button); + b.onclick = () => { Sound.click(); go("orderScreen"); }; + const r = row([b]); r.classList.add("center"); + el.querySelector(".sheet").appendChild(r); + return el; + } + + /* ---- ордер и QR ---- */ + function vOrderScreen() { + const c = CONTENT.orderScreen; + const el = document.createElement("div"); + el.className = "center-wrap"; + el.innerHTML = ` +
+

${esc(c.title)}

+
+ ${c.rows.map(([k, v]) => `
${esc(k)}${esc(v)}
`).join("")} +
+

${esc(c.qrNote)}

+
+
+
Адрес выдачи${esc(CONFIG.pvzAddress)}
+
Кодовое слово${esc(CONFIG.pickupCode)}
+
Дело№ ${esc(CONFIG.caseNumber)}
+
+
+
${esc(c.claimTitle)}
+

${esc(c.claimLead)}

+
    + ${c.claimSteps.map((s) => `
  1. ${esc(s)}
  2. `).join("")} +
+

${esc(c.claimNote)}

+
+

${esc(c.joke)}

+

${esc(c.reserveNote)}

+
`; + try { + /* Держим текст QR коротким: чем он длиннее, тем плотнее код и тем + хуже он ловится камерой. Подробная инструкция — на экране рядом. */ + const text = CONFIG.pvzPageUrl || + `${CONFIG.pvzAddress}. Кодовое слово: ${CONFIG.pickupCode}. Дело №${CONFIG.caseNumber}.`; + const q = qrcode(0, "M"); + q.addData(text); + q.make(); + el.querySelector("#qr").innerHTML = q.createSvgTag(6, 8); + } catch (e) { + el.querySelector("#qr").innerHTML = '

QR не сформирован. Используйте данные ниже.

'; + } + const cp = btn(c.copyButton, "ghost"); + cp.onclick = () => { + const t = `Адрес выдачи: ${CONFIG.pvzAddress}\n` + + `Кодовое слово: ${CONFIG.pickupCode} (напишите его мне в ЛС — пришлю штрихкод)\n` + + `Дело № ${CONFIG.caseNumber}`; + copyText(t); + }; + const r = row([cp]); r.classList.add("center"); + el.querySelector(".order-final").appendChild(r); + return el; + } + + render(); +})(); diff --git a/data.js b/data.js new file mode 100644 index 0000000..092defb --- /dev/null +++ b/data.js @@ -0,0 +1,546 @@ +/* ============================================================ + ДЕЛО № 260826. ИСЧЕЗНОВЕНИЕ ПОДАРКА — версия 2 + Конфигурация и содержимое. + Правьте блок CONFIG — остальное трогать не обязательно. + ============================================================ */ + +const CONFIG = { + name: "Алиса", + caseNumber: "260826", + caseDateWords: "двадцать шестое августа две тысячи двадцать шестого года", + + pvzAddress: "Москва, Кочновский пр-д, 4 к. 1. ПВЗ Озон", + + /* Штрихкод не показывается в квесте: именинница присылает это кодовое + слово в личные сообщения, а штрихкод отправляется в ответ. */ + pickupCode: "АРБУЗ", + + pvzPageUrl: "", + + songSrc: "assets/song.mp3", +}; + +/* Три факта, которые игрок добывает по ходу дела и вписывает + в финальную форму ордера. Меняются только вместе с загадками. */ +const FACTS = { + exitTime: "09:08", // глава 2: журнал турникета + foreignTag: "ДС-04", // глава 1: бирка сторонней службы + codeWord: "ПАУЗА", // глава 4: акростих бриджа +}; + +const COMMON = { + hintIntro: [ + "Следствие разрешает воспользоваться подсказкой. Это не считается поражением. Хотя статистика говорит об обратном.", + "Подсказка выдана. Бюро делает вид, что это было предусмотрено.", + "Следствие идёт навстречу. Один раз. И ещё один. И, кажется, ещё.", + ], + wrongGeneric: [ + "Ответ не подтверждён. Следователь сделал вид, что так и задумано.", + "Нет. Но уверенность впечатляет.", + "Такая версия рассматривалась ещё в 1974 году. Не спрашивайте почему.", + "Система вежливо не согласна. И просит передать это деликатно.", + "Ошибка. Один из документов тихо извинился.", + "Не подтверждается. Но в отчёте можно написать «смелая гипотеза».", + ], +}; + +const CONTENT = { + + /* ---------- ПРОЛОГ ---------- */ + intro: { + tag: "УВЕДОМЛЕНИЕ БКТО", + title: "Объект задержан", + body: [ + "В отношении объекта, предназначавшегося вам в качестве подарка, открыто дело.", + "Статус объекта: ВРЕМЕННО ОГРАНИЧЕН.", + "Прямое раскрытие сюрприза запрещено регламентом. Всё, что вам могут дать, — документы.", + ], + button: "Принять дело к производству", + footnote: "Отказ не предусмотрен. День рождения уже в процессе.", + }, + + loadingScreen: { + system: "ГИС «ПАПКА»", + subtitle: "Подарочный Архив · Праздничный Контроль · Финальная Торжественность · Абсурд", + lines: [ + "Проверка праздничного статуса…", + "Сверка папок…", + "Папки найдены. Одна моргнула.", + "Загрузка материалов дела…", + ], + button: "Войти в систему", + }, + + /* ---------- ГЛАВА 0. НОМЕР ДЕЛА ---------- */ + caseNumber: { + chapter: "Допуск", + title: "ПОДТВЕРЖДЕНИЕ ДОСТУПА", + body: [ + "Доступ к материалам открыт только получателю объекта.", + "Номер дела не сообщается напрямую: он формируется автоматически.", + ], + ruleTitle: "ВЫПИСКА ИЗ РЕГЛАМЕНТА, П. 4.2", + ruleText: "Номер дела образуется из даты регистрации в формате ДДММГГ, без разделителей.", + registryTitle: "КАРТОЧКА РЕГИСТРАЦИИ", + registryRows: [ + ["Заявитель", "под грифом ДРУЖБА"], + ["Дата регистрации", CONFIG.caseDateWords], + ["Предмет", "объект торжественного назначения, 1 шт."], + ], + placeholder: "Номер дела", + button: "Открыть дело", + wrong: [ + "Номер дела не совпадает. Бюрократия тихо плачет в папку.", + "Дело с таким номером существует, но оно про кота. Не ваше.", + "Не сходится. Регламент напоминает: сначала день, потом месяц, потом год. Все по две цифры.", + ], + hints: [ + "Дата регистрации записана прописью. Переведите её в цифры.", + "Формат ДДММГГ: день, месяц и год — каждый ровно двумя цифрами, подряд, без точек.", + "Двадцать шестое августа две тысячи двадцать шестого — это " + CONFIG.caseNumber + ".", + ], + }, + + caseOpened: { + title: "ДЕЛО ОТКРЫТО", + body: [ + "Вам присвоен статус: временный следователь по собственному делу.", + "", + "Права: ошибаться, сомневаться, запрашивать подсказки, быть именинницей.", + "Ограничения: нельзя пропустить день рождения.", + "", + "Бюро обращает внимание: всё, что вы установите, попадёт в журнал дела. В финале журнал понадобится целиком.", + ], + button: "Осмотреть место происшествия", + }, + + /* ---------- ГЛАВА 1. ОПИСЬ ---------- */ + scene: { + chapter: "Глава 1", + title: "МЕСТО ПРОИСШЕСТВИЯ", + body: [ + "Снимок рабочего стола, сделанный после исчезновения объекта.", + "Каждый предмет Бюро снабжает инвентарной биркой. Все предметы отдела имеют префикс БКТО.", + "", + "Задача: найти на столе предмет, которого нет в описи.", + ], + protocolTitle: "ОПИСЬ ИМУЩЕСТВА ОТДЕЛА", + protocolNote: "Проверено вручную. Примерно.", + inventory: [ + ["БКТО-11", "лампа настольная, зелёная"], + ["БКТО-12", "кружка с надписью «НЕ ТРОГАТЬ»"], + ["БКТО-13", "календарь настенный"], + ["БКТО-14", "папка дела"], + ["БКТО-15", "карандаш"], + ["БКТО-16", "кактус (эмоционально устойчив)"], + ["БКТО-17", "лист с надписью «ПЕЧАТЬ НЕ ТРОГАТЬ»"], + ], + correctId: "box", + successTitle: "Предмет вне описи обнаружен", + successBody: "Картонная коробка с биркой " + FACTS.foreignTag + ". Префикс не принадлежит Бюро: это служба доставки «Доставка с чувством». Коробка приехала снаружи — и, судя по всему, уехала обратно.", + reactions: { + lamp: "Бирка БКТО-11. Лампа в описи. Лампа отрицает участие, но свет у неё подозрительно уверенный.", + mug: "Бирка БКТО-12. Кружка в описи. Уликой не является, но морально поддерживает следствие.", + calendar: "Бирка БКТО-13. Календарь в описи. Число 26 обведено дважды — это личное дело календаря.", + folder: "Бирка БКТО-14. Папка уже в деле. Не заставляйте её волноваться повторно.", + pencil: "Бирка БКТО-15. Карандаш в описи. Готов сотрудничать, но ему нечего сказать.", + cactus: "Бирка БКТО-16. Кактус в описи и от показаний отказывается, ссылаясь на колючий характер.", + paper: "Бирка БКТО-17. Лист в описи. Надпись «ПЕЧАТЬ НЕ ТРОГАТЬ» соблюдена. Формально.", + }, + hints: [ + "Сверяйте бирки на предметах со списком в описи. Один предмет в списке отсутствует.", + "Бюро использует только префикс БКТО. На одной бирке префикс другой.", + "Картонная коробка справа внизу помечена «" + FACTS.foreignTag + "» — это чужая служба. Нажмите на неё.", + ], + evidence: { + name: "КОРОБКА", + fact: FACTS.foreignTag, + factLabel: "Инвентарный признак сторонней службы", + desc: "Картонная коробка службы «Доставка с чувством». В опись Бюро не входит.", + }, + button: "Приобщить к делу", + }, + + /* ---------- ГЛАВА 2. ЖУРНАЛ И ПОКАЗАНИЯ ---------- */ + witnesses: { + chapter: "Глава 2", + title: "СВЕРКА ПОКАЗАНИЙ", + body: [ + "Коробку кто-то вынес. Бюро подняло журнал турникета и опросило четверых.", + "", + "Задача: найти свидетеля, чьи показания физически невозможны — то есть противоречат журналу проходов.", + ], + logTitle: "ЖУРНАЛ ТУРНИКЕТА · 26 АВГУСТА", + logRows: [ + ["08:41", "Пылинкин А.", "вход"], + ["09:02", "Шлюз К. И.", "выход"], + ["09:06", "Быстрый Т.", "вход"], + ["09:08", "Быстрый Т.", "выход"], + ["09:12", "Шлюз К. И.", "вход"], + ["09:15", "Каверза Л. П.", "вход"], + ], + cards: [ + { + id: "klavdia", + name: "Клавдия Ивановна Шлюз", + role: "вахтёрша", + text: "«В 09:07 я была на посту. Мимо меня никто не проходил — ни туда, ни обратно. У меня глаз профессиональный».", + }, + { + id: "pylinkin", + name: "Архивариус Пылинкин", + role: "младший хранитель бумажных сущностей", + text: "«Около девяти мимо прошёл человек в шарфе. Папка при этом моргнула. Я доложил. Папке стало легче».", + }, + { + id: "timur", + name: "Тимур Быстрый", + role: "курьер службы «Доставка с чувством»", + text: "«Пришёл, забрал коробку, ушёл. Две минуты, не больше. Коробка была лёгкая. Подозрительно лёгкая».", + }, + { + id: "kaverza", + name: "Л. П. Каверза", + role: "филолог-консультант", + text: "«Когда я вошёл, на столе уже ничего не было. И сразу уточню: не “украдено”, а “изъято”. Это разные вещи».", + }, + ], + correct: "klavdia", + successTitle: "Противоречие установлено", + successBody: "По журналу Шлюз К. И. вышла в 09:02 и вернулась в 09:12. В 09:07 её не было в здании — значит, видеть коридор она не могла. Свидетель ошибается, не становясь преступником.", + wrongReactions: { + pylinkin: "Пылинкин вошёл в 08:41 и не выходил. Около девяти он действительно был внутри — журналу это не противоречит. Странность показаний ещё не делает их невозможными.", + timur: "Быстрый вошёл в 09:06 и вышел в 09:08. Ровно две минуты, как он и говорит. Журнал подтверждает его слово в точности.", + kaverza: "Каверза вошёл в 09:15 — уже после того, как коробку вынесли. Он и правда мог застать пустой стол. Противоречия нет.", + }, + hints: [ + "Возьмите время из каждого показания и проверьте по журналу, был ли этот человек в здании в тот момент.", + "Один свидетель называет время, когда он, судя по журналу, находился снаружи.", + "Шлюз вышла в 09:02 и вернулась в 09:12 — а утверждает, что была на посту в 09:07.", + ], + evidence: { + name: "ЖУРНАЛ", + fact: FACTS.exitTime, + factLabel: "Время выхода объекта из здания", + desc: "Коробку вынес курьер Быстрый Т. Турникет зафиксировал выход в " + FACTS.exitTime + ".", + }, + button: "Приобщить к делу", + }, + + /* ---------- ГЛАВА 3. ФИЛОЛОГИЯ ---------- */ + philology: { + chapter: "Глава 3", + title: "ФИЛОЛОГИЧЕСКАЯ ЭКСПЕРТИЗА", + body: [ + "Отдел нарративной безопасности сообщает: среди трёх документов дела один изготовлен не Бюро.", + "", + "Подделка может иметь подпись и даже печать. Но она не выдерживает проверки языком.", + ], + memoTitle: "ПАМЯТКА КОНСУЛЬТАНТА Л. П. КАВЕРЗЫ", + memoText: "«Предлог “согласно” управляет дательным падежом: согласно чему? — согласно приказу, протоколу, распоряжению. Форма “согласно приказа” — родительный падеж — в служебном документе невозможна. Запятая — это дверь; падеж — это замок».", + docs: [ + { + id: 1, + head: "Распоряжение № 4/тор", + text: "«Согласно приказу № " + CONFIG.caseNumber + ", объект подлежит проверке на торжественную пригодность».", + sign: "К. К. Пропавший-Без-Вести", + }, + { + id: 2, + head: "Служебная записка № 7", + text: "«В соответствии с протоколом № 7 свидетель обязан сохранять загадочность и не разглашать сюрприз в обеденный перерыв».", + sign: "З. М. Штемпель", + }, + { + id: 3, + head: "Постановление б/н", + text: "«Согласно приказа о похищении, подарок считается украденным, а филолог — подозрительным».", + sign: "подпись неразборчива", + }, + ], + correct: 3, + successTitle: "Подделка обнаружена", + successBody: "«Согласно приказа» — родительный падеж. Настоящее Бюро написало бы «согласно приказу». Отдел грамматики аплодирует, но по регламенту делает это беззвучно.", + wrongReactions: { + 1: "Документ № 1 оскорблён. Он всю жизнь использовал дательный падеж и не намерен это обсуждать: «согласно приказу» — безупречно.", + 2: "Документ № 2 загадочен, но законен. «В соответствии с протоколом» — корректная конструкция. Свидетель сохраняет загадочность; дело пока нет.", + }, + hints: [ + "Прочтите памятку Каверзы и проверьте по ней каждый документ.", + "Ищите документ, где после «согласно» стоит не дательный падеж.", + "Документ № 3: «согласно приказа» вместо «согласно приказу». Это подделка.", + ], + evidence: { + name: "ЭКСПЕРТИЗА", + fact: "согласно приказу", + factLabel: "Признак подлинности документа", + desc: "Настоящие документы Бюро используют дательный падеж. «Согласно приказа» — маркер подделки.", + }, + button: "Приобщить к делу", + }, + + /* ---------- ГЛАВА 4. АУДИО ---------- */ + audio: { + chapter: "Глава 4", + title: "АУДИОЭКСПЕРТИЗА", + body: [ + "Из архива поступила служебная песня. Бюро напоминает: музыка считается документом, если в ней есть хотя бы одна печать.", + "", + "В песне спрятано служебное слово. В самом тексте оно не звучит ни разу — иначе какая же это тайна.", + "", + "Припев намеренно подсовывает похожие слова. Не доверяйте припеву.", + ], + songTitle: "«Временное ограничение»", + songAuthor: "ВИА «Канцелярия» при участии Отдела музыкальной отчётности", + bridgeLabel: "служебная вставка", + lyrics: [ + { type: "verse", label: "Куплет 1", lines: [ + "В архиве пахнет пылью и чаем,", + "Объект на месте. Объект молчит.", + "Мы ничего не подозреваем —", + "Мы просто ждём, пока форма звучит.", + ]}, + { type: "chorus", label: "Припев", lines: [ + "Не украден, не забыт,", + "Он в законном состоянье.", + "Не потерян, не сокрыт —", + "Просто длится ожиданье.", + ]}, + { type: "verse", label: "Куплет 2", lines: [ + "Свидетель смотрит в журнал слишком долго,", + "Курьер уверяет, что вышел пустым.", + "Филолог поправит падеж — и только", + "Тогда документ перестанет быть злым.", + ]}, + { type: "chorus", label: "Припев", lines: [ + "Не украден, не забыт,", + "Он в законном состоянье.", + "Не потерян, не сокрыт —", + "Просто длится ожиданье.", + ]}, + { type: "bridge", label: "Бридж", lines: [ + "Пусть протокол никуда не спешит,", + "Архив тишину до конца сторожит.", + "Условие радости: дать ей отстояться,", + "Задержка объекта — не повод бояться,", + "А выдача будет. По форме. Не сразу.", + ]}, + { type: "chorus", label: "Финальный припев", lines: [ + "Не украден, не забыт,", + "Он почти уже открыт.", + "Не потерян, не сокрыт —", + "Форма выдачи звучит.", + ]}, + ], + placeholder: "Служебное слово", + button: "Подтвердить", + correct: FACTS.codeWord, + successTitle: "Служебное слово принято", + successBody: "Первые буквы бриджа складываются в «" + FACTS.codeWord + "». Именно этим словом Бюро обозначает состояние объекта: не украден, не потерян — поставлен на паузу до появления сюжета.", + wrongHints: { + ozhidanie: "Слово из припева. Припев вас именно на это и ловил. Служебное слово в тексте не звучит — его нужно собрать.", + syuzhet: "Красивая версия, но этого слова в бридже не собрать. Смотрите на первые буквы пяти строк служебной вставки.", + }, + wrong: [ + "Слово не принято. Музыкальная комиссия сделала вид, что это была репетиция.", + "Неверно. Хор Бюро выражает сочувствие в допустимом диапазоне громкости.", + "Не совпадает. Возможно, вы слушали слишком доверчиво.", + ], + hints: [ + "Служебная вставка — это бридж, пять строк, выделенных отдельно. Смотрите на начала строк.", + "Возьмите по одной первой букве каждой из пяти строк бриджа и прочтите сверху вниз.", + "П, А, У, З, А — получается «" + FACTS.codeWord + "».", + ], + evidence: { + name: "ЗАПИСЬ", + fact: FACTS.codeWord, + factLabel: "Служебное кодовое слово", + desc: "Акростих бриджа. Обозначает состояние объекта: задержан до появления сюжета.", + }, + button: "Приобщить к делу", + }, + + /* ---------- ГЛАВА 5. ЛОЖНЫЙ СЛЕД ---------- */ + falseTrail: { + chapter: "Глава 5", + title: "СРОЧНОЕ ОБНОВЛЕНИЕ", + alertBody: [ + "В дело поступил рапорт о похищении объекта.", + "Обвиняется филолог-консультант Л. П. Каверза.", + ], + reportTitle: "РАПОРТ О ПОХИЩЕНИИ", + intro: "Рапорт выглядит официально: есть номер дела, время, подпись и печать. Бюро предлагает проверить его до того, как кого-нибудь задержат.", + /* Текст рапорта разбит на фрагменты. is: тип реакции. + tell: true — настоящий признак подделки (нужно найти оба). */ + reportParts: [ + { t: "Дело № " + CONFIG.caseNumber + ". ", id: "num" }, + { t: "Установлено, что филолог-консультант Л. П. Каверза изъял объект ", id: "plain" }, + { t: "в 09:05", id: "time", tell: true }, + { t: " и вынес его через турникет ", id: "plain2" }, + { t: "согласно приказа", id: "case", tell: true }, + { t: " № " + CONFIG.caseNumber + ". Мотив: день рождения казался недостаточно сюжетным. ", id: "motive" }, + { t: "Подпись: Л. П. Каверза.", id: "sign" }, + { t: " Печать: имеется. Уверенность: повышенная.", id: "stamp" }, + ], + task: "Найдите в тексте признаки подделки. Их два. Нажимайте прямо на подозрительные места.", + counter: "Признаков найдено", + tellReactions: { + time: { + title: "Признак 1: невозможное время", + text: "По журналу турникета Каверза вошёл в здание в 09:15. В 09:05 его здесь не было — изъять объект он не мог физически.", + }, + case: { + title: "Признак 2: чужая грамматика", + text: "«Согласно приказа» — родительный падеж. Настоящее Бюро пишет «согласно приказу». Тот же почерк, что и у поддельного постановления.", + }, + }, + plainReactions: { + num: "Номер дела указан верно. Подделка всегда начинается с правдоподобного.", + plain: "Формулировка как формулировка. Обвинение — ещё не доказательство.", + plain2: "Турникет действительно существует. Это, пожалуй, единственное, что здесь бесспорно.", + motive: "Мотив звучит обидно правдоподобно. Но мотив не является уликой.", + sign: "Подпись есть. Каверза утверждает, что подписывается всегда со сноской. Сноски нет — но это спорный аргумент.", + stamp: "«Уверенность: повышенная» — не реквизит документа. Смешно, но подделку этим не доказать.", + }, + successTitle: "Рапорт признан подделкой", + successBody: "Оба признака подтверждены: время противоречит журналу, падеж противоречит регламенту. Филолог освобождён от подозрений с формулировкой «слишком грамотен для этого преступления».", + kaverzaQuote: "«Я подозревал, что меня подставят. Но не ожидал, что сделают это с такой слабой грамматикой. Это обиднее подозрений».", + hints: [ + "Один признак — фактический: сверьте время в рапорте с журналом турникета. Второй — языковой: вспомните памятку Каверзы.", + "Каверза вошёл в 09:15, а рапорт называет другое время. И где-то в тексте снова хромает падеж.", + "Нажмите на «в 09:05» и на «согласно приказа».", + ], + evidence: { + name: "ОПРОВЕРЖЕНИЕ", + fact: "версия о краже несостоятельна", + factLabel: "Итог проверки", + desc: "Рапорт изготовлен Отделом срочных ложных версий. Кражи не было.", + }, + button: "Приобщить к делу", + }, + + /* ---------- ГЛАВА 6. ОРДЕР ---------- */ + order: { + chapter: "Глава 6", + title: "ФОРМА ОРДЕРА НА ВЫДАЧУ", + body: [ + "Кражи не было. Объект не похищен — он задержан.", + "", + "Чтобы снять ограничение, заполните ордер. Все три графы уже установлены следствием: загляните в журнал дела, если что-то забылось.", + ], + fields: [ + { + id: "exitTime", + label: "Графа 1. Время выхода объекта из здания", + placeholder: "чч:мм", + hintSource: "глава 2, журнал турникета", + wrong: "Время не соответствует журналу турникета. Проверьте, когда именно объект покинул здание.", + }, + { + id: "foreignTag", + label: "Графа 2. Инвентарный признак сторонней службы", + placeholder: "XX-00", + hintSource: "глава 1, опись имущества", + wrong: "Такой бирки в деле нет. Речь о предмете, которого не было в описи Бюро.", + }, + { + id: "codeWord", + label: "Графа 3. Служебное кодовое слово", + placeholder: "слово", + hintSource: "глава 4, аудиоэкспертиза", + wrong: "Слово не совпадает со служебным. Оно было собрано из первых букв бриджа.", + }, + ], + button: "Подать ордер", + partial: "Форма принята частично. Бюро не любит частично.", + hints: [ + "Все три значения уже есть в журнале дела — откройте его кнопкой «Журнал» наверху.", + "Графа 1 — время из журнала турникета. Графа 2 — бирка коробки. Графа 3 — акростих бриджа.", + FACTS.exitTime + " · " + FACTS.foreignTag + " · " + FACTS.codeWord, + ], + }, + + /* ---------- ФИНАЛ ---------- */ + twist: { + lines: [ + "Преступления не было.", + "Дело № " + CONFIG.caseNumber + " переквалифицировано.", + "Объект не похищали. Объект был поставлен на паузу — до появления сюжета.", + "Сюжет создан. Вами. Только что.", + ], + stamp: "СЮЖЕТ ПРИЛОЖЕН", + button: "Закрыть дело", + }, + + congrats: { + title: "Дело № " + CONFIG.caseNumber + " закрыто", + body: [ + "Преступления не было.", + "", + "Были бумаги, штампы, журнал турникета, один подделанный рапорт и один филолог, который переживал за падежи больше, чем за собственную репутацию.", + "", + "А ещё был день рождения, который заслуживал не просто подарок, а историю.", + "", + CONFIG.name + ", ты прошла это дело с внимательностью, иронией и терпением, которым позавидовала бы любая бюрократическая система.", + "", + "С днём рождения.", + "", + "Пусть запятые стоят там, где им удобно.", + "Пусть правила нарушаются с умом.", + "Пусть подарки ждут тебя не в камерах хранения, а в руках.", + "", + "И если жизнь когда-нибудь снова покажется слишком серьёзной — вспомни: где-то в этом деле тебя официально признали человеком, способным расследовать собственную радость.", + ], + button: "Получить ордер на выдачу", + }, + + orderScreen: { + title: "ОРДЕР НА ВЫДАЧУ ОБЪЕКТА № " + CONFIG.caseNumber, + rows: [ + ["Объект", "Подарок, 1 шт."], + ["Статус", "готов к выдаче"], + ["Основание", "сюжет приложен"], + ], + qrNote: "Наведите камеру на контрольную печать.", + + /* Штрихкод намеренно не выдаётся системой: последний шаг — + написать организатору. */ + claimTitle: "ПОЛУЧЕНИЕ ШТРИХКОДА", + claimLead: "Бюро не хранит штрихкоды в открытом виде: они выдаются ответственному лицу под расписку.", + claimSteps: [ + "Отправьте мне в личные сообщения кодовое слово: " + CONFIG.pickupCode + ".", + "В ответ придёт штрихкод заказа.", + "Покажите штрихкод на пункте выдачи по адресу выше.", + ], + claimNote: "Да, это ещё одна бюрократическая процедура. Последняя. Обещаю.", + + joke: "Если сотрудник ПВЗ спросит, зачем вам подарок, отвечайте: «По делу № " + CONFIG.caseNumber + "». Это не поможет. Но звучит весомо.", + reserveNote: "Если что-то не открывается — это не подарок, это техническая подделка. Напишите мне.", + copyButton: "Скопировать адрес и кодовое слово", + }, + + easter: { + speedButton: "Ускорить следствие", + speedFirst: "Кнопка работает только в следующем квесте.", + speedSecond: "Следствие ускорено эмоционально. Фактических изменений нет.", + complaintButton: "Пожаловаться на Бюро", + complaintResult: "Ваша жалоба принята. Ей присвоен номер № 0. Номер № 0 благодарит вас за внимание.", + confusedButton: "Я ничего не понимаю", + confusedFirst: "Это нормальная реакция. Следствие признаёт её официальной.", + confusedSecond: "Уровень понимания не изменился. Уровень атмосферы вырос.", + resetButton: "Начать сначала", + resetArm: "Нажмите ещё раз, чтобы начать дело заново. Прогресс будет стёрт.", + footer: "если вы читаете это, вы внимательны. подарок всё ещё реален.", + }, +}; + +/* Порядок глав для индикатора прогресса */ +const CHAPTERS = [ + { stage: "caseNumber", label: "Допуск" }, + { stage: "scene", label: "Опись" }, + { stage: "witnesses", label: "Показания" }, + { stage: "philology", label: "Экспертиза" }, + { stage: "audio", label: "Запись" }, + { stage: "falseTrail", label: "Ложный след" }, + { stage: "orderForm", label: "Ордер" }, +]; diff --git a/index.html b/index.html new file mode 100644 index 0000000..cacbb6c --- /dev/null +++ b/index.html @@ -0,0 +1,20 @@ + + + + + +ГИС «ПАПКА» · Исчезновение подарка + + + + + + + + +
+ + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..15c78eb --- /dev/null +++ b/styles.css @@ -0,0 +1,767 @@ +/* ============================================================ + ДЕЛО № 260826 — визуальный слой v2 + Бюрократический нуар: тёмный терминал + бумажные документы. + ============================================================ */ + +:root { + --bg: #0d1117; + --bg-2: #131a22; + --line: #232c37; + --line-2: #2e3946; + + --paper: #efe4cd; + --paper-2: #f6eeda; + --paper-3: #e5d8bd; + --ink: #23201a; + --ink-2: #5c5344; + --ink-3: #8a7f6a; + + --red: #b03a3a; + --red-soft: #d8a3a3; + --amber: #d7a44a; + --green: #4a7d55; + --dim: #7d8896; + + --mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "Courier New", monospace; + --serif: "PT Serif", "Source Serif 4", Georgia, serif; + --sans: "Inter", "Manrope", "Segoe UI", system-ui, sans-serif; + + --r: 4px; + --shadow: 0 18px 50px rgba(0,0,0,.45); +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg); + color: #ccd4dd; + font-family: var(--sans); + font-size: 15px; + line-height: 1.6; + min-height: 100vh; + background-image: + radial-gradient(1200px 600px at 20% -5%, rgba(215,164,74,.055), transparent 60%), + radial-gradient(900px 500px at 85% 105%, rgba(176,58,58,.05), transparent 60%); + transition: background-color .9s ease; +} + +body.warm { + background-color: #241b13; + background-image: + radial-gradient(1000px 520px at 50% -10%, rgba(215,164,74,.16), transparent 62%), + radial-gradient(760px 460px at 15% 110%, rgba(215,164,74,.09), transparent 60%); +} + +.shell { min-height: 100vh; display: flex; flex-direction: column; } + +/* ---------------- верхняя панель ---------------- */ + +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; justify-content: space-between; align-items: center; + gap: 12px; flex-wrap: wrap; + padding: 11px 20px; + background: rgba(13,17,23,.92); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); + font-family: var(--mono); font-size: 12.5px; +} +.tb-left { display: flex; align-items: baseline; gap: 14px; min-width: 0; } +.tb-logo { color: var(--amber); letter-spacing: .06em; white-space: nowrap; } +.tb-case { color: var(--dim); white-space: nowrap; } +.tb-right { display: flex; gap: 7px; } + +.chip { + display: inline-flex; align-items: center; gap: 6px; + background: #19212b; color: #c4ced9; + border: 1px solid var(--line-2); border-radius: var(--r); + padding: 6px 11px; + font: inherit; cursor: pointer; + transition: border-color .15s, color .15s, transform .08s; +} +.chip:hover:not(:disabled) { border-color: var(--amber); color: #e6ecf2; } +.chip:active:not(:disabled) { transform: translateY(1px); } +.chip:disabled { opacity: .32; cursor: default; } +.chip-n { + background: var(--amber); color: #1a1409; + border-radius: 20px; padding: 0 6px; + font-size: 11px; font-weight: 700; line-height: 17px; +} +.chip-i { padding: 6px 9px; } + +/* ---------------- прогресс ---------------- */ + +.rail { + display: flex; gap: 2px; overflow-x: auto; + padding: 10px 20px 0; + scrollbar-width: none; +} +.rail::-webkit-scrollbar { display: none; } +.rail-step { + display: flex; align-items: center; gap: 7px; + padding: 5px 13px 5px 7px; + border-bottom: 2px solid var(--line); + color: #55606d; + font-family: var(--mono); font-size: 11px; + white-space: nowrap; flex: none; + transition: color .2s, border-color .2s; +} +.rail-dot { + width: 18px; height: 18px; border-radius: 50%; + display: grid; place-items: center; + background: #19212b; border: 1px solid var(--line-2); + font-size: 10px; line-height: 1; +} +.rail-step.done { color: #7f8b98; border-bottom-color: var(--green); } +.rail-step.done .rail-dot { background: var(--green); border-color: var(--green); color: #eaf3ec; } +.rail-step.cur { color: var(--amber); border-bottom-color: var(--amber); } +.rail-step.cur .rail-dot { border-color: var(--amber); color: var(--amber); } + +/* ---------------- лист документа ---------------- */ + +.stage { + flex: 1; + display: flex; justify-content: center; align-items: flex-start; + padding: 26px 16px 40px; + animation: rise .38s cubic-bezier(.2,.7,.3,1); +} +@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } + +.center-wrap { width: 100%; display: flex; justify-content: center; } + +.sheet { + position: relative; + width: 100%; max-width: 680px; + background: var(--paper); + background-image: + repeating-linear-gradient(0deg, rgba(0,0,0,.012) 0 1px, transparent 1px 3px), + radial-gradient(600px 400px at 15% 0%, rgba(255,255,255,.5), transparent 65%); + color: var(--ink); + font-family: var(--serif); + padding: 30px 34px 32px; + border-radius: var(--r); + box-shadow: var(--shadow); +} +.sheet::before { + content: ""; position: absolute; inset: 0; + border-radius: var(--r); + box-shadow: inset 0 0 0 1px rgba(120,100,60,.22); + pointer-events: none; +} +.sheet p { margin: 0 0 11px; } +.vspace { height: 9px; } + +.sheet-head { margin-bottom: 18px; } +.chapter { + font-family: var(--mono); font-size: 11px; + letter-spacing: .14em; text-transform: uppercase; + color: var(--ink-3); +} +.chapter.red { color: var(--red); } +.sheet h1, .sheet h2 { + font-family: var(--sans); color: #1b1812; + letter-spacing: -.01em; margin: 4px 0 0; +} +.sheet h1 { font-size: 25px; line-height: 1.25; } +.sheet h2 { font-size: 19px; } + +.intro h1 { margin-bottom: 14px; } +.note { font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); margin-top: 14px; } +.dim { color: var(--ink-3); } +.quote { + font-style: italic; color: var(--ink-2); + border-left: 2px solid var(--amber); + padding-left: 13px; margin: 15px 0 0; +} +.center-t { text-align: center; } + +/* ---------------- кнопки ---------------- */ + +.row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; } +.row.center { justify-content: center; } + +.btn { + font-family: var(--sans); font-weight: 600; font-size: 14px; + padding: 11px 21px; border: 0; border-radius: var(--r); + cursor: pointer; transition: filter .15s, transform .07s, box-shadow .15s; +} +.btn:active { transform: translateY(1px); } +.btn.primary { + background: var(--red); color: #fbf1e6; + box-shadow: 0 2px 0 rgba(0,0,0,.18); +} +.btn.primary:hover { filter: brightness(1.09); } +.btn.ghost { + background: transparent; color: var(--ink); + box-shadow: inset 0 0 0 1px rgba(120,100,60,.5); +} +.btn.ghost:hover { box-shadow: inset 0 0 0 1px var(--ink-2); } + +.linkbtn { + display: block; margin-top: 14px; padding: 0; + background: none; border: 0; + color: var(--ink-3); font-family: var(--mono); font-size: 11.5px; + text-decoration: underline; cursor: pointer; +} +.linkbtn:hover { color: var(--red); } + +/* ---------------- поля ввода ---------------- */ + +.inrow { display: flex; gap: 9px; flex-wrap: wrap; margin: 18px 0 4px; } +.inrow input { + flex: 1 1 200px; min-width: 0; + padding: 11px 13px; + border: 1px solid rgba(120,100,60,.45); border-radius: var(--r); + background: var(--paper-2); color: var(--ink); + font-family: var(--mono); font-size: 14px; +} +.inrow input:focus { outline: 2px solid var(--amber); outline-offset: -1px; } +.inrow input:disabled { opacity: .65; } + +/* ---------------- обратная связь ---------------- */ + +.feedback { + display: flex; gap: 8px; align-items: flex-start; + font-family: var(--mono); font-size: 12.5px; line-height: 1.5; + min-height: 18px; margin-top: 8px; +} +.feedback:empty { margin: 0; } +.fb-mark { flex: none; width: 3px; align-self: stretch; border-radius: 2px; } +.feedback.bad { color: var(--red); } +.feedback.bad .fb-mark { background: var(--red); } +.feedback.good { color: var(--green); } +.feedback.good .fb-mark { background: var(--green); } +.shake { animation: shake .32s; } +@keyframes shake { + 0%,100% { transform: translateX(0); } + 25% { transform: translateX(-4px); } + 75% { transform: translateX(4px); } +} + +/* ---------------- служебные вставки ---------------- */ + +.excerpt, .memo { + background: var(--paper-3); + border-left: 3px solid var(--amber); + padding: 13px 16px; margin: 16px 0; + border-radius: 0 var(--r) var(--r) 0; +} +.excerpt-h, .memo-h { + font-family: var(--mono); font-size: 10.5px; + letter-spacing: .12em; text-transform: uppercase; + color: var(--ink-3); margin-bottom: 6px; +} +.excerpt p, .memo p { margin: 0; font-size: 14px; } +.memo p { font-style: italic; } + +.record { + background: var(--paper-2); + border: 1px solid rgba(120,100,60,.3); + border-radius: var(--r); + padding: 13px 16px; margin: 16px 0; +} +.record-h { + font-family: var(--mono); font-size: 10.5px; + letter-spacing: .12em; text-transform: uppercase; + color: var(--ink-3); margin-bottom: 9px; + padding-bottom: 7px; border-bottom: 1px solid rgba(120,100,60,.22); +} +.rec-row, .inv-row { + display: flex; justify-content: space-between; gap: 14px; + padding: 5px 0; font-size: 14px; + border-bottom: 1px dotted rgba(120,100,60,.24); +} +.rec-row:last-child, .inv-row:last-child { border-bottom: 0; } +.rec-row span { color: var(--ink-2); } +.rec-row b { text-align: right; font-family: var(--mono); font-size: 13px; } +.inv-row { font-family: var(--mono); font-size: 12.5px; justify-content: flex-start; } +.inv-n { color: var(--red); flex: none; width: 82px; } +.record-note { + font-family: var(--mono); font-size: 11px; + color: var(--ink-3); font-style: italic; margin-top: 8px; +} + +/* журнал турникета */ +.log-grid { display: flex; flex-direction: column; } +.log-row { + display: grid; grid-template-columns: 62px 1fr 74px; + gap: 10px; align-items: center; + padding: 6px 0; font-family: var(--mono); font-size: 13px; + border-bottom: 1px dotted rgba(120,100,60,.24); +} +.log-row:last-child { border-bottom: 0; } +.log-t { color: var(--ink); font-weight: 700; } +.log-n { color: var(--ink-2); } +.log-d { + justify-self: end; font-size: 11px; + padding: 2px 8px; border-radius: 20px; letter-spacing: .04em; +} +.log-d.in { background: rgba(74,125,85,.16); color: var(--green); } +.log-d.out { background: rgba(176,58,58,.13); color: var(--red); } + +/* ---------------- две колонки (опись + стол) ---------------- */ + +/* Глава с описью шире прочих: бирки на столе должны читаться без усилий. */ +.sheet.wide { max-width: 940px; } + +.two-col { + display: grid; grid-template-columns: 264px minmax(0, 1fr); + gap: 18px; align-items: start; margin: 16px 0; +} +.two-col .record { margin: 0; } +.scene-box { + border: 1px solid rgba(120,100,60,.35); + border-radius: var(--r); + overflow: hidden; + overflow-x: auto; + background: #43331f; + -webkit-overflow-scrolling: touch; +} +.desk { width: 100%; display: block; } +.desk .hit { cursor: pointer; } +.desk .hit > *:not(.tag) { transition: filter .15s; } +.desk .hit:hover > *:not(.tag) { filter: brightness(1.14); } +.desk .tag rect { fill: #f2e7cf; stroke: #8a7550; stroke-width: .8; } +.desk .tag text { + fill: #3a3125; font-family: var(--mono); font-size: 9.5px; font-weight: 700; +} +.desk .hit:hover .tag rect { fill: #fff8e6; stroke: var(--amber); } +.desk .lbl { fill: #3a3125; font-family: var(--mono); font-size: 12px; } +.desk .lbl-s { fill: #3a3125; font-family: var(--mono); font-size: 10.5px; } +.desk .lbl-xs { fill: #6b5c44; font-family: var(--mono); font-size: 8.5px; } +.desk .num { fill: #23201a; font-family: var(--sans); font-size: 27px; font-weight: 700; } +.desk .found .tag rect { fill: var(--amber); stroke: #8a6a20; } +.pulse { animation: pulse 1.05s ease-in-out infinite; } +@keyframes pulse { + 0%,100% { filter: none; } + 50% { filter: drop-shadow(0 0 9px var(--amber)) brightness(1.15); } +} + +/* ---------------- карточки-варианты ---------------- */ + +.cards, .docs { display: grid; gap: 10px; margin: 16px 0; } + +.pcard, .doc { + display: block; width: 100%; text-align: left; + background: var(--paper-2); + border: 1px solid rgba(120,100,60,.32); + border-left: 3px solid rgba(120,100,60,.32); + border-radius: var(--r); + padding: 13px 15px; + font-family: var(--serif); font-size: 14px; color: var(--ink); + cursor: pointer; + transition: border-color .16s, background .16s, transform .1s, box-shadow .16s; +} +.pcard:hover:not(:disabled), .doc:hover:not(:disabled) { + border-left-color: var(--red); + background: #f8f1de; + transform: translateX(2px); + box-shadow: 0 3px 12px rgba(0,0,0,.07); +} +.pcard:disabled, .doc:disabled { cursor: default; opacity: .62; } +.pcard.found, .doc.found { + opacity: 1; + border-left-color: var(--green); + background: #f1f5ea; +} + +.pcard-top { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; margin-bottom: 6px; } +.pcard-name { font-family: var(--sans); font-weight: 700; font-size: 14.5px; } +.pcard-role { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); } +.pcard-text { color: var(--ink-2); } + +.doc-h { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 7px; } +.doc-n { font-family: var(--mono); font-size: 10.5px; color: var(--red); letter-spacing: .08em; } +.doc-title { font-family: var(--sans); font-weight: 600; font-size: 13.5px; } +.doc-t { margin-bottom: 8px; } +.doc-sign { + font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); + text-align: right; font-style: italic; +} + +/* ---------------- успех ---------------- */ + +.success { + background: linear-gradient(180deg, rgba(74,125,85,.11), rgba(74,125,85,.05)); + border: 1px solid rgba(74,125,85,.34); + border-radius: var(--r); + padding: 15px 17px; margin-top: 18px; + animation: rise .35s; +} +.success-head { + display: flex; align-items: center; gap: 9px; + font-family: var(--sans); font-weight: 700; font-size: 15px; + color: #2f5b39; margin-bottom: 7px; +} +.tick { + flex: none; width: 20px; height: 20px; border-radius: 50%; + background: var(--green); color: #fff; + display: grid; place-items: center; font-size: 12px; +} +.success p { margin: 0; color: var(--ink-2); font-size: 14px; } + +/* ---------------- карточка факта ---------------- */ + +.jcard { + background: var(--paper-2); + border: 1px dashed rgba(120,100,60,.45); + border-radius: var(--r); + padding: 11px 14px; margin-bottom: 10px; +} +.jcard.inline { margin: 13px 0 0; background: rgba(255,255,255,.5); } +.jcard-name { + font-family: var(--sans); font-weight: 700; font-size: 13px; + letter-spacing: .06em; color: var(--red); margin-bottom: 6px; +} +.jcard-fact { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; flex-wrap: wrap; + padding: 7px 10px; border-radius: 3px; + background: rgba(215,164,74,.16); +} +.jcard-label { font-family: var(--sans); font-size: 11.5px; color: var(--ink-2); } +.jcard-fact b { font-family: var(--mono); font-size: 15px; color: var(--ink); letter-spacing: .04em; } +.jcard-desc { font-size: 12.5px; color: var(--ink-3); margin-top: 7px; } + +/* ---------------- песня ---------------- */ + +.song { + background: var(--paper-2); + border: 1px solid rgba(120,100,60,.3); + border-radius: var(--r); + padding: 16px 18px; margin: 16px 0; +} +.song-t { font-family: var(--sans); font-weight: 700; font-size: 16px; } +.song-a { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); margin-top: 2px; } +.song audio { width: 100%; margin: 12px 0 6px; } +.song-fb { font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); } + +.lyrics { display: grid; gap: 14px; margin-top: 12px; } +.lblock p { margin: 1px 0; font-size: 13.5px; color: var(--ink-2); } +.lblock-h { + font-family: var(--mono); font-size: 10px; + letter-spacing: .12em; text-transform: uppercase; + color: var(--ink-3); margin-bottom: 4px; +} +.badge { + background: var(--red); color: #fdf3e8; + border-radius: 20px; padding: 1px 8px; + font-size: 9px; letter-spacing: .06em; margin-left: 4px; +} +.lblock.chorus p { font-style: italic; color: var(--ink-3); } +.lblock.bridge { + background: rgba(215,164,74,.13); + border-left: 3px solid var(--amber); + padding: 10px 13px; margin-left: -4px; + border-radius: 0 var(--r) var(--r) 0; +} +.lblock.bridge p { color: var(--ink); font-weight: 600; } +.acro { + color: var(--red); font-weight: 800; + font-size: 1.14em; + border-bottom: 2px solid rgba(176,58,58,.32); +} + +/* ---------------- ложный след ---------------- */ + +.alert { + background: linear-gradient(180deg, rgba(176,58,58,.12), rgba(176,58,58,.05)); + border: 1px solid rgba(176,58,58,.42); + border-radius: var(--r); + padding: 13px 16px; margin-bottom: 15px; +} +.alert p { margin: 0 0 4px; font-size: 14px; } +.alert p:last-child { margin: 0; } + +.report { + position: relative; + background: var(--paper-2); + border: 1px solid rgba(120,100,60,.34); + border-radius: var(--r); + padding: 17px 19px 22px; margin: 15px 0; +} +.report-h { + font-family: var(--sans); font-weight: 700; font-size: 15px; + letter-spacing: .04em; margin-bottom: 10px; + padding-bottom: 8px; border-bottom: 1px solid rgba(120,100,60,.22); +} +.report-body { font-size: 14.5px; line-height: 1.85; margin: 0; } +.report-seal { + position: absolute; right: 16px; bottom: 10px; + font-family: var(--mono); font-size: 9.5px; + color: var(--red-soft); border: 1px solid var(--red-soft); + border-radius: 3px; padding: 2px 7px; + transform: rotate(-6deg); opacity: .8; +} + +/* Искомые фрагменты НЕ отличаются от обычного текста: найти их — + и есть загадка. Кликается весь рапорт, «подозрительность» игрок + определяет сам, сверяя с журналом и памяткой. */ +.plain { cursor: pointer; border-radius: 2px; transition: background .14s; } +.plain:hover { background: rgba(120,100,60,.11); } + +.tell { + font: inherit; color: inherit; + background: none; border: 0; padding: 0; + border-radius: 2px; + cursor: pointer; transition: background .14s, color .14s; +} +.tell:hover:not(:disabled) { background: rgba(120,100,60,.11); } +.tell.got { + background: rgba(74,125,85,.2); color: #2f5b39; + box-shadow: inset 0 -1.5px 0 var(--green); + font-weight: 700; padding: 1px 3px; + cursor: default; +} + +.task { + display: flex; justify-content: space-between; align-items: center; + gap: 14px; flex-wrap: wrap; + background: var(--paper-3); border-radius: var(--r); + padding: 11px 15px; margin-top: 4px; +} +.task p { margin: 0; font-size: 13.5px; } +.counter { font-family: var(--mono); font-size: 12.5px; color: var(--ink-2); white-space: nowrap; } +.counter b { color: var(--red); font-size: 15px; } + +/* ---------------- форма ордера ---------------- */ + +.form { display: grid; gap: 17px; margin: 20px 0 4px; } +.fgroup label { + display: block; + font-family: var(--sans); font-weight: 600; font-size: 13.5px; + color: var(--ink); margin-bottom: 6px; +} +.fline { display: flex; align-items: center; gap: 9px; } +.fline input { + flex: 1; min-width: 0; + padding: 10px 13px; + border: 1px solid rgba(120,100,60,.45); border-radius: var(--r); + background: var(--paper-2); color: var(--ink); + font-family: var(--mono); font-size: 14px; +} +.fline input:focus { outline: 2px solid var(--amber); outline-offset: -1px; } +.fmark { + flex: none; width: 22px; text-align: center; + color: var(--green); font-size: 17px; font-weight: 700; +} +.fsrc { + font-family: var(--mono); font-size: 10.5px; + color: var(--ink-3); margin-top: 5px; +} +.ferr { + font-family: var(--mono); font-size: 11.5px; + color: var(--red); margin-top: 5px; +} +.ferr:empty { margin: 0; } +.fgroup.ok label { color: var(--green); } +.fgroup.ok .fline input { + background: rgba(74,125,85,.09); + border-color: rgba(74,125,85,.5); +} + +/* ---------------- финал ---------------- */ + +.twist { + width: 100%; max-width: 620px; margin: 70px auto 0; text-align: center; +} +.twist-lines { min-height: 150px; } +.twist-lines p { + font-family: var(--mono); font-size: 16.5px; line-height: 1.6; + color: #e8ded0; margin: 12px 0; + opacity: 0; animation: fade .85s forwards; +} +@keyframes fade { to { opacity: 1; } } + +.bigstamp { + display: inline-block; margin-top: 26px; + border: 3px solid var(--red); color: var(--red); + font-family: var(--mono); font-weight: 700; font-size: 17px; + letter-spacing: .1em; padding: 11px 22px; + transform: rotate(-4deg); + animation: slam .38s cubic-bezier(.2,1.4,.4,1); +} +@keyframes slam { + from { transform: rotate(-4deg) scale(2.1); opacity: 0; } + to { transform: rotate(-4deg) scale(1); opacity: 1; } +} + +.warm-sheet { + background: #fbf4e6; + background-image: radial-gradient(700px 400px at 20% 0%, rgba(255,255,255,.7), transparent 60%); + max-width: 620px; +} +.warm-sheet h1 { text-align: center; font-size: 22px; margin-bottom: 18px; } +.warm-sheet p { line-height: 1.72; } + +.order-final .record { background: rgba(255,255,255,.55); } +.qr { + width: 232px; margin: 18px auto; + background: #fff; padding: 8px; border-radius: var(--r); + box-shadow: 0 3px 14px rgba(0,0,0,.12); +} +.qr svg { width: 100%; height: auto; display: block; } +.pickup .rec-row b { font-size: 14px; } + +/* Блок «как получить штрихкод» — последний шаг вне системы */ +.claim { + background: rgba(215,164,74,.16); + border: 1px solid rgba(215,164,74,.5); + border-radius: var(--r); + padding: 15px 18px; margin: 16px 0 4px; + text-align: left; +} +.claim-h { + font-family: var(--mono); font-size: 10.5px; + letter-spacing: .12em; text-transform: uppercase; + color: #8a6a20; margin-bottom: 7px; +} +.claim-lead { font-size: 13.5px; color: var(--ink-2); margin: 0 0 10px; } +.claim-steps { + margin: 0; padding-left: 20px; + display: grid; gap: 6px; +} +.claim-steps li { font-size: 14.5px; line-height: 1.5; } +.claim-steps li::marker { color: var(--red); font-weight: 700; } +.claim-note { + font-family: var(--mono); font-size: 11.5px; + color: var(--ink-3); font-style: italic; + margin: 14px 0 0; + padding-top: 11px; + border-top: 1px dotted rgba(120,100,60,.35); +} + +/* ---------------- док и футер ---------------- */ + +.dock { + display: flex; gap: 7px; justify-content: center; flex-wrap: wrap; + padding: 6px 16px 0; opacity: .42; transition: opacity .2s; +} +.dock:hover { opacity: 1; } +.dk { + background: none; border: 1px solid #232c37; color: #5b6673; + border-radius: var(--r); padding: 5px 10px; + font-family: var(--mono); font-size: 10.5px; cursor: pointer; + transition: border-color .15s, color .15s; +} +.dk:hover { border-color: var(--amber); color: var(--amber); } + +.footer { + text-align: center; padding: 14px 0 20px; + font-family: var(--mono); font-size: 10px; color: #222a34; +} + +/* ---------------- панели ---------------- */ + +.backdrop { + position: fixed; inset: 0; z-index: 60; + background: rgba(4,7,10,.6); + display: flex; justify-content: flex-end; + animation: fadein .18s; +} +@keyframes fadein { from { opacity: 0; } to { opacity: 1; } } + +.panel { + width: min(400px, 93vw); height: 100vh; + background: var(--bg-2); color: #ccd4dd; + overflow-y: auto; + box-shadow: -14px 0 44px rgba(0,0,0,.5); + animation: slidein .24s cubic-bezier(.2,.7,.3,1); +} +@keyframes slidein { from { transform: translateX(24px); opacity: .4; } to { transform: none; opacity: 1; } } +.panel-head { + position: sticky; top: 0; z-index: 2; + display: flex; justify-content: space-between; align-items: center; + padding: 15px 18px; + background: var(--bg-2); border-bottom: 1px solid var(--line); +} +.panel-head h3 { + margin: 0; font-family: var(--mono); font-size: 13px; + color: var(--amber); letter-spacing: .06em; +} +.panel-x { + background: none; border: 0; color: var(--dim); + font-size: 21px; line-height: 1; cursor: pointer; padding: 0 4px; +} +.panel-x:hover { color: #e6ecf2; } +.panel-body { padding: 16px 18px 28px; } +.panel-lead { font-size: 12.5px; margin-bottom: 14px; } +.panel-body .dim { color: var(--dim); } + +.panel-body .jcard { + background: #19212b; border-color: var(--line-2); +} +.panel-body .jcard-name { color: var(--amber); } +.panel-body .jcard-fact { background: rgba(215,164,74,.1); } +.panel-body .jcard-label { color: var(--dim); } +.panel-body .jcard-fact b { color: #f0e3c8; } +.panel-body .jcard-desc { color: var(--dim); } + +.hint { + font-family: var(--mono); font-size: 12.5px; line-height: 1.55; + color: #d3dbe3; margin: 10px 0; + padding-left: 11px; border-left: 2px solid var(--amber); +} +.hint b { color: var(--amber); } + +/* ---------------- загрузка ---------------- */ + +.boot { + width: 100%; text-align: center; padding-top: 54px; + display: flex; flex-direction: column; align-items: center; +} +.boot-title { + font-family: var(--mono); color: var(--amber); + letter-spacing: .1em; font-size: 21px; margin: 0; +} +.boot-sub { + color: var(--dim); font-size: 12.5px; max-width: 440px; + margin: 8px auto 30px; font-family: var(--mono); +} +.boot-lines p { + font-family: var(--mono); font-size: 13.5px; color: #b9c3ce; + margin: 7px 0; opacity: 0; animation: fade .5s forwards; +} + +/* ---------------- тост ---------------- */ + +#toast { + position: fixed; left: 50%; bottom: 26px; z-index: 90; + transform: translateX(-50%) translateY(10px); + background: #19212b; color: #ecd9b4; + border: 1px solid var(--amber); border-radius: var(--r); + padding: 10px 18px; max-width: 90vw; + font-family: var(--mono); font-size: 12px; + opacity: 0; pointer-events: none; + transition: opacity .24s, transform .24s; +} +#toast.on { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* ---------------- адаптив ---------------- */ + +@media (max-width: 720px) { + .two-col { grid-template-columns: 1fr; } + /* На узком экране стол не ужимаем: иначе инвентарные бирки — + а на них держится вся загадка — становятся нечитаемыми. + Вместо этого даём сцене горизонтальную прокрутку. */ + .desk { min-width: 500px; } + .scene-box { box-shadow: inset -14px 0 12px -12px rgba(0,0,0,.5); } +} +@media (max-width: 520px) { + body { font-size: 14.5px; } + .sheet { padding: 22px 18px 24px; } + .sheet h1 { font-size: 21px; } + .sheet h2 { font-size: 17px; } + .topbar { padding: 10px 13px; } + .rail { padding: 9px 13px 0; } + .stage { padding: 18px 10px 32px; } + .task { flex-direction: column; align-items: flex-start; } + .twist { margin-top: 44px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } +} diff --git a/vendor/qrcode.js b/vendor/qrcode.js new file mode 100644 index 0000000..75065c0 --- /dev/null +++ b/vendor/qrcode.js @@ -0,0 +1,2323 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript UTF8 Support (optional) +// +// Copyright (c) 2011 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +!function(qrcode) { + + //--------------------------------------------------------------------- + // overwrite qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['UTF-8']; + +}(qrcode); diff --git a/ПРОХОЖДЕНИЕ.md b/ПРОХОЖДЕНИЕ.md new file mode 100644 index 0000000..680b426 --- /dev/null +++ b/ПРОХОЖДЕНИЕ.md @@ -0,0 +1,310 @@ +# Дело № 260826 — полное прохождение + +> **Внимание: сплошные спойлеры.** Этот файл — для организатора квеста, +> а не для именинницы. Здесь все ответы и вся логика загадок. +> Не отправляйте его вместе со ссылкой на квест. + +Версия: `quest-v2`. Ориентировочное время прохождения — 30–45 минут. + +--- + +## Быстрая таблица ответов + +| № | Глава | Что сделать | Ответ | +|---|---|---|---| +| 1 | Допуск | Ввести номер дела | `260826` | +| 2 | Опись | Кликнуть предмет вне описи | коробка с биркой `ДС-04` | +| 3 | Показания | Выбрать невозможное показание | Клавдия Ивановна Шлюз | +| 4 | Экспертиза | Найти подделку | Документ № 3 | +| 5 | Запись | Ввести служебное слово | `ПАУЗА` | +| 6 | Ложный след | Найти 2 признака подделки | `в 09:05` и `согласно приказа` | +| 7 | Ордер | Заполнить три графы | `09:08` · `ДС-04` · `ПАУЗА` | + +--- + +## Подробное прохождение + +### Экран 0. Уведомление + +Заголовок «Объект задержан». Ничего решать не нужно. + +**Действие:** кнопка «Принять дело к производству» → экран загрузки → +кнопка «Войти в систему». + +--- + +### Глава 1 (шаг «Допуск»). Номер дела + +**Что на экране:** выписка из регламента и карточка регистрации. + +- Регламент, п. 4.2: «Номер дела образуется из даты регистрации в + формате ДДММГГ, без разделителей». +- Карточка регистрации, поле «Дата регистрации»: *двадцать шестое + августа две тысячи двадцать шестого года*. + +**Логика:** дату прописью перевести в цифры и склеить по формату: +26 (день) + 08 (месяц) + 26 (год) → `260826`. + +**Ответ:** `260826` + +**Что принимается:** любые нецифровые символы игнорируются, так что +`26-08-26` и `26 08 26` тоже пройдут. + +**Примечание:** до решения этой загадки номер дела нигде не показывается — +он скрыт и в шапке («дело № ——————»), и в заголовке вкладки. + +**Получаем:** статус следователя. Дальше — кнопка «Осмотреть место +происшествия». + +--- + +### Глава 2 (шаг «Опись»). Место происшествия + +**Что на экране:** слева опись имущества отдела, справа фото стола. +Каждый предмет на столе снабжён инвентарной биркой. + +Опись Бюро: + +| Бирка | Предмет | +|---|---| +| БКТО-11 | лампа настольная, зелёная | +| БКТО-12 | кружка с надписью «НЕ ТРОГАТЬ» | +| БКТО-13 | календарь настенный | +| БКТО-14 | папка дела | +| БКТО-15 | карандаш | +| БКТО-16 | кактус (эмоционально устойчив) | +| БКТО-17 | лист с надписью «ПЕЧАТЬ НЕ ТРОГАТЬ» | + +**Логика:** на столе восемь предметов с бирками. Семь совпадают с +описью. У восьмого — картонной коробки внизу — бирка `ДС-04`: другой +префикс. Все предметы отдела имеют префикс БКТО, значит коробка +принадлежит сторонней организации (служба доставки «Доставка с +чувством») и в опись не входит. + +**Ответ:** клик по картонной коробке (бирка `ДС-04`). + +**На телефоне:** сцена прокручивается по горизонтали — коробка находится +в нижней части стола, ближе к правому краю. + +**Клики по остальным предметам** дают шутливые реакции и всегда называют +бирку — это подсказывает саму механику сверки. + +**В журнал записывается:** `ДС-04` — инвентарный признак сторонней +службы. **Понадобится в графе 2 финального ордера.** + +--- + +### Глава 3 (шаг «Показания»). Сверка показаний + +**Что на экране:** журнал турникета и четыре показания. + +Журнал турникета за 26 августа: + +| Время | Кто | Направление | +|---|---|---| +| 08:41 | Пылинкин А. | вход | +| 09:02 | Шлюз К. И. | **выход** | +| 09:06 | Быстрый Т. | вход | +| 09:08 | Быстрый Т. | **выход** | +| 09:12 | Шлюз К. И. | вход | +| 09:15 | Каверза Л. П. | вход | + +**Логика — проверяем каждое показание по журналу:** + +- **Клавдия Шлюз:** «В 09:07 я была на посту». Но по журналу она вышла + в 09:02 и вернулась только в 09:12. В 09:07 её не было в здании → + **показание физически невозможно**. ✅ правильный ответ +- **Пылинкин:** «Около девяти мимо прошёл человек в шарфе». Вошёл в + 08:41 и не выходил — внутри был, противоречия нет. +- **Тимур Быстрый:** «Пришёл, забрал коробку, ушёл. Две минуты». + 09:06 → 09:08 = ровно две минуты. Журнал подтверждает слово в слово. +- **Каверза:** «Когда я вошёл, на столе уже ничего не было». Вошёл в + 09:15, то есть после выноса коробки в 09:08. Мог застать пустой стол. + +**Ответ:** Клавдия Ивановна Шлюз. + +**В журнал записывается:** `09:08` — время выхода объекта из здания +(коробку вынес курьер). **Понадобится в графе 1 финального ордера.** + +--- + +### Глава 4 (шаг «Экспертиза»). Филологическая экспертиза + +**Что на экране:** памятка консультанта Каверзы и три документа. + +Памятка: «Предлог *согласно* управляет дательным падежом: согласно +чему? — согласно приказу, протоколу, распоряжению. Форма *согласно +приказа* — родительный падеж — в служебном документе невозможна». + +Документы: + +1. «Согласно **приказу** № 260826…» — дательный падеж, верно. +2. «В соответствии **с протоколом** № 7…» — корректная конструкция. +3. «Согласно **приказа** о похищении…» — родительный падеж. ❌ + +**Ответ:** Документ № 3. + +**Почему это двойной намёк:** документ не только содержит ошибку, но и +слишком удобно обвиняет филолога — то есть подброшен. + +**В журнал записывается:** «согласно приказу» — признак подлинности +документа. Пригодится в главе 6 (не в форме, а как знание). + +--- + +### Глава 5 (шаг «Запись»). Аудиоэкспертиза + +**Что на экране:** плеер (если mp3 не добавлен — прячется, остаётся +текст) и текст песни ВИА «Канцелярия» — «Временное ограничение». + +**Логика:** нужно служебное слово, которого **в тексте песни нет ни +разу**. Блок «Бридж» помечен badge «служебная вставка» — смотреть надо +туда. Берём первые буквы пяти его строк: + +``` +Пусть протокол никуда не спешит, → П +Архив тишину до конца сторожит. → А +Условие радости: дать ей отстояться, → У +Задержка объекта — не повод бояться, → З +А выдача будет. По форме. Не сразу. → А +``` + +**Ответ:** `ПАУЗА` + +**Ловушка:** припев дважды повторяет «Просто длится **ожиданье**» — +похожее по смыслу слово, которое напрашивается. На ответ «ожидание» +предусмотрена отдельная реакция: «Слово из припева. Припев вас именно на +это и ловил». На ответ «сюжет» (правильный ответ из v1) — тоже своя +реакция. + +**В журнал записывается:** `ПАУЗА` — служебное кодовое слово. +**Понадобится в графе 3 финального ордера.** + +--- + +### Глава 6 (шаг «Ложный след»). Рапорт о похищении + +**Что на экране:** срочное уведомление и рапорт, обвиняющий Каверзу. +Кликается **каждый** фрагмент текста, визуально они не различаются. + +Текст рапорта: «Дело № 260826. Установлено, что филолог-консультант +Л. П. Каверза изъял объект **в 09:05** и вынес его через турникет +**согласно приказа** № 260826. Мотив: день рождения казался недостаточно +сюжетным. Подпись: Л. П. Каверза. Печать: имеется. Уверенность: +повышенная». + +**Нужно найти два признака подделки:** + +1. **`в 09:05`** — по журналу турникета (глава 3) Каверза вошёл в + здание только в 09:15. В 09:05 его здесь не было, изъять объект он + не мог физически. +2. **`согласно приказа`** — родительный падеж вместо дательного. + Тот же почерк, что и у поддельного постановления из главы 4. + +Счётчик показывает «Признаков найдено: N / 2». Нужны оба. + +**Клики по остальным фрагментам** дают юмористические ответы и +объясняют, почему это не улика (мотив, подпись, печать, номер дела). + +**В журнал записывается:** версия о краже несостоятельна. + +--- + +### Глава 7 (шаг «Ордер»). Форма ордера на выдачу + +Финал. Три графы, каждая — из своей главы. Под каждой подписан источник. + +| Графа | Значение | Откуда | +|---|---|---| +| 1. Время выхода объекта из здания | `09:08` | глава 3, журнал турникета | +| 2. Инвентарный признак сторонней службы | `ДС-04` | глава 2, опись | +| 3. Служебное кодовое слово | `ПАУЗА` | глава 5, аудиоэкспертиза | + +Все три значения лежат в **журнале дела** (кнопка «Журнал» в шапке) — +если игрок что-то не записал, они всегда под рукой. + +**Терпимость к формату ввода:** + +- время: `09:08`, `9:08`, `09.08`, `0908`, `9 08` — всё принимается; + невалидное (`25:00`, `09:60`) отклоняется; +- бирка: `ДС-04`, `дс 04`, `дс04` — регистр, пробелы и дефисы не важны; +- слово: регистр не важен, `ё` приравнивается к `е`. + +Графы проверяются по отдельности: верная сразу становится зелёной с +галочкой и блокируется, у неверной появляется своя подсказка. Введённый +текст не теряется. + +**После заполнения всех трёх:** твист → поздравление → ордер с QR-кодом. + +--- + +## Финал + +1. **Твист.** Построчно проявляется: преступления не было, объект был + поставлен на паузу до появления сюжета, сюжет создан игроком. + Печать «СЮЖЕТ ПРИЛОЖЕН». +2. **Поздравление.** Тёплый текст с именем из `CONFIG.name`. +3. **Ордер.** QR-код, адрес выдачи, кодовое слово и номер дела, а также + блок «Получение штрихкода». + +**Как именинница получает подарок (последний шаг вне квеста):** + +1. Пишет вам в личные сообщения кодовое слово **АРБУЗ**. +2. Вы присылаете в ответ штрихкод заказа. +3. Она показывает штрихкод на ПВЗ Озон по адресу + **Москва, Кочновский пр-д, 4 к. 1**. + +Сам штрихкод в квесте нигде не хранится и не показывается — это +сознательное решение, обыгранное в сюжете: «Бюро не хранит штрихкоды в +открытом виде, они выдаются ответственному лицу под расписку». + +**Что нужно от вас:** дождаться сообщения с кодовым словом и отправить +штрихкод. Держите его под рукой в день рождения. + +QR ведёт на `CONFIG.pvzPageUrl`, если он указан; иначе кодирует строку с +адресом, кодовым словом и номером дела. + +--- + +## Если игрок застрял + +Квест невозможно заблокировать — механики помощи работают на каждой +загадке: + +- **Кнопка «Подсказка»** в шапке — три уровня, от намёка до прямого + ответа. Каждое нажатие открывает следующий. +- **Автоподсказки** — открываются сами после 3, 5 и 8 неверных попыток. +- **«Следствие настаивает на правильном ответе»** — появляется после + 8 ошибок. Подсвечивает нужный элемент или подставляет ответ в поле. +- **Кнопка «Журнал»** — все добытые факты в одном месте. + +--- + +## Пасхалки (на прохождение не влияют) + +Внизу каждого экрана, приглушённой строкой: + +- **«Ускорить следствие»** — «Кнопка работает только в следующем + квесте», при повторном нажатии — «Следствие ускорено эмоционально». +- **«Я ничего не понимаю»** — «Это нормальная реакция. Следствие + признаёт её официальной». +- **«Пожаловаться на Бюро»** — жалобе присваивается номер № 0. +- **«Начать сначала»** — сброс прогресса, требует двойного нажатия. +- В самом низу мелким шрифтом: «если вы читаете это, вы внимательны. + подарок всё ещё реален». + +Прогресс сохраняется в браузере автоматически: если закрыть вкладку, +квест продолжится с того же места. + +--- + +## Что нужно подготовить организатору + +1. В `data.js` → `CONFIG`: имя именинницы; адрес и кодовое слово уже + проставлены (ПВЗ Озон на Кочновском, слово `АРБУЗ`). +2. Проверить, что `caseNumber` и `caseDateWords` согласованы между собой + (номер выводится игроком из даты прописью). +3. Опционально: `assets/song.mp3` и `pvzPageUrl`. +4. Выложить папку на любой статический хостинг и отправить ссылку. +5. **Держать штрихкод заказа наготове** — его нужно будет отправить в + ответ на кодовое слово `АРБУЗ`.