657 lines
47 KiB
Markdown
657 lines
47 KiB
Markdown
# Спринт 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» (причины по числам)
|
||
|
||
1. **Асимметрия сил.** Втягивание — `30*reel` px/с (26–69 по удилищам), отдача
|
||
рыбы — максимум `(3+4*1)*(0.5+0.4*ov)` ≈ 7.7 px/с. Рыба **никогда** не
|
||
выигрывает дистанцию: при правильном ритме «держал-отпускал» сход
|
||
математически невозможен (0/329 у T5).
|
||
2. **Дешёвое натяжение.** Карась (ag≈0.17): в pull при удержании
|
||
`(14+5.1)*(0.6+0.12)≈15.8/с` → до обрыва 6+ с; в calm — `9/с` → 11 с.
|
||
Бот с реакцией 140 мс обрывает только в наихудшем стечении фаз.
|
||
3. **Нет выносливости.** «Злость» `aggr` постоянна весь бой: нет дуги
|
||
«первый рывок — паузы — усталость — слабый финиш», нет «усталой» рыбы,
|
||
которую втягивают быстрее. Бой бесконечен, но статичен.
|
||
4. **Нет тормоза (drag).** У удилища только `line` (предел прочности) и
|
||
`reel` (скорость). Механики «уступить рыбе» нет: единственная свобода —
|
||
держать/отпустить. Нет и «бесплатного» скольжения лески, которое в
|
||
реальности гасит пик нагрузки.
|
||
5. **Клёв 100%.** `biteTimer` всегда приводит к рыбе: нет удачи, «чтения
|
||
воды», пустых забросов (0 пустых у T5).
|
||
6. **Босс = гарантия победы.** Три фазы с бесплатными ретраями (обрыв фазы
|
||
без штрафа + сброс `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]` (против
|
||
`line` 100/140/190/260) — консервативный низ вилки «⅓…½ разрывной»
|
||
(раздел 2.1). Описание удилищ в лавке: «… · Drag 34».
|
||
- Сила рыбы (новая функция, глобальная — тесты смогут вызывать):
|
||
```
|
||
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 // «сопротивление»
|
||
```
|
||
Диапазон: мелочь ~15–25, карп/щука ~45–70, сом/осетёр/босс ~85–101.5.
|
||
- Ядро `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):**
|
||
1. `stamina: reel.st есть и убывает в бою` — старт REEL, `st0 = reel.st`,
|
||
держать 2 с → `reel.st < st0`.
|
||
2. `stamina: усталая рыба слабее` — `fishForce` глобальна: `st=1 → F1`,
|
||
`st=0.05 → F2`; проверить `F2 < F1*0.6` и `reel.tired===true` после
|
||
установки `st=0.1`.
|
||
3. `drag: рывок сильнее drag скользит леску` — форс крупной рыбы
|
||
(`w=12, ov, aggr=1, st=1, phase='pull', phaseT=9999, holding=true`),
|
||
замер `d` через 1 с: `d > d0` (отдача) и `tension > rod().drag`
|
||
(нагрузка ушла за черту).
|
||
4. `drag: рывок слабее drag — втягивание` — мелочь (`w=0.3, aggr≈0.2,
|
||
st=0.9, phase='pull', phaseT=9999, holding=true`): через 1 с `d < d0`
|
||
(втягиваем, скольжения нет) и `tension < rod().drag`.
|
||
5. `пустой клёв: без 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, и перепроверяются живым плейтестом.
|