47 KiB
Спринт 4 «Симулятор»: исследование физики рыбалки
Цель — довести механику боя с рыбой до уровня рыбалки-симулятора: у рыбы появляется выносливость и характер (рывки с паузами, усталость), у катушки — тормоз (drag), у снастей — честный предел, у воды — пустые клёвы, у босса — реальный риск. Документ: что есть сейчас, как это устроено в реальной рыбалке и в симуляторах, приоритеты P0/P1/P2 и план спринта, реализуемый одной разработкой (1 файл, ~150–300 строк изменений).
Исходная точка — вердикт T5 (спринт 3, DEVLOG, этап 14): too_easy — 0 сходов на 329 уловов, босс 18/18 без шанса проиграть, клёв 100% на каждый заброс. Числовой баланс (этап 14) подкрутил тайминги клёва и редкости, но «вываживание и ×5 босса требуют правки логики» — это и есть спринт 4.
Ограничения жёсткие (AGENTS.md): один index.html, без зависимостей/ассетов/
бэкенда; имена глобалов, которые пользуют тесты, не переименовывать
(state, save, biteTimer, reel, SPECIES, SPECIES_NIGHT, rod(),
SHINY_CHANCE, forceBoss, stateBoss, bossPhase, dayPhase,
pickSpecies(), setDayTime(), todayStr(), dateShift(), isoWeek(),
achBonus(), состояния MENU/IDLE/CHARGE/CAST/WAIT/APPROACH/BITE/REEL/SNAP/CARD);
ввод — Pointer Events; сейв fishing-save-v1 — новые поля только через
миграцию в loadSave(); 60 fps; один палец.
1. Состояние сейчас
Кратко, с именами функций и формулами (всё в index.html).
1.1 Данные
SPECIES(8 рыб):strength(0.25–0.9),weight(вес спавна в рандоме видов),wMin/wMax(диапазон веса улова),minDist(нужна дальняя дистанция),price(🪙/кг).SPECIES_NIGHT— клон с ночными весами.RODS(4 удочки):maxW(предел веса),line(прочность шнура 100/140/190/260),reel(скорость втягивания 1.0–2.3),size(бонус на крупную).LURES(4 приманки):cast(дальность),bite(частота клёва),rare(шанс редких/крупных). ПолеnightOnlyудалено (M5): фонарик — не приманка, а апгрейд 1 любого удилища (2+ уровень,hasLight()).SHINY_CHANCE(3%),forceBoss(тест-форс босса).
1.2 Бросок
CHARGE:charge += dt,power = 1 - |1 - ((charge/0.9) % 2)|— треугольник с периодом 1.8 с (нажал-отпустил в любой момент).doCast():dist = min(0.97, (0.10 + 0.80*power) * lure().cast); состояниеCAST— фиксированная парабола 0.65 с (castT, дуга-150*sin(π*t)), потомWAIT,biteTimer = rand(4,10)/lure().bite.- Нет ветра, течения, баллистики: расстояние — детерминированная функция мощности и приманки.
1.3 Клёв и подсечка
WAIT:biteTimerтикает; при≤ 0— роллforceBite || Math.random() < biteChance()→startBite(), иначе новый таймерrand(2.5,5)/lure().bite(пустые клёвы ЕСТЬ: «молчание» ~10–35% забросов, ночью тише —×0.85). Ночью без фонарика (M5: апгрейд 1 удилища) таймер тикает вхолостую — клёв не стартует.startBite():pickSpecies(dist)(вес вида × редкость-бонус приманки, штраф×0.06за близкую дистанцию); осётр → босс с шансом 30% (forceBossфорсирует). Рыба плывёт к поплавку за 0.45 с (APPROACH,updateBiteFishIn()).BITE: окноbiteT = biteWindow(sp)—0.8 - rarity*0.08 + rod*0.04 + achBonus().windowS(не уже 0.35 с), у боссаrand(0.25,0.5). Тап (hook()) в окне — подсечка; мимо — «Ушла… жми быстрее!», новый таймерrand(5,11).- Подсечка мгновенно успешна в любом месте окна: качество момента не влияет на бой.
1.4 Вываживание (hook() + updateReel())
hook() создаёт reel:
w = rollWeight(sp) // смещение к крупной: B = min(0.95, rod.size + lure.size)
ov = w / rod().maxW // 1.0 = на пределе удочки
aggr = min(1, 0.35*str + 0.75*min(1.25, ov)/1.25) // «злость»
d0 = 40 + 3.5*w // стартовая дистанция (px)
line = rod().line + achBonus().line
reel = { d: d0, d0, tension: 0, escape: 0, holding: false,
phase: 'calm', phaseT: rand(0.8,1.5), sp, w, ov, aggr, tick: 0, line, boss }
updateReel(dt) — машина фаз + накопители:
фазы: pull (рыба тянет) phaseT = 0.3 + ag*rand(0.4, 0.9) [0.7–1.2 с]
calm (отдых) phaseT = max(0.35, rand(0.7,1.7) - 0.3*ag)
держа: d -= 30 * rod().reel * dt [26–69 px/с втягивание]
tension += (pull ? (14+30*ag)*(0.6+0.9*ov) : 9) * dt
отпустил: tension -= 38*dt
pull: d += (3+4*ag)*(0.5+0.4*ov) * dt [до ~7.7 px/с отдача]
escape: держа -= 0.5*dt; отпустил += (0.2+0.4*ag)*dt (1 = сбежала)
исходы: tension >= line → SNAP (обрыв, save.snaps++)
d <= 0 → улов (у босса фаз 1–2: «рвёт леску», без штрафа,
дальше d = 0.6·d0 / 1.0·d0)
d >= 1.4*d0 → near-miss: «Сбежала!» (без выплат, Этап 23)
escape >= 1 → сбежала: «Сбежала!» (без выплат, Этап 23)
1.5 Улов и босс
catchFish():val = w * price * (shiny?3:1) * (boss?5:1) * (0.95…1.05) * (1 + achBonus().coinPct); рекорд, shiny, босс-флаги, квесты, достижения.- Босс:
stateBoss/bossPhase; фазы 1–2 завершаются авто-SNAPбез штрафа (сбросtension=0, следующая фаза глубже), фаза 3 — обычный улов ×5. Настоящий обрыв по натяжению и near-miss бой завершают (stateBoss=false).
1.6 Почему «too_easy» (причины по числам)
- Асимметрия сил. Втягивание —
30*reelpx/с (26–69 по удилищам), отдача рыбы — максимум(3+4*1)*(0.5+0.4*ov)≈ 7.7 px/с. Рыба никогда не выигрывает дистанцию: при правильном ритме «держал-отпускал» сход математически невозможен (0/329 у T5). - Дешёвое натяжение. Карась (ag≈0.17): в pull при удержании
(14+5.1)*(0.6+0.12)≈15.8/с→ до обрыва 6+ с; в calm —9/с→ 11 с. Бот с реакцией 140 мс обрывает только в наихудшем стечении фаз. - Нет выносливости. «Злость»
aggrпостоянна весь бой: нет дуги «первый рывок — паузы — усталость — слабый финиш», нет «усталой» рыбы, которую втягивают быстрее. Бой бесконечен, но статичен. - Нет тормоза (drag). У удилища только
line(предел прочности) иreel(скорость). Механики «уступить рыбе» нет: единственная свобода — держать/отпустить. Нет и «бесплатного» скольжения лески, которое в реальности гасит пик нагрузки. - Клёв 100%.
biteTimerвсегда приводит к рыбе: нет удачи, «чтения воды», пустых забросов (0 пустых у T5). - Босс = гарантия победы. Три фазы с бесплатными ретраями (обрыв фазы
без штрафа + сброс
tension), после фазы 3 дёргать до улова. 18/18 у T5.
2. Как это устроено в реальной рыбалке и в симуляторах
2.1 Тормоз катушки (drag)
У любого современного барабанного/безынерционного катушки есть тормоз — трения, которое лимитирует натяжение лески (Wikipedia, «Fishing reel», разделы Drag mechanisms / Setting the drag):
- Принцип: когда натяжение лески превышает усилие тормоза, шпуля «проскальзывает», отдавая леску, пока натяжение не упадёт обратно ниже уровня drag — drag это потолок нагрузки на шнур. Именно это позволяет вываживать рыбу сильнее, чем «на бумаге» держит шнур: рыбак «разговаривает» с рыбой (walk the fish) — гасит рывки тормозом, медленно утомляет её.
- Рекомендуемая настройка: drag ≈ 1/3…1/2 от разрывной нагрузки лески (пример из статьи: шнур 20 lb → drag 7–10 lb).
- Связанные механики: anti-reverse (леска идёт, ручка стоит — «рыба тянет, катушка крутится сама»), freespool (полное освобождение шпули на стартовом рывке), strike position на рычажном тормозе (слабый drag на момент подсечки, чтобы не вырвать крюк, полный drag — после).
В нашей игре tension/line — это натяжение и предел прочности, но самого
тормоза нет: нагрузка идёт либо «в ноль», либо «в обрыв». Добавление
drag на удилище превращает палочную механику «держать/отпустить» в
симуляционную: рывок сильнее drag → леска скользит (рыба отходит, нагрузка
держится у drag); рывок слабее drag → спокойно втягиваешь.
2.2 Рывки, паузы и выносливость рыбы
- Реальная рыба борется рывками: после подсечки — «первый рывок» (first run) с приманкой во рту, затем чередование рывков (секунды-полторы) и затишья. С утомлением рывки слабеют и короче, в конце — вялое «плывёт».
- Длительность боя зависит от размера и вида: мелочь — десятки секунд, крупный сом/осетёр — минуты. Выносливость — ключевая переменная: именно её исчерпание, а не «вытянул дистанцию», и есть конец боя.
- Игрок управляет темпом: подтягивает в паузах, «сдаёт» тормоз в рывках. Ошибка «держать в рывок» → пик нагрузки выше прочности → обрыв. Ошибка «не держать вовсе» → рыба уходит, сходит с крюка / уводит в заросли.
2.3 Момент подсечки
Подсечка — короткий энергичный подъём удилища в момент, когда рыба взяла приманку. Раньше — рыба «пробует» насадку (пустой удар), позже — заглотила и уже уходит (слабое зацепление, вырвется в первые секунды боя). Качество подсечки в симуляторах влияет на начало боя: глубина зацепа → сила первого рывка и вероятность схода в первые секунды.
2.4 Баллистика заброса, ветер, течение
- Заброс — баллистическая траектория: дальность = ф(мощности, веса приманки, угла, ветра). Ветер против/по — сдвигает точку приземления на десятки процентов; рыбак «щупает» линию пальцем (feathering), гася бегущую шпулю.
- Течение дрейфует поплавок после приводнения; рыба чаще подходит со стороны течения. Даже слабый дрейф делает каждый заброс уникальным.
2.5 Что делают симуляторы (паттерны)
- Fishing Planet / Fishing Sim World: настройка drag, у рыбы stamina, «разговор с рыбой» — подтяжка в паузах, сдача в рывках; усталая рыба тянет слабее и её втягивают заметно быстрее.
- Stardew Valley (tug-of-war): зона «рыбы» по полосе; втягиваешь, когда она стоит, отпускаешь, когда идёт; удержание «в движение» сжимает зону и ведёт к сходу. Наш цикл «держи в calm / отпускай в pull» — уже аналог, но без последствий по силам: сжиматься «зона» (escape) умеет, а вот силы сторон не соревнуются (см. 1.6).
- Мобильные симуляторы (Angler, Lure): та же пара «натяжение + выносливость», управление одним пальцем — подтверждение, что механика укладывается в наш ввод.
Вывод: ядро (фазы pull/calm, метры натяжения/дистанции, escape) у нас уже есть. Не хватает слоя симуляции: (а) drag как потолок нагрузки, (б) выносливость как движок дуги боя, (в) честный баланс сил, (г) пустые клёвы и качество подсечки, (д) риск у босса.
3. Приоритеты
P0 — берём в спринт 4 (раздел 4). P1 — следующий шаг после P0. P2 — «симуляционная мишени» на потом. По каждому: что меняем, что видит игрок, риск для тестов, объём.
P0-1. Выносливость рыбы (stamina) и структура рывков
Что меняем.
hook(): новые поляreel.st(выносливость 1→0) и стартовоеst0 = 0.65 + 0.35*min(1, w/sp.wMax)(крупная = выносливее); у боссаst0 = 1.- Структура фаз в
updateReel(): длительность рывка растёт сst, пауза растёт при усталости:pull: phaseT = (0.8 + 2.2*st) * (0.6 + 0.7*ag) * rand(0.8, 1.25) calm: phaseT = max(0.35, (1.3 - 0.8*st) * rand(0.8, 1.3)) - Расход (каждый кадр):
При
drain = (pull ? 0.05 + 0.16*ag : 0.008) + (holding ? 0.035 : 0.004) + (pull && !holding ? 0.05 : 0) // свободный бег тоже утомляет st = max(0, st - drain*dt)st ≤ 0.12— флагreel.tired: сила рывка ×0.45, рывки ×0.6, втягивание ×1.4 (усталую втягивают быстрее — как в реальности). - Оценка боя: карась ~10–15 с, карп/щука ~15–25 с, сом/осетёр ~25–40 с, босс ~30–50 с (3 фазы). Дуга боя появляется: свежая рыба рвёт, уставшая — «плывёт».
Что видит игрок. Тонкая горизонтальная полоска «выносливость» под
дистанцией (затухает к нулю, при tired — иконка 💤 и статус «Рыба устала —
втягивай!»). Силуэт рыбы в drawHookedFish() «вялый» при усталости (амплитуда
качества хвоста ниже, движение медленнее).
Риск для тестов. Средний. Тесты пишут reel.phase/phaseT явно (6, 9c,
9f, M4) — структура фаз не мешает; M4 p2/p3 проверяют d в ±5% от
0.6·d0/1.0·d0 сразу после авто-REEL — при st=1 (босс) скорость
втягивания в calm остаётся 30 px/с (см. P0-2), смещение за ~80 мс ≈ 2.4 px,
в допуске (у осётра d0 ≥ 57.5 px → допуск ±2.9 px, впритык — см. план
спринта: скорость втягивания при st=1 не менять). reel.st — новое поле,
тесты его не трогают; миграция сейва не нужна (бой не сохраняется).
Объём. ~35 строк index.html (hook + updateReel + drawMeters +
drawHookedFish).
P0-2. Тормоз катушки (drag) и скольжение лески
Что меняем.
RODS: новое полеdrag≈ ⅓ прочности:[34, 47, 63, 87](противline100/140/190/260) — консервативный низ вилки «⅓…½ разрывной» (раздел 2.1). Описание удилищ в лавке: «… · Drag 34».- Сила рыбы (новая функция, глобальная — тесты смогут вызывать):
Диапазон: мелочь ~15–25, карп/щука ~45–70, сом/осетёр/босс ~85–101.5.
fishForce(reel, ag, pull): pull: (16 + 34*st) * (0.5 + 0.9*ag) * (0.7 + 0.5*min(1.5, ov)) // 16…101.5 calm: 5 + 7*ag // «сопротивление» - Ядро
updateReel()— «перетягивание» через drag:F = fishForce(reel, ag, pull); R = holding ? 22 + 8*rod().reel : 0 держа: over = F - reel.drag tension += (8 + max(0, over)*1.1 + 14*ag*(pull ? 1 : 0.35)) * dt if over > 0: d += over * 0.22 * dt // леска скользит: рыба отходит else: d -= R * (1 + 0.35*(1-st)) * dt // втягивание (усталая — быстрее) отпустил: tension -= 38*dt if pull: d += F * 0.4 * dt // свободный бег (быстрее, чем сейчас) - Смысл: рывок сильнее drag → скольжение + рост нагрузки (держись или лопнет); рывок слабее drag → просто втягиваешь (мелочь можно «держать через всё»). Свободный бег без удержания теперь заметен: у сома ~40 px/с отдача (было ≤7.7) — пассивный игрок реально теряет дистанцию.
- Натяжение при мелкой рыбе остаётся мягким (over < 0 → базовые 8–15/с), при крупной — жёстким (у сома на 1-й удочке: ~96/с → обрыв ~1 с, если «держать в рывок»).
Что видит игрок. Правая вертикальная полоса натяжения получает метку drag (белая черта на 34% высоты шкалы): «выше черты — леска скользит, сдавайся или готовься к обрыву». Иконка над шкалой: 💪 (держишь, F<drag), ⚡ (рыба тянет), 🌀 (скольжение, over>0). Рывок визуально: поплавок/линия дёргаются, леска «прямая» при F>drag (меньше провис — см. P2-4).
Риск для тестов. Высокий (менят формулу, которую тесты обходят конструктивно) — разобран по кейсам, все 61 проходят при указанных числах:
- 4b (12 кг на 3-кг удочке → SNAP за 4 с): при ag=1, ov→1.5, st≈0.8: F≈88, over≈54 → tension ≈ 8+59+14 ≈ 81/с → обрыв ~1.3 с (+≤1.7 с calm) ✓
- 5 (
tension = line-0.3+ держать → SNAP за 3 с): рост ≥13/с в calm ✓ - 4c/9a/9d/9h (
d=0.4, calm, держать → CARD): F_calm≈12 < drag → втягивание 30 px/с → CARD за ~15 мс, tension не успевает дойти до line ✓ - 6/9c (
pull,phaseT=9999, не держать,d=1.38·d0→ WAIT за 15 с): свободный бег 6–40 px/с → 1.4·d0 за <1 с (или escape за ~2–5 с) ✓ - 9f (escape растёт/падает/сход): формула escape не меняется ✓
- M4 (p2: d в [0.55,0.65]·d0; p3: [0.95,1.05]·d0): см. P0-1 — при st=1 скорость в calm 30 px/с, как сейчас ✓
- 9l(3) (босс: tension 0.9·line, calm, держать → SNAP, затем tension≈0): рост 13/с в calm → SNAP ~0.8 с; сброс tension в авто-REEL не трогается ✓
Объём. ~35 строк index.html (RODS + fishForce + ядро updateReel +
метка drag в drawMeters + описания лавки).
P0-3. Ребаланс рисков (числа в рамках P0-1/P0-2)
Что меняем. Не отдельная функция, а выбор чисел так, чтобы появились три «честных» исхода:
- SNAP — при «держишь в рывок» у крупной рыбы (1–2 с до обрыва на слабой удочке, 3–5 с на титане);
- сход (escape) — при пассивной игре: escape-скорость не меняется
(0.2+0.4*ag)/с, но теперь к ней добавляется реальная потеря дистанции (свободный бег 20–40 px/с) → «не держать» = почти гарантированный сход за 3–6 с; - улов — при правильной игре (держишь в calm, «сдаёшь» в pull): мелочь
~10–15 с, крупная ~20–40 с;
tired-фаза ускоряет финиш. - Целевые метрики (проверяются живым плейтестом, см. раздел 4.4): «умный» бот (реакция 140 мс) — 80–95% улова мелочи, 50–80% крупной, 30–70% босса; «тупой» бот (всегда держать) — 30–50% мелочи, 0–20% крупной (SNAP); пассив (никогда не держать) — 100% сходов.
Риск для тестов. Тот же, что P0-1/P0-2 (числа уже согласованы с кейсами выше). Отдельного риска нет.
Объём. ~10 строк (константы) — включены в P0-1/P0-2.
P0-4. Пустые клёвы (не каждый заброс — рыба)
Что меняем.
- Глобал
let forceBite=false(тест-форс, паттернforceBoss). WAIT: приbiteTimer ≤ 0— ролл:if (forceBite || Math.random() < biteChance()) startBite() else biteTimer = rand(2.5, 5) / lure().bite // «пусто»: тише, попробуем ещё function biteChance(){ let p = 0.65 + 0.25*dist; // дальше = чаще (дальше = жирнее) if (dayPhase==='night') p *= 0.85; // ночь тише return p; // ~0.65–0.9, ночь ~0.55–0.77 }dist— нормированная дистанция заброса (та же, что вstartBite()). Пустой клёв молчалив: поплавок просто лежит (игрок сам решает ждать илиrecall()). Меню: строка «Не каждый заброс — с клёвом: подожди, если молчит».
Что видит игрок. Честные «пустые» 10–35% забросов — появляется элемент терпения и «чтения» (дальше = лучше). Никаких новых метров.
Риск для тестов. Высокий, но механически чинится: ~15 мест в tests/test.js
и ~5 в tests/orca-playtest.js ставят biteTimer=0.05 и ждут BITE —
добавить рядом forceBite=true (и сброс false после секции). Имена
глобалов не переименовываются (biteTimer на месте, тесты обновляются в
том же коммите — разрешено AGENTS.md). Миграция сейва не нужна.
Объём. ~18 строк index.html + ~20 строк правки тестов.
P1-1. Качество подсечки
Что. В hook(): quality = 1 - 0.6*|biteT/biteWindow - 0.5| (центр
окна — лучшее). Плохая подсечка → reel.loose = 1-quality: escape-скорость
×(1+loose), старт с tension=0, но st0 выше (рыба «не дозацепилась» —
дольше дёргается). Тост при loose>0.3: «Слабая подсечка! Рыба может сойти».
Видео-сигнал: в BITE кольцо-таймер вокруг поплавка (сжимается с biteT).
Риск тестов. Низкий (только множитель escape и старт; выплаты near-miss
не меняются). Объём. ~30 строк + 1–2 теста (loose-флаг, ускоренный
escape).
P1-2. Босс: реальный риск
Что. Босс на P0-механике уже опасен (F≈101, drag 34 → «держать в рывок»
= обрыв за ~1 с), но фазы дают бесплатные ретраи. Добавляем: (а) старт
st=1, drain ×0.8 (держится дольше), F ×1.15, длительность рывков ×1.3;
(б) «погружение»: в фазах 2–3 раз в ~8 с босс ныряет на 1.2–1.8 с —
сила ×1.5 и отдача ×1.5 даже при отпускании (сдаваться бесполезно —
только терпеть пик: tension растёт медленнее при holding=false? Нет:
over-ветка работает только при holding; при отпускании tension падает
-38/с, но d уходит — риск near-miss). Авто-REEL после фазы сбрасывает
dive=0 (важно для M4-тестов). Статус «⚠️ Босс ныряет!» + затемнение воды.
Риск тестов. Средний: M4-кейсы проходят (dive выключен на старте фазы,
числа p2/p3 проверены), но нужен новый тест «босс можно потерять»: forceBoss,
tension=line-1, phase='pull', держать → SNAP, stateBoss=false,
bossPhase=0 (боевой обрыв, не фазовый). Объём. ~45 строк + 1 тест.
P1-3. Стартовый рывок (first run)
Что. В hook(): reel.firstRun = rand(1.2, 2.5)*(0.5+0.5*ag) — первая
фаза после подсечки обязательно pull с этой длительностью (рыба уходит с
приманкой во рту). Статус 2–3 с: «Первый рывок — сдай тормоз!».
Риск тестов. Низкий: все тесты, ожидающие конкретную фазу, выставляют
phase/phaseT явно; M4-старт проверяет только флаги, затем сам ставит
d=0.4, calm. Объём. ~12 строк.
P2-1. Ветер
Что. Детерминированный от дня (тесты повторяют):
wind = (seededRand(daySeed(todayStr())+'wind')()*2-1)*1.5 (м/с, −1.5…1.5).
В doCast(): dist *= 1 + wind*0.08; в WAIT/REEL: дрейф поплавка
bobber.x += wind*3*dt (clamp до бровки). Визуал: существующий флажок на
лодке (drawBoat) наклоняется/трепещет сильнее, в HUD — «🌬 +1.2» у монет.
Соль 'wind' отделяет поток seededRand от квестов (daySeed без соли).
Риск тестов. Низкий: тесты не читают castTo/dist (M3 (b) зовёт
pickSpecies(1) напрямую). Объём. ~30 строк.
P2-2. Течение
Что. Второе число от той же соли дня: current = (seededRand(daySeed( todayStr())+'flow')()*2-1). Дрейф поплавка + current*2*dt (суммируется с
ветром в P2-1), startBite(): рыба приходит преимущественно со стороны
течения (fromLeft не 50/50). Чисто атмосфера + лёгкая вариативность.
Риск тестов. Низкий (M3 не проверяет сторону подхода). Объём. ~20 строк.
P2-3. Баллистика заброса
Что. doCast(): считать vx = distPx/0.65, vy0 под гравитацию g,
чтобы траектория приземлялась в castTo; CAST-ветка — честная интеграция
(vx,vy,g), время полёта остаётся ~0.65 с. Всплеск/рассыпание — от
скорости при ударе. После заброса — короткая метка «12 м» у бровки
(дистанция в «метрах» = dist×30 м — игрок видит результат мощности+ветра).
Риск тестов. Низкий: состояние WAIT приходит по castT>=1 — время не
меняется, меняется только формула отрисовки. Объём. ~35 строк.
P2-4. Визуальное натяжение
Что. Провис лески drawLineTo(sag) от tension (натяжение — прямая
линия, покой — провис 18 px); в REEL удилище в drawBoat() гнётся к
рыбе (control point), силуэт в рывке дергается сильнее (wob ∝ F).
Полностью canvas, механика не трогается. Риск тестов. Нулевой.
Объём. ~30 строк.
4. План спринта (P0 одной разработкой)
4.1 Состав
P0-1 выносливость + P0-2 drag + P0-3 ребаланс + P0-4 пустые клёвы.
Один файл index.html, ~130 строк изменений + ~45 строк тестов. Сейв
не меняется (stamina — поле боя, drag — константа удилища, forceBite —
тест-форс, как forceBoss): миграция в loadSave() не нужна.
Затронутые зоны: RODS (+drag), hook() (+st, +firstRun не берём — P1),
updateReel() (ядро), новая fishForce(), WAIT-ветка updateGame()
(+biteChance/forceBite), drawMeters() (полоска st, черта drag, иконка 🌀),
drawHookedFish() (усталость), текст меню, описания лавки.
4.2 Формулы (ИТОГ после ребаланса T2b)
В разделах P0-1/P0-2 ниже — начальные числа; финальные (подогнанные Monte-Carlo, см. 4.7) — здесь:
hook():
st0 = boss ? 1 : 0.65 + 0.35*min(1, w/sp.wMax)
d0 = 45 + 7*w // T2b: чем крупнее, тем больше буфер дистанции
reel.st = st0; reel.tired = false
updateReel(dt):
// 1) фазы (структура рывков) — без изменений
if (phaseT <= 0) {
pull: phaseT = (0.8 + 2.2*st) * (0.6 + 0.7*ag) * rand(0.8,1.25)
calm: phaseT = max(0.35, (1.3 - 0.8*st) * rand(0.8,1.3))
}
// 2) выносливость (T2b: медленная дуга — свежая тянет сильно, уставшая слабо)
drain = (pull ? 0.016+0.02*ag : 0.002) + (holding ? 0.006 : 0.001)
+ (pull && !holding ? 0.01 : 0)
st = max(0, st - drain*dt); if (st <= 0.12) tired = true
F = tired ? 0.45*fishForceBase : fishForceBase
fishForceBase(pull) = (16+34*st) * (0.5+0.9*ag) * (0.7+0.5*min(1.5, ov))
fishForceBase(calm) = 5 + 7*ag
// 3) перетягивание через drag
if (holding) {
over = F - rod().drag
tension += (10 + max(0,over)*1.1 + 22*ag*(pull?1:0.35)) * dt // T2b: чувствительнее
if (over > 0) d += over * 0.22 * dt // скольжение
else d -= (11+3*rod().reel)*(1+0.6*(1-st)) * dt // втягивание, уст. до ×1.6
} else {
tension -= 38*dt
if (pull) d += F*0.32*dt // свободный бег (T2b: 0.4 → 0.32)
}
tension = max(0, tension)
// 4) escape и исходы (T2b: отпускать в рывок — безопасно)
// escape: holding ? -0.5 : (!pull ? +0.2+0.4*ag : 0) [1/с]
// SNAP: tension >= line | улов: d<=0 (босс: фазы) |
// near-miss: d>=1.45*d0 (T2b: 1.4 → 1.45) | сбежала: escape>=1
WAIT:
if (biteTimer <= 0 && !biteFish)
forceBite || Math.random() < biteChance() ? startBite()
: biteTimer = rand(2.5,5)/lure().bite
biteChance = (0.65 + 0.25*dist) * (night ? 0.85 : 1)
Ключевые инварианты (T2b):
- втягивание в calm при
st=1=(11+3*rod().reel)px/с (14 / 14.9 / 16.1 / 17.9 по удилищам) и растёт с усталостью до ×1.6 (st=0). Старое «ровно30*rod().reel» снято: скорость — часть дуги усталости; фактические значения для старт-уловов 17–41% медленнее старых (учтено в балансе); escapeНЕ растёт приpull && !holding— отпускание в рывке безопасное (выигрышное) действие; его стоимость — только дистанция (near-miss); пассив («засиделся» без удержания в затишье) и «всегда держать» — те, кто платит сходами и обрывами;tension=0в авто-REEL босса не трогается;reelсохраняет все текущие поля (d, d0, tension, escape, holding, phase, phaseT, sp, w, ov, aggr, tick, line, boss) — новые толькоst,tired;- имена: новые глобалы
forceBite(let) иfishForce(function) — в списке защищённых имён AGENTS.md.
4.3 План тестов
Правка существующих (тот же коммит):
tests/test.js: во всех местахbiteTimer = 0.05(≈15 шт: §§3, 4b, 4c, 5, 6, 9a, 9c, 9d, 9h, 9j(c)(d), 9k, 9l(1,2b,3), 9f) добавитьforceBite=true; после секций, где форс включён, сбросforceBite=false. Удобнее — хелперforceBiteNow(page).tests/orca-playtest.js: то же в ~5 местах (тот же приём сbiteT=5).
Новые проверки (+5 → 66/66):
stamina: reel.st есть и убывает в бою— старт REEL,st0 = reel.st, держать 2 с →reel.st < st0.stamina: усталая рыба слабее—fishForceглобальна:st=1 → F1,st=0.05 → F2; проверитьF2 < F1*0.6иreel.tired===trueпосле установкиst=0.1.drag: рывок сильнее drag скользит леску— форс крупной рыбы (w=12, ov, aggr=1, st=1, phase='pull', phaseT=9999, holding=true), замерdчерез 1 с:d > d0(отдача) иtension > rod().drag(нагрузка ушла за черту).drag: рывок слабее drag — втягивание— мелочь (w=0.3, aggr≈0.2, st=0.9, phase='pull', phaseT=9999, holding=true): через 1 сd < d0(втягиваем, скольжения нет) иtension < rod().drag.пустой клёв: без forceBite и с Math.random=0.99+ клёва нет—state='WAIT',biteTimer=0.05, подменитьMath.random(как в M3 (b)), 700 мс →state==='WAIT' && !biteFish; вернутьMath.random.
Регресс-гарант: все 61 существующих без правок формул исходов — прогон 61 + 5 = 66. M4-блок (5 проверок) не правится вовсе — он и есть проверка, что босс-фазы не сломаны новой физикой.
4.4 Приёмка живым плейтестом (orca-playtest, боты)
Добавить в tests/orca-playtest.js (или отдельным прогоном T5-стиля)
сравнение трёх ботов × N=100+ рыб:
| Бот | Поведение | Ожидание |
|---|---|---|
| «умный» | держать в calm, отпускать в pull (реакция 140 мс) | мелочь 80–95% улов, крупная 50–80%, босс 30–70% |
| «тупой» | всегда держать | мелочь 30–50% (SNAP у крупных), крупная 0–20% |
| пассив | никогда не держать | 100% сходов за 3–8 с |
Плюс: доля пустых забросов 10–35%; медиана времени боя: мелочь 8–15 с, крупная 20–40 с. Критерий «не too_easy»: хотя бы один исход ≠ улов в каждом типе бота, и босс не 100%.
4.5 Оценка объёма
| Файл | Строк |
|---|---|
index.html (логика + UI + текст) |
~130 |
tests/test.js (правки + 5 новых) |
~45 |
tests/orca-playtest.js (правки + боты — опционально во 2-й итерации) |
~15–60 |
| Итого | ~190–235 |
Влезает в ориентир 150–300. Порядок работ внутри задачи: (1) P0-2 drag +
fishForce, (2) P0-1 stamina на новый каркас, (3) P0-3 подгонка чисел по
прогонам ботов, (4) P0-4 пустые клёвы + forceBite и правка тестов,
(5) node tools/check.mjs + npm test в tests/ (66/66) + живой плейтест.
4.6 Риски и откаты
- Чувствительность M4 p2/p3 (±5% d): единственная «впритык» точка.
Страхуем тем, что в кейсах p2/p3 палец отпущен (holding=false) и фаза
calm —
dстатичен, втягивание(11+3·reel)·(1+0.6·(1-st))в проверках M4 не участвует. Если тест всё же дрогнет на медленной CI — первый кандидат на локальное увеличение допуска в M4 (согласовать в коммите). - Флик пустых клёв в тестах: исключён
forceBite(детерминированный путь, как уforceBoss). - Сложность для новичка: если плейтест покажет <60% улова мелочи у
«умного» бота — ослабляем drag (34→40 на 1-й удочке) или
drain(×0.9); числа вынесены в формулы, правка = одна строка. - Откат: P0-4 (forceBite) и P0-1/P0-2 независимы логически; при проблеме с drag достаточно вернуть старое ядро updateReel (git-патч), выносливость и пустые клёвы останутся.
4.7 Т2b: ребаланс Monte-Carlo (приёмка до мержа)
Первый P0-коммит (70ff964) провалил приёмку 4.4: «умная» стратегия
(держать в calm, отпускать в pull) теряла 98–100% босса (свежий рывок
2.5–3.9 с длиннее escape-окна 1.7–2.3 с), «всегда держать» давало 100%
улова у босса на Титане/Карбоне, бои 1.3–7.2 с вместо 8–50 с,
stamina-дуга не видна. Т2b подогнал числа (только константы 4.2):
| Параметр | Было (P0) | Стало (T2b) | Зачем |
|---|---|---|---|
d0 |
40+3.5·w |
45+7·w |
буфер дистанции под «сдавать в рывок» |
escape при pull&&!holding |
+(0.2+0.4·ag) |
0 |
отпускание в рывке безопасно |
| free-run | F·0.4 |
F·0.32 |
стоимость отпускания — дистанция |
| втягивание | (22+8·reel)·(1+0.35·(1-st)) |
(11+3·reel)·(1+0.6·(1-st)) |
бои длиннее; усталая — до ×1.6 |
| drain | 0.05+0.16·ag / 0.008 / … |
0.016+0.02·ag / 0.002 / … |
дуга усталости за бой, а не за фазу |
| натяжение | 8+over·1.1+14·ag·… |
10+over·1.1+22·ag·… |
«всегда держать» обрывает крупных |
| near-miss | 1.4·d0 |
1.45·d0 |
окно на «сдачу» в сильном рывке |
Приёмка (точные формулы index.html, N=400/сценарий, dt=1/60 с, три бота 4.4; «умный» = держать calm/отпускать pull, реакция 140 мс):
| Сценарий | умный (улов/SNAP/сход, t_med) | держать | пассив |
|---|---|---|---|
| карась/Дедова (мелочь) | 98% / 0 / 2% — 13.0 с | 100% улов, 2.9 с | 100% сход, 5.9 с |
| окунь/Дедова (мелочь) | 85% / 0 / 15% — 11.7 с | 100% улов | 100% сход |
| лещ/Стекло | 92% / 0 / 8% — 14.1 с | 100% улов | 100% сход |
| сом/Карбон (крупная) | 66% / 0 / 34% — 20.4 с (уловы) | 79% / SNAP 21% | 100% сход, 5.7 с |
| сом/Стекло (крупная, слабая) | 39% / 0 / 62% | 39% / SNAP 61% | 100% сход |
| босс/Титан | 59.5% / 0 / 40% — 42.8 с | 62% / SNAP 38% | 100% сход, 6.7 с |
| босс/Титан lv4 + Блёстка lv4 (M5 max) | 43.5% / 0 / 56.5% — 55.2 с | 77.3% / SNAP 22.8% — 25.8 с | 100% сход |
| босс/Карбон | 0% / 0 / 100% (недогрейд) | 40% / SNAP 60% | 100% сход |
Смысл метрик: умный >80% мелочи ✓, крупная 50–80% ✓ (66% на Карбоне; blended t_med по всем исходам 15.9 с — низ из-за 34% ранних сходов, медиана уловов 20.4 с в окне 20–40 с), босс <100% на топ-удочке ✓ (59.5%); «всегда держать» на крупной у слабого удилища — SNAP ✓ (61%), у босса — SNAP ✓ (38%); пассив — 100% сходов ✓. Stamina-дуга видна: st при улове 0.2–0.5 (крупная/босс 0.0) против старта 0.7–1.0.
Сознательно оставлено в P1:
- P1-2 (обязательный follow-up) — детализация риска босса: drain×0.8, F×1.15, рывки×1.3, «погружение», тест «босс можно потерять» (боевой обрыв, не фазовый). Базовый риск T2b уже даёт (умный: 40% сходов на Титане, «всегда держать»: 38% обрывов); босс на недогрейде (Карбон/Стекло, maxW 8–18 против 5–40 кг) — 0% улова у любого бота: это про «грейдись, прежде чем лезть на осетра», а не баланс;
- «всегда держать» на мелочи = 100% уловов (2.9 с) — мелочь на своей удочке честна и без риска (цель 4.4 «30–50%» недостижима при умных 8–15 с: dumb-время = d0/скорость ≤ умного-времени);
- «тупой» бот не читает черту drag (держит всегда) — продвинутая стратегия «держать, пока F<drag» не формализована (кандидат P1-3).
5. Источники
- Wikipedia, «Fishing reel»: разделы Drag mechanisms (принцип: drag — потолок натяжения; шпуля отдаёт леску, пока нагрузка не упадёт ниже drag), Setting the drag (drag ≈ ⅓…½ разрывной нагрузки лески; пример 20 lb → 7–10 lb), Anti-reverse reel, Braking mechanisms (feathering шпули).
- DEVLOG.md, этап 14 (спринт 3): вердикт T5 too_easy (0 сходов/329, босс 18/18), «вываживание и ×5 босса требуют правки логики».
- Паттерны симуляторов (Fishing Planet, Fishing Sim World, Stardew Valley tug-of-war, мобильные Angler/Lure) — по описанию механик: drag-настройка, stamina рыбы, «walk the fish», зона-перетягивание. Точные формулы сторонних игр не цитируются — числа P0 подобраны под наши тесты и целевые метрики 4.4, и перепроверяются живым плейтестом.