MAJOR-1 (skill-curve): отпускание в рывок теперь безопасно и выигрышно — escape-бар не растёт при pull && !holding (стоимость отпускания — только дистанция); пассив = 100% сходов, «всегда держать» = обрывы крупных (сом/Стекло 61% SNAP) и босса (38% SNAP на Титане). Числа (только константы updateReel/hook): d0 45+7w, drain ×~4 медленнее (дуга усталости за бой), натяжение 10+over·1.1+22·ag, втягивание (11+3·reel)·(1+0.6(1-st)), free-run F·0.32, near-miss 1.45·d0. Monte-Carlo (точные формулы, N=400/сценарий, dt=1/60, 3 бота доки 4.4): умный: карась 98%/13.0с, окунь 85%/11.7с, лещ 92%, сом/Карбон 66%/20.4с (уловы), босс/Титан 59.5%/42.8с (т.е. босс ≠ 100% даже на топ-удочке); держать: мелочь 100%, сом/Стекло 39%/SNAP 61%, босс/Титан 62%/SNAP 38%; пассив: 100% сходов за 3.5–6.7с. Stamina-дуга видна: st при улове 0.2–0.5 против старта 0.7–1.0. Таблица и остаток P1-2 — доки §4.7. MAJOR-2: подсказка «⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡» теперь соответствует механике (отпускание в рывке = выигрышное действие) — текст сохранён. MINOR: AGENTS.md — forceBite/fishForce в защищённых именах, 68 проверок, секция «Физика боя»; доки: инвариант «30·reel px/с» заменён фактической формулой (решение: правка доки, код — источник истины; MINOR-2 ревью). NIT: сброс forceBite/forceBoss в btnResetYes; полоска stamina сдвинута после ⛵ (dx+18); near-miss в orca-playtest 1.45·d0; скриншоты свежие. Тесты: порогов, упёршихся в новые константы, нет (запасы ×2.4–×3.1 проверены прогоном) — 68/68 PASS ×2 подряд, node tools/check.mjs OK. Живой Orca-браузер: босс 3 фазы прогнан вживую (фазовый SNAP → d=0.6·d0, tension=0; pointercancel в фоне = сход — поведение корректное).
1702 lines
82 KiB
HTML
1702 lines
82 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||
<meta name="theme-color" content="#0d3b66">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<title>Рыбалка</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}
|
||
html,body{width:100%;height:100%;overflow:hidden;background:#0d3b66;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}
|
||
#game{position:fixed;inset:0;touch-action:none;display:block}
|
||
button{font-family:inherit;cursor:pointer}
|
||
.hidden{display:none!important}
|
||
|
||
/* ---------- HUD ---------- */
|
||
#hud{position:fixed;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:center;
|
||
padding:calc(8px + env(safe-area-inset-top)) 10px 8px;z-index:10;pointer-events:none}
|
||
#coins{background:linear-gradient(rgba(9,32,58,.72),rgba(6,25,45,.62));padding:8px 15px;border-radius:999px;color:#ffe9a8;font-weight:700;
|
||
font-size:17px;border:1px solid rgba(255,233,168,.22);
|
||
box-shadow:0 3px 12px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.12);text-shadow:0 1px 2px rgba(0,0,0,.4)}
|
||
.hudbtns{display:flex;gap:8px;pointer-events:auto}
|
||
.hudbtns button{width:46px;height:46px;font-size:21px;border-radius:14px;
|
||
border:1px solid rgba(255,255,255,.16);background:linear-gradient(rgba(9,32,58,.72),rgba(6,25,45,.62));color:#fff;
|
||
box-shadow:0 3px 10px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.1);
|
||
transition:background .15s,transform .12s,box-shadow .12s}
|
||
.hudbtns button:hover{background:rgba(30,60,95,.8);transform:translateY(-1px)}
|
||
.hudbtns button:active{background:rgba(30,60,95,.85);transform:translateY(1px);box-shadow:0 1px 4px rgba(0,0,0,.3)}
|
||
|
||
#btnRecall{position:fixed;right:12px;bottom:calc(16px + env(safe-area-inset-bottom));width:54px;height:54px;
|
||
border-radius:50%;font-size:24px;background:linear-gradient(rgba(9,32,58,.8),rgba(6,25,45,.7));color:#fff;
|
||
border:1px solid rgba(255,255,255,.22);z-index:10;
|
||
box-shadow:0 4px 14px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.12);
|
||
transition:background .15s,transform .12s}
|
||
#btnRecall:hover{background:rgba(30,60,95,.85);transform:scale(1.05)}
|
||
#btnRecall:active{background:rgba(30,60,95,.9);transform:scale(.96)}
|
||
|
||
#toast{position:fixed;left:50%;bottom:calc(92px + env(safe-area-inset-bottom));transform:translateX(-50%);
|
||
background:linear-gradient(rgba(9,32,58,.94),rgba(5,20,38,.92));color:#fff;padding:11px 20px;border-radius:999px;font-size:15px;font-weight:600;
|
||
z-index:30;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;
|
||
border:1px solid rgba(255,255,255,.18);
|
||
box-shadow:0 6px 20px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.1);
|
||
max-width:92vw;overflow:hidden;text-overflow:ellipsis}
|
||
|
||
/* ---------- Overlays ---------- */
|
||
.overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
|
||
background:rgba(4,16,32,.62);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:20;padding:16px}
|
||
.panel{position:relative;background:linear-gradient(165deg,#1a4d7d 0%,#123a61 55%,#0d2f4e 100%);
|
||
border:1px solid rgba(120,180,235,.35);border-radius:22px;
|
||
padding:22px;width:min(92vw,370px);max-height:86vh;overflow-y:auto;color:#eaf4ff;text-align:center;
|
||
box-shadow:0 24px 60px rgba(0,0,0,.55),0 4px 14px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.14)}
|
||
.panel::-webkit-scrollbar{width:8px}
|
||
.panel::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18);border-radius:99px}
|
||
.panel::-webkit-scrollbar-track{background:transparent}
|
||
.title{font-size:34px;font-weight:800;letter-spacing:1px;margin-bottom:10px;
|
||
text-shadow:0 2px 10px rgba(0,0,0,.4),0 1px 0 rgba(255,255,255,.15)}
|
||
.panel p{font-size:14.5px;line-height:1.5;color:#bcd6ee;margin-bottom:4px}
|
||
.big{width:100%;margin-top:16px;padding:15px;font-size:19px;font-weight:800;color:#04263d;
|
||
background:linear-gradient(#ffe08a,#f5a623);border:none;border-radius:14px;
|
||
box-shadow:0 4px 0 #b97708,0 8px 18px rgba(245,166,35,.35);
|
||
transition:transform .12s,box-shadow .12s,filter .12s}
|
||
.big:hover{filter:brightness(1.07)}
|
||
.big:active{transform:translateY(2px);box-shadow:0 2px 0 #b97708,0 4px 10px rgba(245,166,35,.3)}
|
||
.panelhead{font-size:22px;font-weight:800;margin-bottom:4px;text-shadow:0 1px 6px rgba(0,0,0,.35)}
|
||
#btnCloseShop{position:absolute;top:12px;right:12px;width:36px;height:36px;border-radius:50%;border:none;
|
||
background:rgba(255,255,255,.12);color:#fff;font-size:17px;transition:background .15s,transform .12s}
|
||
#btnCloseShop:hover{background:rgba(255,255,255,.22);transform:rotate(90deg)}
|
||
#btnCloseShop:active{transform:rotate(90deg) scale(.92)}
|
||
|
||
.tabs{display:flex;gap:8px;margin:10px 0 6px}
|
||
.tabs button{flex:1;padding:11px;border-radius:12px;border:none;background:rgba(255,255,255,.1);
|
||
color:#cfe4f7;font-weight:700;font-size:15px;box-shadow:inset 0 1px 0 rgba(255,255,255,.08);
|
||
transition:background .15s,color .15s,transform .12s,box-shadow .15s}
|
||
.tabs button:hover{background:rgba(255,255,255,.17)}
|
||
.tabs button:active{transform:scale(.96)}
|
||
.tabs button.active{background:linear-gradient(#ffd76e,#f5a623);color:#04263d;
|
||
box-shadow:0 3px 12px rgba(245,166,35,.4),inset 0 1px 0 rgba(255,255,255,.35)}
|
||
.section-title{font-size:12px;letter-spacing:1.5px;color:#8fb8dd;text-transform:uppercase;margin:14px 0 8px;text-align:left}
|
||
.item{display:flex;align-items:center;justify-content:space-between;gap:10px;background:rgba(255,255,255,.06);
|
||
border:1px solid rgba(255,255,255,.09);border-radius:12px;padding:10px 12px;margin-bottom:8px;text-align:left;
|
||
transition:background .15s,border-color .15s,transform .12s}
|
||
.item:hover{background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.2);transform:translateY(-1px)}
|
||
.itemname{font-weight:700;font-size:15px}
|
||
.itemdesc{font-size:12px;color:#9fc3e2;margin-top:2px;line-height:1.35}
|
||
.buybtn{min-width:96px;padding:10px 10px;border-radius:10px;border:none;font-weight:700;font-size:13px;
|
||
background:#2e6da3;color:#fff;white-space:nowrap;flex-shrink:0;
|
||
box-shadow:0 2px 6px rgba(0,0,0,.25),inset 0 1px 0 rgba(255,255,255,.15);transition:filter .12s,transform .12s,background .15s}
|
||
.buybtn:hover{filter:brightness(1.15)}
|
||
.buybtn:active{transform:scale(.96)}
|
||
.buybtn.owned{background:rgba(255,255,255,.14)}
|
||
.buybtn.owned:hover{filter:none;background:rgba(255,255,255,.22)}
|
||
.buybtn.current{background:#2ecc71;color:#04263d;box-shadow:0 2px 8px rgba(46,204,113,.4)}
|
||
.buybtn:disabled{opacity:.45;cursor:default}
|
||
.statsline{font-size:13px;color:#9fc3e2;margin-bottom:6px}
|
||
.jrow{display:flex;align-items:center;gap:10px;padding:9px 10px;background:rgba(255,255,255,.05);
|
||
border:1px solid rgba(255,255,255,.06);border-radius:10px;margin-bottom:6px;font-size:14px;text-align:left;
|
||
transition:background .15s,border-color .15s}
|
||
.jrow:hover{background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.14)}
|
||
.jrow .je{font-size:22px;width:30px;text-align:center}
|
||
.jrow .jn{flex:1}
|
||
.jrow .jc{color:#9fc3e2;font-weight:600;font-size:13px}
|
||
.jrow .jsn{color:#ffd75e;font-weight:800;font-size:12px;margin-left:4px}
|
||
.jrow.unknown .je,.jrow.unknown .jn{color:#5d7f9e}
|
||
.jrow .adesc{font-size:11px;color:#9fc3e2}
|
||
.jrow.locked{opacity:.5}
|
||
.jrow.got .jc{color:#2ecc71;font-weight:800;font-size:16px}
|
||
.jrow.qdone{background:rgba(46,204,113,.14)}
|
||
.jrow.qdone .jc{color:#2ecc71;font-weight:700}
|
||
.streak{display:flex;gap:6px;justify-content:center;margin:8px 0 4px}
|
||
.st-dot{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;
|
||
background:rgba(255,255,255,.08);color:#5d7f9e;border:2px solid rgba(255,255,255,.12);
|
||
transition:transform .15s,box-shadow .15s}
|
||
.st-dot.on{background:linear-gradient(#ffd76e,#f5a623);color:#04263d;border-color:#ffd75e}
|
||
.st-dot.cur{box-shadow:0 0 12px rgba(255,215,94,.85);transform:scale(1.12)}
|
||
|
||
#card .bigemoji{font-size:74px;margin:4px 0 2px;display:block;animation:bob 1.2s ease-in-out infinite;
|
||
filter:drop-shadow(0 6px 10px rgba(0,0,0,.4))}
|
||
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-9px)}}
|
||
#card .rarity{display:inline-block;font-weight:800;font-size:12px;letter-spacing:1.5px;text-transform:uppercase;
|
||
padding:5px 14px;border-radius:999px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15)}
|
||
#card .cname{font-size:24px;font-weight:800;margin:6px 0 6px;text-shadow:0 1px 6px rgba(0,0,0,.4)}
|
||
#card .cval{font-size:20px;font-weight:800;color:#ffe9a8;margin-bottom:6px;text-shadow:0 1px 4px rgba(0,0,0,.3)}
|
||
#card .csub{font-size:13px;color:#9fc3e2;margin-bottom:4px}
|
||
.record{display:inline-block;background:linear-gradient(#ffd76e,#f5a623);color:#04263d;font-weight:800;font-size:14px;letter-spacing:1px;padding:4px 14px;border-radius:999px;animation:recpulse .8s ease-in-out infinite;box-shadow:0 0 14px rgba(255,215,94,.5)}
|
||
@keyframes recpulse{0%,100%{transform:scale(1)}50%{transform:scale(1.09)}}
|
||
.shiny-badge{display:inline-block;background:linear-gradient(#fff6c9,#ffd75e);color:#6b4a00;font-weight:800;font-size:14px;letter-spacing:1px;padding:4px 14px;border-radius:999px;margin-left:8px;
|
||
border:1px solid #ffd75e;box-shadow:0 0 14px rgba(255,215,94,.75);animation:recpulse .8s ease-in-out infinite}
|
||
#card.shiny-card .panel{border-color:#ffd75e;box-shadow:0 0 34px rgba(255,215,94,.5),0 10px 40px rgba(0,0,0,.5)}
|
||
#card.shiny-card .bigemoji{filter:drop-shadow(0 0 12px rgba(255,215,94,.9))}
|
||
.boss-badge{display:inline-block;background:linear-gradient(#fff6c9,#ffd75e);color:#6b4a00;font-weight:800;font-size:14px;letter-spacing:1px;padding:4px 14px;border-radius:999px;margin-left:8px;
|
||
border:1px solid #ffd75e;box-shadow:0 0 14px rgba(255,215,94,.75);animation:recpulse .8s ease-in-out infinite}
|
||
#card.boss-card .panel{border-color:#ffd75e;box-shadow:0 0 34px rgba(255,215,94,.55),0 10px 40px rgba(0,0,0,.5)}
|
||
#card.boss-card .bigemoji{filter:drop-shadow(0 0 12px rgba(255,215,94,.95))}
|
||
#card{animation:pop .3s ease}
|
||
@keyframes pop{from{transform:scale(.75);opacity:0}to{transform:scale(1);opacity:1}}
|
||
|
||
.twobtns{display:flex;gap:10px;margin-top:16px}
|
||
.twobtns .big{margin-top:0;width:auto;flex:1}
|
||
.big.danger{background:linear-gradient(#ff8a7a,#e74c3c);box-shadow:0 4px 0 #96281b,0 8px 18px rgba(231,76,60,.35);color:#fff}
|
||
.big.danger:hover{filter:brightness(1.08)}
|
||
.big.danger:active{box-shadow:0 2px 0 #96281b,0 4px 10px rgba(231,76,60,.3)}
|
||
|
||
/* ---------- Адаптив: узкие и низкие экраны ---------- */
|
||
@media (max-width:420px){
|
||
.title{font-size:27px}
|
||
.panel{padding:16px;border-radius:18px}
|
||
.panelhead{font-size:19px}
|
||
.panel p{font-size:13.5px}
|
||
.big{padding:13px;font-size:17px;border-radius:12px}
|
||
#card .bigemoji{font-size:58px}
|
||
#card .cname{font-size:21px}
|
||
.hudbtns button{width:42px;height:42px;font-size:19px;border-radius:12px}
|
||
#coins{font-size:15px;padding:7px 12px}
|
||
.tabs button{padding:10px 6px;font-size:13.5px}
|
||
.jrow .je{font-size:20px;width:26px}
|
||
}
|
||
@media (max-height:620px){
|
||
.panel{max-height:92vh;padding:14px 16px}
|
||
.title{font-size:24px;margin-bottom:6px}
|
||
.panel p{font-size:13px;line-height:1.4}
|
||
#card .bigemoji{font-size:50px;margin:2px 0 0}
|
||
.big{margin-top:10px;padding:11px;font-size:16px}
|
||
.tabs button{padding:8px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="game"></canvas>
|
||
|
||
<div id="hud">
|
||
<div id="coins">🪙 0</div>
|
||
<div class="hudbtns">
|
||
<button id="btnShop">🛒</button>
|
||
<button id="btnReset" title="Сбросить прогресс">🗑️</button>
|
||
<button id="btnMute">🔊</button>
|
||
</div>
|
||
</div>
|
||
<button id="btnRecall" class="hidden">↩</button>
|
||
<div id="toast"></div>
|
||
|
||
<!-- Меню -->
|
||
<div id="menu" class="overlay">
|
||
<div class="panel">
|
||
<div class="title">🎣 РЫБАЛКА</div>
|
||
<p>👆 <b>Держи палец</b> — заряжай бросок,<br>
|
||
<b>отпусти</b> — кидай удочку.<br>
|
||
Дальше бросок = реже и жирнее рыба!</p>
|
||
<p style="margin-top:8px">Видишь <b style="color:#ff6b5e">!</b> — быстро жми, чтобы зацепить.<br>
|
||
При вываживании: тяни, когда рыба отдыхает,<br>
|
||
отпусти, когда она <b>тянет</b> — иначе лопнет!<br>
|
||
Не тяни слишком долго — рыба <b>сбегает</b> (красная полоса)!</p>
|
||
<p style="margin-top:8px">У каждой рыбы свой <b>вес</b>, продажа — по цене за кг.<br>
|
||
У удочек есть <b>предел веса</b>: перевесишь — шнур лопнет! <br>
|
||
Лучшие снасти = больше шансов на трофей 🏆</p>
|
||
<p style="margin-top:8px">Не каждый заброс — с клёвом: если молчит — подожди.<br>
|
||
У удилищ есть <b>тормоз (drag)</b>: рывок сильнее — леска скользит!</p>
|
||
<button id="btnPlay" class="big">Играть</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Магазин -->
|
||
<div id="shop" class="overlay hidden">
|
||
<div class="panel">
|
||
<button id="btnCloseShop">×</button>
|
||
<div class="panelhead">🛒 Лавка рыбака</div>
|
||
<div class="tabs">
|
||
<button id="tabTackle" class="active">Снасти</button>
|
||
<button id="tabJournal">Дневник</button>
|
||
<button id="tabRewards" title="Награды">🎯</button>
|
||
<button id="tabAch" title="Достижения">🏅</button>
|
||
</div>
|
||
<div id="shopBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Подтверждение сброса -->
|
||
<div id="resetDlg" class="overlay hidden">
|
||
<div class="panel">
|
||
<div class="panelhead">🗑️ Сбросить прогресс?</div>
|
||
<p style="margin-top:8px">Монеты, снасти и коллекция будут стёрты.<br>Это действие <b>нельзя отменить</b>.</p>
|
||
<div class="twobtns">
|
||
<button id="btnResetNo" class="big">Отмена</button>
|
||
<button id="btnResetYes" class="big danger">Сбросить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Карточка улова -->
|
||
<div id="card" class="overlay hidden">
|
||
<div class="panel">
|
||
<div class="rarity" id="cardRarity">Обычная</div>
|
||
<div><span class="record hidden" id="cardRecord">🏆 РЕКОРД!</span><span class="shiny-badge hidden" id="cardShiny">✨ ШИНИ!</span><span class="boss-badge hidden" id="cardBoss">🏆 БОСС!</span></div>
|
||
<span class="bigemoji" id="cardEmoji">🐟</span>
|
||
<div class="cname" id="cardName">Карась</div>
|
||
<div class="csub" id="cardSub">0.0 кг · 0 🪙/кг</div>
|
||
<div class="cval" id="cardVal">+0 🪙</div>
|
||
<button id="btnCardOk" class="big">Отлично!</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
'use strict';
|
||
/* =========================================================
|
||
ДАННЫЕ
|
||
========================================================= */
|
||
const SPECIES=[
|
||
{id:'carass', name:'Карась', emoji:'🐟', rarity:0, weight:100, minDist:0.00, strength:0.25, wMin:0.1, wMax:0.8, price:20, hue:45},
|
||
{id:'perch', name:'Окунь', emoji:'🐠', rarity:0, weight:55, minDist:0.05, strength:0.30, wMin:0.1, wMax:1.5, price:22, hue:120},
|
||
{id:'bream', name:'Лещ', emoji:'🐠', rarity:1, weight:26, minDist:0.30, strength:0.40, wMin:0.3, wMax:3.0, price:25, hue:210},
|
||
{id:'carp', name:'Карп', emoji:'🐡', rarity:1, weight:22, minDist:0.35, strength:0.45, wMin:0.5, wMax:8.0, price:28, hue:30},
|
||
{id:'pike', name:'Щука', emoji:'🦈', rarity:2, weight:9, minDist:0.50, strength:0.60, wMin:1.0, wMax:10.0, price:35, hue:100},
|
||
{id:'catfish',name:'Сом', emoji:'🐡', rarity:2, weight:5, minDist:0.65, strength:0.75, wMin:2.0, wMax:20.0, price:45, hue:260},
|
||
{id:'sturge', name:'Осётр', emoji:'🐋', rarity:3, weight:2.5, minDist:0.80, strength:0.85, wMin:5.0, wMax:40.0, price:65, hue:200},
|
||
{id:'gold', name:'Золотая рыбка', emoji:'🎏', rarity:4, weight:1.2, minDist:0.90, strength:0.90, wMin:0.05, wMax:1.0, price:1500, hue:45},
|
||
];
|
||
const RARITY_NAMES=['Обычная','Необычная','Редкая','Эпическая','Легендарная'];
|
||
const RARITY_COLORS=['#9fc3e2','#2ecc71','#4da3ff','#c56cf0','#ffd75e'];
|
||
|
||
/* M3: ночная таблица — те же виды, но веса спавна смещены к ночной рыбе (клон SPECIES) */
|
||
const SPECIES_NIGHT=SPECIES.map(s=>Object.assign({},s,{weight:({carass:40,perch:80,bream:10,carp:8,pike:22,catfish:18,sturge:4,gold:1.2})[s.id]}));
|
||
|
||
/* Шини-рыба: шанс золотого улова и множитель его цены (let — автотест форсирует значения) */
|
||
let SHINY_CHANCE=0.03, SHINY_MULT=3;
|
||
|
||
/* M4: босс-рыба (Осётр-босс) — тест-форс появления (let — автотест, как SHINY_CHANCE) */
|
||
let forceBoss=false;
|
||
|
||
/* Спринт 4: пустые клёвы — тест-форс клёва (let — автотест, паттерн forceBoss) */
|
||
let forceBite=false;
|
||
|
||
const RODS=[
|
||
{id:0, name:'Дедова удочка', cost:0, maxW:3, line:100, drag:34, reel:1.0, size:0.00, desc:'До 3 кг · Нагрузка 100 · Drag 34 · Подсечка ×1.0'},
|
||
{id:1, name:'Стеклопластик', cost:250, maxW:8, line:140, drag:47, reel:1.3, size:0.08, desc:'До 8 кг · Нагрузка 140 · Drag 47 · Подсечка ×1.3'},
|
||
{id:2, name:'Карбоновая', cost:700, maxW:18, line:190, drag:63, reel:1.7, size:0.16, desc:'До 18 кг · Нагрузка 190 · Drag 63 · Подсечка ×1.7'},
|
||
{id:3, name:'Титан', cost:1600, maxW:40, line:260, drag:87, reel:2.3, size:0.28, desc:'До 40 кг · Нагрузка 260 · Drag 87 · Подсечка ×2.3'},
|
||
];
|
||
const LURES=[
|
||
{id:0, name:'Простой крючок', cost:0, cast:1.00, bite:1.0, rare:0.0, size:0.00, desc:'Надёжно, но рыба не в восторге'},
|
||
{id:1, name:'Червь', cost:150, cast:1.00, bite:1.3, rare:0.2, size:0.12, desc:'Клёв на 30% чаще, шанс крупной выше'},
|
||
{id:2, name:'Воблер', cost:450, cast:1.15, bite:1.2, rare:0.8, size:0.28, desc:'Дальше бросок, приманивает крупных'},
|
||
{id:3, name:'Золотая блёстка',cost:1200, cast:1.20, bite:1.4, rare:2.0, size:0.50, desc:'Лучшее, что есть. К ней плывут легенды'},
|
||
{id:4, name:'Фонарик', cost:500, cast:1.00, bite:1.0, rare:0.0, size:0.00, nightOnly:true, desc:'Позволяет рыбачить ночью'},
|
||
];
|
||
|
||
/* =========================================================
|
||
СОХРАНЕНИЕ
|
||
========================================================= */
|
||
const SAVE_KEY='fishing-save-v1';
|
||
let save={coins:0,rod:0,lure:0,rods:[0],lures:[0],caught:{},muted:false,casts:0,earned:0,
|
||
snaps:0,ach:{},quests:null,streak:{last:null,days:0,shieldWeek:null},questsDoneTotal:0,
|
||
bossBeaten:false,bossCount:0};
|
||
function freshSave(muted){
|
||
return {coins:0,rod:0,lure:0,rods:[0],lures:[0],caught:{},muted:muted,casts:0,earned:0,
|
||
snaps:0,ach:{},quests:null,streak:{last:null,days:0,shieldWeek:null},questsDoneTotal:0,
|
||
bossBeaten:false,bossCount:0};
|
||
}
|
||
function loadSave(){
|
||
try{ const s=localStorage.getItem(SAVE_KEY); if(s) Object.assign(save,JSON.parse(s)); }catch(e){}
|
||
// миграция старых сейвов: caught[id] был числом → теперь {n, best}
|
||
for(const k in save.caught){
|
||
if(typeof save.caught[k]==='number') save.caught[k]={n:save.caught[k],best:0};
|
||
}
|
||
// миграция M1: у записи появился счётчик shiny (sn)
|
||
for(const k in save.caught){
|
||
const c=save.caught[k];
|
||
if(c&&typeof c==='object'&&typeof c.sn!=='number') c.sn=0;
|
||
}
|
||
// миграция M2: новые поля (старые сейвы обязаны читаться)
|
||
if(typeof save.snaps!=='number') save.snaps=0;
|
||
if(!save.ach||typeof save.ach!=='object') save.ach={};
|
||
if(typeof save.questsDoneTotal!=='number') save.questsDoneTotal=0;
|
||
if(!save.quests||typeof save.quests!=='object') save.quests=null;
|
||
if(!save.streak||typeof save.streak!=='object') save.streak={last:null,days:0,shieldWeek:null};
|
||
// M3: приманки (фонарик) — дефолт для старых сейвов
|
||
if(!save.lures||!Array.isArray(save.lures)) save.lures=[0];
|
||
// M3: цикл дня/ночи — без dayTime старт с утра; день идёт в реальном времени,
|
||
// пока игрока не было, сдвиг догоняет (mod 1)
|
||
if(typeof save.dayTime!=='number'||save.dayTime<0) save.dayTime=0;
|
||
setDayTime(save.dayTime+(Date.now()-(save.lastSaveMs||Date.now()))/1000/DAY_LENGTH);
|
||
save.lastSaveMs=Date.now();
|
||
// M4: босс (Осётр-босс) — новые поля, дефолты для старых сейвов
|
||
if(typeof save.bossBeaten!=='boolean') save.bossBeaten=false;
|
||
if(typeof save.bossCount!=='number') save.bossCount=0;
|
||
// M2: стрик входа и квесты дня (новый день — перегенерация)
|
||
applyStreak();
|
||
refreshQuests();
|
||
}
|
||
function persist(){
|
||
save.dayTime=dayTime; // M3: время суток храним в сейве
|
||
save.lastSaveMs=Date.now(); // M3: время последнего сейва (для догона дня)
|
||
try{ localStorage.setItem(SAVE_KEY,JSON.stringify(save)); }catch(e){}
|
||
}
|
||
|
||
/* =========================================================
|
||
M3: ЦИКЛ ДНЯ/НОЧИ
|
||
========================================================= */
|
||
const DAY_LENGTH=300; // секунд на полный день
|
||
let dayTime=0; // 0..1 — позиция в сутках
|
||
let dayPhase='morning'; // morning (0-0.25) day (0.25-0.5) evening (0.5-0.75) night (0.75-1.0)
|
||
let nightNoLureToast=false; // тост «Ночью нужен фонарик» — разово, не каждый кадр
|
||
function phaseOf(t){ return t<0.25?'morning':(t<0.5?'day':(t<0.75?'evening':'night')); }
|
||
function setDayTime(t){ t%=1; if(t<0)t+=1; dayTime=t; dayPhase=phaseOf(dayTime); }
|
||
|
||
/* =========================================================
|
||
M2: НАГРАДЫ — КВЕСТЫ ДНЯ, СТРИК ВХОДА, ДОСТИЖЕНИЯ
|
||
========================================================= */
|
||
const STREAK_REWARDS=[25,50,75,100,125,150,200]; // награды дней 1-7 стрика
|
||
|
||
function todayStr(){
|
||
const d=new Date();
|
||
return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
|
||
}
|
||
function dateShift(dateStr,days){
|
||
const d=new Date(dateStr+'T00:00:00');
|
||
d.setDate(d.getDate()+days);
|
||
return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
|
||
}
|
||
/* ISO-неделя «YYYY-Www» (детерминированно — тесты повторяют расчёт) */
|
||
function isoWeek(dateStr){
|
||
const d=new Date(dateStr+'T12:00:00');
|
||
const day=(d.getDay()+6)%7;
|
||
d.setDate(d.getDate()-day+3); // четверг этой ISO-недели
|
||
const year=d.getFullYear();
|
||
const jan4=new Date(year,0,4);
|
||
const jan4day=(jan4.getDay()+6)%7;
|
||
const week1Thu=new Date(year,0,4-jan4day+3);
|
||
const week=1+Math.round((d-week1Thu)/(7*24*3600*1000));
|
||
return year+'-W'+String(week).padStart(2,'0');
|
||
}
|
||
/* Детерминированный hash строки даты (FNV-1a) — тесты смогут повторить генерацию */
|
||
function daySeed(dateStr){
|
||
let h=2166136261>>>0;
|
||
for(let i=0;i<dateStr.length;i++){
|
||
h^=dateStr.charCodeAt(i);
|
||
h=Math.imul(h,16777619);
|
||
}
|
||
return h>>>0;
|
||
}
|
||
function seededRand(seed){
|
||
let a=seed>>>0;
|
||
return function(){
|
||
a=(a+0x6D2B79F5)>>>0;
|
||
let t=a;
|
||
t=Math.imul(t^(t>>>15),t|1);
|
||
t^=t+Math.imul(t^(t>>>7),t|61);
|
||
return ((t^(t>>>14))>>>0)/4294967296;
|
||
};
|
||
}
|
||
|
||
/* --- Квесты дня --- */
|
||
const QUEST_NAMES={carass:['карась','карася','карасей'],perch:['окунь','окуня','окуней'],bream:['лещ','леща','лещей']};
|
||
const QUEST_TPLS=[
|
||
{tpl:'catch_species', make(r){ const sp=SPECIES[[0,1,2][Math.floor(r()*3)]];
|
||
return {tpl:'catch_species', param:sp.id, goal:3+Math.floor(r()*3), reward:100+Math.floor(r()*51)}; }},
|
||
{tpl:'earn_coins', make(r){
|
||
return {tpl:'earn_coins', param:0, goal:[150,250,400][Math.floor(r()*3)], reward:80+Math.floor(r()*41)}; }},
|
||
{tpl:'catch_weight', make(r){ const n=[2,3,5][Math.floor(r()*3)];
|
||
return {tpl:'catch_weight', param:n, goal:1, reward:120+Math.floor(r()*41)}; }},
|
||
{tpl:'casts', make(r){
|
||
return {tpl:'casts', param:0, goal:[8,10,15][Math.floor(r()*3)], reward:60+Math.floor(r()*41)}; }},
|
||
{tpl:'catch_rarity', make(r){
|
||
return {tpl:'catch_rarity', param:[1,2][Math.floor(r()*2)], goal:1, reward:150}; }},
|
||
{tpl:'catch_shiny', make(r){
|
||
return {tpl:'catch_shiny', param:0, goal:1, reward:200}; }},
|
||
];
|
||
function pluralN(n,forms){
|
||
const m10=n%10,m100=n%100;
|
||
if(m10===1&&m100!==11) return forms[0];
|
||
if(m10>=2&&m10<=4&&(m100<12||m100>14)) return forms[1];
|
||
return forms[2];
|
||
}
|
||
function questText(q){
|
||
switch(q.tpl){
|
||
case 'catch_species': return 'Поймай '+q.goal+' '+pluralN(q.goal,QUEST_NAMES[q.param]);
|
||
case 'earn_coins': return 'Заработай '+q.goal+' монет за день';
|
||
case 'catch_weight': return 'Поймай рыбу от '+q.param+' кг';
|
||
case 'casts': return 'Сделай '+q.goal+' забросов за день';
|
||
case 'catch_rarity': return 'Поймай рыбу редкости '+RARITY_NAMES[q.param]+'+';
|
||
case 'catch_shiny': return 'Поймай shiny-рыбу';
|
||
}
|
||
return '?';
|
||
}
|
||
function questIcon(q){
|
||
if(q.tpl==='catch_species'){ const sp=SPECIES.find(s=>s.id===q.param); return sp?sp.emoji:'🐟'; }
|
||
return {earn_coins:'🪙', catch_weight:'⚖', casts:'🎣', catch_rarity:'⭐', catch_shiny:'✨'}[q.tpl]||'🎯';
|
||
}
|
||
/* 3 квеста дня из 6 шаблонов, без повторов, детерминированно от даты */
|
||
function genQuests(dateStr){
|
||
const r=seededRand(daySeed(dateStr));
|
||
const ids=QUEST_TPLS.map((t,i)=>i);
|
||
for(let i=ids.length-1;i>0;i--){ const j=Math.floor(r()*(i+1)); const tmp=ids[i]; ids[i]=ids[j]; ids[j]=tmp; }
|
||
return ids.slice(0,3).map(i=>{ const q=QUEST_TPLS[i].make(r); q.prog=0; q.done=false; return q; });
|
||
}
|
||
function refreshQuests(){
|
||
const today=todayStr();
|
||
if(!(save.quests&&save.quests.date===today)){
|
||
save.quests={date:today, list:genQuests(today)};
|
||
persist();
|
||
}
|
||
}
|
||
|
||
/* --- Стрик входа (с еженедельным щитом на один пропуск) --- */
|
||
function streakIdx(days){ return ((days-1)%7)+1; }
|
||
function nextStreakReward(days){ return STREAK_REWARDS[streakIdx((days||0)+1)-1]; }
|
||
function applyStreak(){
|
||
const today=todayStr();
|
||
if(save.streak.last===today) return;
|
||
const fresh=!save.streak.last; // первый визит — стрик запускается (награда дня с неё не выдаётся)
|
||
if(save.streak.last===dateShift(today,-1)){
|
||
save.streak.days=(save.streak.days||0)+1; // вчерашний визит — стрик растёт
|
||
}else if(save.streak.last===dateShift(today,-2)&&save.streak.shieldWeek!==isoWeek(today)){
|
||
save.streak.days=(save.streak.days||0)+1; // один пропуск в неделю прощает щит
|
||
save.streak.shieldWeek=isoWeek(today);
|
||
toast('Щит спас стрик!');
|
||
}else{
|
||
save.streak.days=1;
|
||
}
|
||
save.streak.last=today;
|
||
if(!fresh){
|
||
const coins=STREAK_REWARDS[streakIdx(save.streak.days)-1];
|
||
save.coins+=coins;
|
||
toast('Стрик: день '+save.streak.days+' — +'+coins+' 🪙');
|
||
}
|
||
persist();
|
||
checkAchievements(); // streak_7
|
||
}
|
||
|
||
/* --- Прогресс квестов (delta — на сколько шагнуть: 1 улов / N монет и т.п.) --- */
|
||
function questHit(tpl,condFn,delta){
|
||
if(!save.quests) return;
|
||
for(const q of save.quests.list){
|
||
if(q.done||q.tpl!==tpl) continue;
|
||
if(!condFn(q)) continue;
|
||
q.prog+=delta||1;
|
||
if(q.prog>=q.goal){
|
||
q.prog=q.goal; q.done=true;
|
||
save.coins+=q.reward; save.questsDoneTotal++;
|
||
updateHUD(); // награда может прийти на забросе (вне catchFish) — счётчик монет обновить сразу
|
||
sfx('coin');
|
||
toast('Квест выполнен: '+questText(q)+' +'+q.reward+' 🪙');
|
||
checkAchievements(); // quests_5
|
||
}
|
||
}
|
||
}
|
||
/* Новый день: стрик + перегенерация квестов (loadSave, cast, catch) */
|
||
function newDayCheck(){ applyStreak(); refreshQuests(); }
|
||
|
||
/* --- Достижения с постоянными бонусами --- */
|
||
function caughtN(s,id){ const c=s.caught&&s.caught[id]; return c?c.n:0; }
|
||
function caughtBest(s,id){ const c=s.caught&&s.caught[id]; return c?c.best:0; }
|
||
const ACHIEVEMENTS=[
|
||
{id:'first_catch', name:'Первый улов', desc:'Поймай первую рыбу', icon:'🐟', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>caughtN(s,sp.id)>0)},
|
||
{id:'casts_10', name:'Разминка', desc:'Сделай 10 забросов', icon:'🎣', bonus:{coinPct:0.01}, check:s=>s.casts>=10},
|
||
{id:'casts_100', name:'Моряк', desc:'Сделай 100 забросов', icon:'⛵', bonus:{coinPct:0.01}, check:s=>s.casts>=100},
|
||
{id:'earned_1000', name:'Первая тысяча', desc:'Заработай 1000 монет', icon:'🪙', bonus:{coinPct:0.01}, check:s=>s.earned>=1000},
|
||
{id:'earned_10000', name:'Рыбацкий капитал',desc:'Заработай 10 000 монет', icon:'💰', bonus:{coinPct:0.02}, check:s=>s.earned>=10000},
|
||
{id:'species_3', name:'Ботаник', desc:'Поймай 3 вида рыб', icon:'📖', bonus:{coinPct:0.01}, check:s=>SPECIES.filter(sp=>caughtN(s,sp.id)>0).length>=3},
|
||
{id:'species_8', name:'Энциклопедия', desc:'Поймай все 8 видов', icon:'🏆', bonus:{coinPct:0.02}, check:s=>SPECIES.every(sp=>caughtN(s,sp.id)>0)},
|
||
{id:'rare_fish', name:'Редкий гость', desc:'Поймай рыбу редкости «Редкая»+ ',icon:'⭐', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>sp.rarity>=2&&caughtN(s,sp.id)>0)},
|
||
{id:'legendary_fish',name:'Легенда', desc:'Поймай рыбу редкости «Легендарная»',icon:'👑',bonus:{coinPct:0.02}, check:s=>SPECIES.some(sp=>sp.rarity>=4&&caughtN(s,sp.id)>0)},
|
||
{id:'first_shiny', name:'Золотая жила', desc:'Поймай shiny-рыбу', icon:'✨', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>{const c=s.caught&&s.caught[sp.id]; return c&&c.sn>0;})},
|
||
{id:'weight_10', name:'Трофей', desc:'Поймай рыбу 10+ кг', icon:'⚖', bonus:{windowS:0.05}, check:s=>SPECIES.some(sp=>caughtBest(s,sp.id)>=10)},
|
||
{id:'weight_20', name:'Монстр глубин', desc:'Поймай рыбу 20+ кг', icon:'🐋', bonus:{line:5}, check:s=>SPECIES.some(sp=>caughtBest(s,sp.id)>=20)},
|
||
{id:'snaps_10', name:'Упёртый', desc:'10 обрывов шнура', icon:'💥', bonus:{coinPct:0.01}, check:s=>s.snaps>=10},
|
||
{id:'snaps_50', name:'Хроники обрывов', desc:'50 обрывов шнура', icon:'📜', bonus:{coinPct:0.01}, check:s=>s.snaps>=50},
|
||
{id:'streak_7', name:'Семёрка', desc:'Стрик 7 дней', icon:'🔥', bonus:{coinPct:0.01}, check:s=>s.streak&&s.streak.days>=7},
|
||
{id:'quests_5', name:'Дневник искателя',desc:'Выполни 5 квестов', icon:'🎯', bonus:{coinPct:0.01}, check:s=>s.questsDoneTotal>=5},
|
||
{id:'boss_first', name:'Босс-рыбак', desc:'Победи Осётра-босса', icon:'🏆', bonus:{coinPct:0.03}, check:s=>(s.bossBeaten===true)||(s.bossCount>0)},
|
||
];
|
||
/* Сумма бонусов по выданным достижениям */
|
||
function achBonus(){
|
||
const b={coinPct:0,windowS:0,line:0};
|
||
for(const a of ACHIEVEMENTS){
|
||
if(!save.ach||!save.ach[a.id]) continue;
|
||
if(a.bonus.coinPct) b.coinPct+=a.bonus.coinPct;
|
||
if(a.bonus.windowS) b.windowS+=a.bonus.windowS;
|
||
if(a.bonus.line) b.line+=a.bonus.line;
|
||
}
|
||
return b;
|
||
}
|
||
function checkAchievements(){
|
||
let changed=false;
|
||
for(const a of ACHIEVEMENTS){
|
||
if(save.ach[a.id]) continue;
|
||
if(a.check(save)){
|
||
save.ach[a.id]=true; changed=true;
|
||
sfx('coin');
|
||
toast('🏅 Достижение: '+a.name);
|
||
}
|
||
}
|
||
if(changed) persist();
|
||
}
|
||
function bonusText(b){
|
||
if(b.coinPct) return '+'+(b.coinPct*100)+'% к монетам';
|
||
if(b.windowS) return '+'+b.windowS+' с к подсечке';
|
||
if(b.line) return '+'+b.line+' к прочности';
|
||
return '';
|
||
}
|
||
|
||
/* =========================================================
|
||
ХОЛСТ И РАЗМЕРЫ
|
||
========================================================= */
|
||
const canvas=document.getElementById('game');
|
||
const ctx=canvas.getContext('2d');
|
||
let W=0,H=0,DPR=1,waterY=0,boatX=0;
|
||
let tip={x:0,y:0};
|
||
function resize(){
|
||
DPR=Math.min(window.devicePixelRatio||1,2);
|
||
W=window.innerWidth; H=window.innerHeight;
|
||
canvas.width=W*DPR; canvas.height=H*DPR;
|
||
canvas.style.width=W+'px'; canvas.style.height=H+'px';
|
||
ctx.setTransform(DPR,0,0,DPR,0,0);
|
||
waterY=Math.round(H*0.26);
|
||
boatX=Math.round(W*0.20);
|
||
initClouds(); initSparkles();
|
||
}
|
||
window.addEventListener('resize',resize);
|
||
|
||
/* =========================================================
|
||
ЗВУК
|
||
========================================================= */
|
||
let AC=null;
|
||
function ac(){
|
||
if(!AC){ try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ return null; } }
|
||
if(AC&&AC.state==='suspended'){ AC.resume(); }
|
||
return AC;
|
||
}
|
||
function beep(t0,f,dur,type,vol){
|
||
const c=ac(); if(!c)return;
|
||
const o=c.createOscillator(),g=c.createGain();
|
||
o.type=type||'sine'; o.frequency.setValueAtTime(f,t0);
|
||
g.gain.setValueAtTime(vol,t0); g.gain.exponentialRampToValueAtTime(0.001,t0+dur);
|
||
o.connect(g); g.connect(c.destination); o.start(t0); o.stop(t0+dur+0.02);
|
||
}
|
||
function noiseBurst(t0,dur,vol,freq){
|
||
const c=ac(); if(!c)return;
|
||
const n=Math.floor(c.sampleRate*dur);
|
||
const buf=c.createBuffer(1,n,c.sampleRate),d=buf.getChannelData(0);
|
||
for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
|
||
const src=c.createBufferSource(); src.buffer=buf;
|
||
const f=c.createBiquadFilter(); f.type='lowpass'; f.frequency.value=freq;
|
||
const g=c.createGain(); g.gain.value=vol;
|
||
src.connect(f); f.connect(g); g.connect(c.destination); src.start(t0);
|
||
}
|
||
function sfx(name){
|
||
if(save.muted) return;
|
||
const c=ac(); if(!c) return;
|
||
const t=c.currentTime;
|
||
switch(name){
|
||
case 'click': beep(t,500,0.06,'triangle',0.15); break;
|
||
case 'cast': noiseBurst(t,0.22,0.15,2500); break;
|
||
case 'splash': noiseBurst(t,0.3,0.4,700); break;
|
||
case 'bite': beep(t,900,0.07,'square',0.2); beep(t+0.1,1200,0.09,'square',0.2); break;
|
||
case 'hook': beep(t,600,0.05,'triangle',0.25); beep(t+0.06,900,0.08,'triangle',0.25); break;
|
||
case 'tick': beep(t,260,0.03,'triangle',0.07); break;
|
||
case 'pull': beep(t,140,0.18,'sawtooth',0.12); break;
|
||
case 'snap': noiseBurst(t,0.25,0.5,3000); beep(t,180,0.2,'sawtooth',0.2); break;
|
||
case 'escape': beep(t,500,0.12,'sine',0.2); beep(t+0.12,350,0.16,'sine',0.18); break;
|
||
case 'coin': beep(t,1200,0.08,'sine',0.2); beep(t+0.09,1600,0.12,'sine',0.2); break;
|
||
case 'catch': [523,659,784,1047].forEach((f,i)=>beep(t+i*0.11,f,0.14,'triangle',0.22)); break;
|
||
case 'no': beep(t,220,0.12,'square',0.15); break;
|
||
}
|
||
}
|
||
function vib(p){ try{ if(navigator.vibrate) navigator.vibrate(p); }catch(e){} }
|
||
|
||
/* =========================================================
|
||
СОСТОЯНИЕ ИГРЫ
|
||
========================================================= */
|
||
let state='MENU'; // MENU IDLE CHARGE CAST WAIT APPROACH BITE REEL SNAP CARD
|
||
let uiOpen=true;
|
||
let time=0;
|
||
let charge=0, power=0;
|
||
let castT=0, castFrom={x:0,y:0}, castTo={x:0,y:0};
|
||
let bobber={x:0,y:0};
|
||
let biteTimer=0, biteT=0, biteFish=null, rippleTimer=0;
|
||
let reel=null;
|
||
let snapT=0, shake=0;
|
||
let stateBoss=false, bossPhase=0; // M4: босс-бой (Осётр-босс, 3 фазы)
|
||
let ambient=[]; // фоновые рыбы
|
||
let clouds=[], sparkles=[];
|
||
let particles=[], ripples=[];
|
||
let bobberTrail=[]; // визуал: след поплавка при забросе
|
||
|
||
const rod=()=>RODS[save.rod];
|
||
const lure=()=>LURES[save.lure];
|
||
const rand=(a,b)=>a+Math.random()*(b-a);
|
||
|
||
function initFish(){
|
||
ambient=[];
|
||
for(let i=0;i<9;i++){
|
||
ambient.push({
|
||
x:Math.random()*W, depth:rand(0.12,0.92), size:rand(8,22),
|
||
speed:rand(10,32), dir:Math.random()<0.5?1:-1, hue:rand(140,260), ph:rand(0,6.28)
|
||
});
|
||
}
|
||
}
|
||
function initClouds(){
|
||
clouds=[];
|
||
for(let i=0;i<4;i++){
|
||
clouds.push({x:Math.random()*W,y:rand(14,Math.max(20,waterY-50)),s:rand(0.6,1.2),v:rand(4,9)});
|
||
}
|
||
}
|
||
function initSparkles(){
|
||
sparkles=[];
|
||
for(let i=0;i<24;i++){
|
||
sparkles.push({x:Math.random()*W,y:waterY+rand(10,H-waterY-10),ph:rand(0,6.28)});
|
||
}
|
||
initStars();
|
||
}
|
||
/* Визуал: звёзды для ночного неба (только отрисовка, на механику не влияют) */
|
||
let stars=[];
|
||
function initStars(){
|
||
stars=[];
|
||
for(let i=0;i<46;i++){
|
||
stars.push({x:Math.random()*W,y:Math.random()*Math.max(10,waterY-14),r:rand(0.6,1.7),ph:rand(0,6.28),sp:rand(0.8,2.2)});
|
||
}
|
||
}
|
||
|
||
/* =========================================================
|
||
УТИЛИТЫ
|
||
========================================================= */
|
||
const $=id=>document.getElementById(id);
|
||
let toastTO=null;
|
||
function toast(msg){
|
||
const t=$('toast'); t.textContent=msg; t.style.opacity=1;
|
||
clearTimeout(toastTO); toastTO=setTimeout(()=>t.style.opacity=0,1600);
|
||
}
|
||
function updateHUD(){ $('coins').textContent='🪙 '+save.coins; $('btnMute').textContent=save.muted?'🔇':'🔊'; }
|
||
function splash(x,y,n,spread){
|
||
for(let i=0;i<n;i++){
|
||
particles.push({
|
||
x:x,y:y, vx:rand(-1,1)*(spread||90), vy:rand(-160,-40),
|
||
g:320, life:rand(0.35,0.7), max:0.7, size:rand(1.5,3.5),
|
||
color:Math.random()<0.5?'255,255,255':'160,215,255'
|
||
});
|
||
}
|
||
}
|
||
function addRipple(x,y,max){ ripples.push({x:x,y:y,r:4,max:max||36,a:0.6}); }
|
||
|
||
/* =========================================================
|
||
ЛОГИКА
|
||
========================================================= */
|
||
function pickSpecies(dist){
|
||
const l=lure();
|
||
// M3: ночь/вечер — ночная таблица, иначе дневная (жёсткий переключатель)
|
||
const tbl=(dayPhase==='night'||dayPhase==='evening')?SPECIES_NIGHT:SPECIES;
|
||
let total=0;
|
||
const ws=tbl.map(s=>{
|
||
let w=s.weight;
|
||
if(dist<s.minDist) w*=0.06;
|
||
w*=1+l.rare*s.rarity;
|
||
total+=w; return w;
|
||
});
|
||
let r=Math.random()*total;
|
||
for(let i=0;i<tbl.length;i++){ r-=ws[i]; if(r<=0) return tbl[i]; }
|
||
return tbl[0];
|
||
}
|
||
function biteWindow(sp){
|
||
if(sp.boss) return rand(0.25,0.5); // M4: босс клюёт реже — окно подсечки короче
|
||
return Math.max(0.35, 0.8 - sp.rarity*0.08 + save.rod*0.04 + achBonus().windowS);
|
||
}
|
||
/* Спринт 4: шанс клёва на таймер — не каждый заброс даёт рыбу (дальше = чаще, ночь тише) */
|
||
function biteChance(){
|
||
const dist=Math.min(1,(castTo.x-(boatX+30))/Math.max(1,W-boatX-70));
|
||
let p=0.65+0.25*dist;
|
||
if(dayPhase==='night') p*=0.85;
|
||
return p;
|
||
}
|
||
/* Ролл веса рыбы: сдвиг в крупную сторону зависит от удочки и приманки */
|
||
function rollWeight(sp){
|
||
const B=Math.min(0.95, rod().size + lure().size);
|
||
const s=Math.pow(Math.random(), 2/(1+2*B)); // B=0 → смещено к мелкой, B↑ → ближе к равномерной
|
||
return sp.wMin + s*(sp.wMax-sp.wMin);
|
||
}
|
||
|
||
function startBite(){
|
||
const dist=Math.min(1,(castTo.x-(boatX+30))/Math.max(1,W-boatX-70));
|
||
let sp=pickSpecies(dist);
|
||
// M4: осётр → босс (30% шанс или тест-форс) — клонируем, чтобы не трогать таблицу SPECIES
|
||
if(sp.id==='sturge' && (forceBoss || Math.random()<0.3)) sp=Object.assign({},sp,{boss:true});
|
||
const fromLeft=Math.random()<0.5;
|
||
const d=Math.max(0.25,sp.minDist)*0.8+0.1;
|
||
biteFish={
|
||
x:fromLeft?-30:W+30, y:waterY+d*(H-waterY-40),
|
||
sx:fromLeft?-30:W+30, sy:waterY+d*(H-waterY-40),
|
||
dir:fromLeft?1:-1, sp:sp, t:0, mode:'in'
|
||
};
|
||
state='APPROACH';
|
||
}
|
||
function hook(){
|
||
const sp=biteFish.sp;
|
||
biteFish=null;
|
||
sfx('hook'); vib(30);
|
||
state='REEL';
|
||
stateBoss=!!sp.boss; bossPhase=sp.boss?1:0; // M4: старт босс-боя — фаза 1
|
||
const w=rollWeight(sp);
|
||
const ov=w/rod().maxW; // 1.0 = рыба ровно на пределе удочки
|
||
const str=sp.boss?0.95:sp.strength; // M4: босс сильнее (осётр 0.85 → 0.95)
|
||
const aggr=Math.min(1, 0.35*str + 0.75*Math.min(1.25,ov)/1.25); // «злость» рыбы
|
||
const d0=45+7*w; // чем крупнее, тем дальше от лодки (T2b: буфер для «сдавать в рывок»)
|
||
const line=rod().line+achBonus().line; // прочность с бонусом достижений
|
||
// Спринт 4: выносливость — крупная рыба выносливее, босс свежий
|
||
const st0=sp.boss?1:0.65+0.35*Math.min(1,w/sp.wMax);
|
||
reel={d:d0, d0:d0, tension:0, escape:0, holding:false, phase:'calm', phaseT:rand(0.8,1.5), sp:sp, w:w, ov:ov, aggr:aggr, tick:0, line:line, boss:!!sp.boss, st:st0, tired:false};
|
||
}
|
||
/* Спринт 4: сила рыбы (перетягивание с drag) — глобальная, автотесты вызывают */
|
||
function fishForce(r,ag,pull){
|
||
const base=pull?(16+34*r.st)*(0.5+0.9*ag)*(0.7+0.5*Math.min(1.5,r.ov)):5+7*ag;
|
||
return r.tired?0.45*base:base;
|
||
}
|
||
function doCast(){
|
||
const p=Math.max(0.12,power);
|
||
const dist=Math.min(0.97,(0.10+0.80*p)*lure().cast);
|
||
castFrom={x:tip.x,y:tip.y};
|
||
castTo={x:boatX+30+dist*Math.max(1,W-boatX-70), y:waterY-2};
|
||
castT=0; state='CAST'; sfx('cast');
|
||
save.casts++;
|
||
newDayCheck();
|
||
questHit('casts',()=>true); // condFn обязателен: без него questHit падает с TypeError
|
||
checkAchievements();
|
||
persist();
|
||
}
|
||
function recall(){
|
||
if(state==='WAIT'||state==='APPROACH'||state==='BITE'){
|
||
state='IDLE'; biteFish=null; sfx('click');
|
||
}
|
||
}
|
||
function updateReel(dt){
|
||
const r=rod(), sp=reel.sp, ag=reel.aggr, ov=reel.ov;
|
||
reel.phaseT-=dt;
|
||
if(reel.phaseT<=0){
|
||
if(reel.phase==='calm'){
|
||
// Спринт 4: рывок длиннее у свежей рыбы, короче у уставшей
|
||
reel.phase='pull'; reel.phaseT=(0.8+2.2*reel.st)*(0.6+0.7*ag)*rand(0.8,1.25);
|
||
sfx('pull'); vib(40);
|
||
}else{
|
||
// Спринт 4: пауза растёт по мере усталости
|
||
reel.phase='calm'; reel.phaseT=Math.max(0.35,(1.3-0.8*reel.st)*rand(0.8,1.3)); vib(15);
|
||
}
|
||
}
|
||
const pulling=reel.phase==='pull';
|
||
// Спринт 4 T2b: выносливость — медленная дуга: свежая тянет сильно, уставшая слабо (Monte-Carlo)
|
||
const drain=(pulling?0.016+0.02*ag:0.002)+(reel.holding?0.006:0.001)+(pulling&&!reel.holding?0.01:0);
|
||
reel.st=Math.max(0,reel.st-drain*dt);
|
||
if(reel.st<=0.12) reel.tired=true; // устала: рывки слабее, втягивается быстрее
|
||
const F=fishForce(reel,ag,pulling);
|
||
if(reel.holding){
|
||
// Спринт 4: перетягивание через drag — рывок сильнее тормоза: леска скользит
|
||
const over=F-r.drag;
|
||
reel.tension+=(10+Math.max(0,over)*1.1+22*ag*(pulling?1:0.35))*dt;
|
||
if(over>0) reel.d+=over*0.22*dt;
|
||
else reel.d-=(11+3*r.reel)*(1+0.6*(1-reel.st))*dt; // втягивание, усталую быстрее (до ×1.6)
|
||
reel.tick-=dt;
|
||
if(reel.tick<=0){ sfx('tick'); reel.tick=0.12; }
|
||
if(Math.random()<0.3) splash(castTo.x,waterY,1,40);
|
||
}else{
|
||
reel.tension-=38*dt;
|
||
if(pulling) reel.d+=F*0.32*dt; // свободный бег: рыба реально отходит (стоимость отпускания — дистанция)
|
||
}
|
||
reel.tension=Math.max(0,reel.tension);
|
||
// escape T2b: отпускать в РЫВОК безопасно (бар не растёт) — «сдался рыбе»;
|
||
// а вот зависнуть без удержания в затишье = рыба уходит (пассив = сход)
|
||
if(reel.holding){
|
||
reel.escape=Math.max(0,reel.escape-0.5*dt);
|
||
}else if(!pulling){
|
||
reel.escape=Math.min(1,reel.escape+(0.2+ag*0.4)*dt);
|
||
}
|
||
if(reel.escape>=1){
|
||
stateBoss=false; bossPhase=0;
|
||
const nm=Math.floor(reel.w*sp.price*0.1);
|
||
if(nm>0){
|
||
save.coins+=nm; persist(); updateHUD();
|
||
toast('Сбежала! '+sp.emoji+' '+sp.name+' '+reel.w.toFixed(1)+' кг · +'+nm+' 🪙');
|
||
}else{
|
||
toast('Сбежала! '+sp.emoji+' '+sp.name+' '+reel.w.toFixed(1)+' кг');
|
||
}
|
||
state='WAIT'; biteTimer=rand(5,10)/lure().bite;
|
||
sfx('escape');
|
||
return;
|
||
}
|
||
if(reel.tension>=reel.line){
|
||
state='SNAP'; snapT=1.0; shake=0.5;
|
||
save.snaps++; persist(); // счётчик обрывов (достижения)
|
||
sfx('snap'); vib(200);
|
||
stateBoss=false; bossPhase=0; // M4: настоящий обрыв — босс-бой окончен
|
||
toast(ov>1.15?'💥 Рыба крупнее предела удочки!':'💥 Линия лопнула!');
|
||
checkAchievements();
|
||
}else if(reel.d<=0){
|
||
if(reel.boss && bossPhase<3){
|
||
// M4: босс-обрыв фаз 1-2 — без штрафа (без save.snaps++), дальше фаза глубже
|
||
state='SNAP'; snapT=0.6; shake=0.5;
|
||
sfx('snap'); vib(120);
|
||
toast('⚠️ Босс рвёт леску! Фаза '+bossPhase+'/3');
|
||
}else{
|
||
catchFish(sp);
|
||
}
|
||
}else if(reel.d>=reel.d0*1.45){
|
||
// near-miss T2b: порог 1.45·d0 — рыба сбежала, утешение 10% цены (SNAP выплаты не даёт)
|
||
stateBoss=false; bossPhase=0; // M4: босс тоже может уйти — босс-бой окончен
|
||
const nm=Math.floor(reel.w*sp.price*0.1);
|
||
if(nm>0){
|
||
save.coins+=nm; persist(); updateHUD();
|
||
toast('Сбежала! '+sp.emoji+' '+sp.name+' '+reel.w.toFixed(1)+' кг · +'+nm+' 🪙');
|
||
}else{
|
||
toast('Сбежала! '+sp.emoji+' '+sp.name+' '+reel.w.toFixed(1)+' кг');
|
||
}
|
||
state='WAIT'; biteTimer=rand(5,10)/lure().bite;
|
||
sfx('escape');
|
||
}
|
||
}
|
||
function catchFish(sp){
|
||
const w=reel.w;
|
||
const boss=!!reel.boss; // M4: пойман Осётр-босс
|
||
const shiny=Math.random()<SHINY_CHANCE; // ролл золотого (шини) улова
|
||
const b=achBonus();
|
||
const mult=(shiny?SHINY_MULT:1)*(boss?5:1); // M4: босс = ×5 к цене
|
||
const val=Math.max(1,Math.round(w*sp.price*mult*(0.95+Math.random()*0.1)*(1+b.coinPct)));
|
||
save.coins+=val; save.earned+=val;
|
||
const prev=save.caught[sp.id]||{n:0,best:0,sn:0};
|
||
const isRecord=w>prev.best;
|
||
save.caught[sp.id]={n:prev.n+1, best:Math.max(prev.best,w), sn:(prev.sn||0)+(shiny?1:0)};
|
||
if(boss){ // M4: босс побеждён
|
||
save.bossBeaten=true; save.bossCount++;
|
||
toast('🏆 БОСС ПОБЕЖДЁН! +'+val+' 🪙');
|
||
}
|
||
newDayCheck();
|
||
questHit('catch_species',q=>sp.id===q.param);
|
||
questHit('catch_weight',q=>w>=q.param);
|
||
questHit('catch_rarity',q=>sp.rarity>=q.param);
|
||
questHit('catch_shiny',q=>shiny);
|
||
questHit('earn_coins',()=>true,val); // прогресс — монеты улова (с бонусами)
|
||
checkAchievements();
|
||
persist(); updateHUD();
|
||
sfx('catch'); vib([40,60,40]);
|
||
splash(castTo.x,waterY,boss?40:26,boss?180:140);
|
||
// шини — ярче: больше и разноцветнее золотых частиц; босс — частицы крупнее
|
||
for(let i=0;i<(boss?44:(shiny?28:10));i++){
|
||
particles.push({x:castTo.x,y:waterY,vx:rand(-80,80),vy:rand(-240,-80),g:300,
|
||
life:rand(0.5,0.9),max:0.9,size:boss?rand(3,7):rand(2,4),
|
||
color:(shiny&&i%3===0)?'255,246,190':'255,215,94'});
|
||
}
|
||
reel=null; stateBoss=false; bossPhase=0; state='CARD';
|
||
showCard(sp,w,val,isRecord,shiny,boss);
|
||
}
|
||
|
||
function updateGame(dt){
|
||
// фоновые рыбы
|
||
for(const f of ambient){
|
||
f.x+=f.dir*f.speed*dt;
|
||
if(f.dir>0&&f.x>W+40){ f.x=-40; f.depth=rand(0.12,0.92); }
|
||
if(f.dir<0&&f.x<-40){ f.x=W+40; f.depth=rand(0.12,0.92); }
|
||
}
|
||
switch(state){
|
||
case 'CHARGE':
|
||
charge+=dt;
|
||
power=1-Math.abs(1-((charge/0.9)%2));
|
||
break;
|
||
case 'CAST':{
|
||
castT+=dt/0.65;
|
||
if(castT>=1){
|
||
castT=1;
|
||
bobber={x:castTo.x,y:waterY-2};
|
||
state='WAIT';
|
||
biteTimer=rand(4,10)/lure().bite;
|
||
nightNoLureToast=false; // новый заброс — тост о фонарике может показаться снова
|
||
splash(castTo.x,waterY,14,100);
|
||
addRipple(castTo.x,waterY,40);
|
||
sfx('splash'); vib(10);
|
||
}else{
|
||
const t=castT;
|
||
bobber.x=castFrom.x+(castTo.x-castFrom.x)*t;
|
||
bobber.y=castFrom.y+(castTo.y-castFrom.y)*t-150*Math.sin(Math.PI*t);
|
||
}
|
||
break;
|
||
}
|
||
case 'WAIT':{
|
||
bobber.y=waterY-3+Math.sin(time*2)*1.5;
|
||
rippleTimer-=dt;
|
||
if(rippleTimer<=0){ addRipple(bobber.x,waterY+3,22); rippleTimer=1.6; }
|
||
if(biteFish&&biteFish.mode==='out') updateBiteFishOut(dt);
|
||
biteTimer-=dt;
|
||
if(biteTimer<=0&&!biteFish){
|
||
if(dayPhase==='night'&&!save.lures.includes(4)){
|
||
// M3: ночью без фонарика клёва нет — таймер тикает вхолостую
|
||
if(!nightNoLureToast){ toast('Ночью нужен фонарик'); nightNoLureToast=true; }
|
||
}else if(forceBite||Math.random()<biteChance()){
|
||
startBite();
|
||
}else{
|
||
// Спринт 4: пустой клёв — поплавок молчит, попробуем ещё раз
|
||
biteTimer=rand(2.5,5)/lure().bite;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case 'APPROACH':
|
||
updateBiteFishIn(dt);
|
||
if(biteFish){
|
||
if(biteFish.t>=1){
|
||
state='BITE';
|
||
biteT=biteWindow(biteFish.sp);
|
||
sfx('bite'); vib(60);
|
||
addRipple(castTo.x,waterY+3,30);
|
||
}
|
||
}
|
||
break;
|
||
case 'BITE':
|
||
biteT-=dt;
|
||
bobber.y=waterY+5+Math.sin(time*22)*2;
|
||
if(Math.random()<0.2) addRipple(castTo.x,waterY+3,26);
|
||
if(biteT<=0){
|
||
if(biteFish) biteFish.mode='out';
|
||
state='WAIT'; biteTimer=rand(5,11)/lure().bite;
|
||
sfx('escape'); toast('Ушла… жми быстрее!');
|
||
}
|
||
break;
|
||
case 'REEL':
|
||
updateReel(dt);
|
||
if(state==='REEL'&&reel){
|
||
bobber.x=castTo.x+(reel.holding?Math.sin(time*30)*2:0);
|
||
bobber.y=waterY-2+Math.sin(time*9)*2;
|
||
if(Math.random()<0.25) addRipple(castTo.x,waterY+3,24);
|
||
}
|
||
break;
|
||
case 'SNAP':
|
||
snapT-=dt;
|
||
if(snapT<=0){
|
||
if(stateBoss && bossPhase<3){
|
||
// M4: босс-обрыв — не штраф: автоматом следующая (глубже) фаза, без ввода
|
||
reel.d=(bossPhase===1?0.6:1.0)*reel.d0; // фаза2: d=0.6·d0, фаза3: d=1.0·d0
|
||
reel.tension=0; // фазовый обрыв сбрасывает нагрузку, иначе «беспошлинный» обрыв ведёт к настоящему
|
||
bossPhase++;
|
||
state='REEL';
|
||
}else{
|
||
state='IDLE';
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
syncDom();
|
||
}
|
||
function updateBiteFishIn(dt){
|
||
biteFish.t+=dt/0.45;
|
||
const t=Math.min(1,biteFish.t);
|
||
const e=t*t*(3-2*t);
|
||
biteFish.x=biteFish.sx+(castTo.x-biteFish.sx)*e;
|
||
biteFish.y=biteFish.sy+(waterY+14-biteFish.sy)*e;
|
||
biteFish.dir=castTo.x>biteFish.sx?1:-1;
|
||
}
|
||
function updateBiteFishOut(dt){
|
||
biteFish.t+=dt/0.6;
|
||
const dir=biteFish.x<W/2?-1:1;
|
||
biteFish.x+=dir*380*dt;
|
||
biteFish.y+=40*dt;
|
||
if(biteFish.t>=1||biteFish.x<-50||biteFish.x>W+50) biteFish=null;
|
||
}
|
||
function updateDecor(dt){
|
||
for(const c of clouds){ c.x+=c.v*dt; if(c.x>W+80) c.x=-80; }
|
||
for(let i=particles.length-1;i>=0;i--){
|
||
const p=particles[i];
|
||
p.life-=dt; p.vy+=p.g*dt; p.x+=p.vx*dt; p.y+=p.vy*dt;
|
||
if(p.life<=0||p.y>H) particles.splice(i,1);
|
||
}
|
||
for(let i=ripples.length-1;i>=0;i--){
|
||
const r=ripples[i];
|
||
r.r+=45*dt; r.a-=0.9*dt;
|
||
if(r.a<=0||r.r>r.max) ripples.splice(i,1);
|
||
}
|
||
if(shake>0) shake-=dt;
|
||
}
|
||
function syncDom(){
|
||
const showRecall=(state==='WAIT'||state==='APPROACH'||state==='BITE');
|
||
$('btnRecall').classList.toggle('hidden',!showRecall);
|
||
}
|
||
|
||
/* =========================================================
|
||
ОТРИСОВКА
|
||
========================================================= */
|
||
function rr(x,y,w,h,r){
|
||
ctx.beginPath();
|
||
ctx.moveTo(x+r,y);
|
||
ctx.arcTo(x+w,y,x+w,y+h,r);
|
||
ctx.arcTo(x+w,y+h,x,y+h,r);
|
||
ctx.arcTo(x,y+h,x,y,r);
|
||
ctx.arcTo(x,y,x+w,y,r);
|
||
ctx.closePath();
|
||
}
|
||
const SKY_PHASES={ // M3: градиенты неба по фазе суток
|
||
morning:['#f5a623','#fce38a'],
|
||
day:['#7ec8e3','#cfeef7'],
|
||
evening:['#e44d26','#f16529'],
|
||
night:['#0c1445','#1a237e']
|
||
};
|
||
/* Визуал: плавные переходы неба/освещения — зона перехода в КОНЦЕ фазы
|
||
(ночь темнеет к концу вечера, рассвет — в конце ночи; на стыке t=0 чистое утро) */
|
||
const SKY_SEQ=['morning','day','evening','night'];
|
||
const SKY_BOUNDS=[0.25,0.5,0.75,1.0]; // переход к следующей фазе завершается на границе
|
||
const SKY_ZONE=0.08; // ширина переходной зоны
|
||
function hexLerp(a,b,k){
|
||
const pa=parseInt(a.slice(1),16),pb=parseInt(b.slice(1),16);
|
||
const r=Math.round(((pa>>16)&255)+(((pb>>16)&255)-((pa>>16)&255))*k);
|
||
const g=Math.round(((pa>>8)&255)+(((pb>>8)&255)-((pa>>8)&255))*k);
|
||
const bl=Math.round((pa&255)+((pb&255)-(pa&255))*k);
|
||
return 'rgb('+r+','+g+','+bl+')';
|
||
}
|
||
function skyColors(){
|
||
const t=dayTime;
|
||
for(let i=0;i<4;i++){
|
||
const b=SKY_BOUNDS[i];
|
||
if(t<b||i===3){
|
||
const a=SKY_PHASES[SKY_SEQ[i]], c=SKY_PHASES[SKY_SEQ[(i+1)%4]];
|
||
const z0=b-SKY_ZONE;
|
||
if(t>=z0){ const k=Math.max(0,Math.min(1,(t-z0)/SKY_ZONE)); return [hexLerp(a[0],c[0],k),hexLerp(a[1],c[1],k)]; }
|
||
return [a[0],a[1]];
|
||
}
|
||
}
|
||
return [SKY_PHASES.morning[0],SKY_PHASES.morning[1]];
|
||
}
|
||
/* Числовые величины (ночная доля и т.п.) с теми же переходами */
|
||
function phaseLerp(vals){
|
||
const t=dayTime;
|
||
for(let i=0;i<4;i++){
|
||
const b=SKY_BOUNDS[i];
|
||
if(t<b||i===3){
|
||
const z0=b-SKY_ZONE;
|
||
if(t>=z0){ const k=Math.max(0,Math.min(1,(t-z0)/SKY_ZONE)); return vals[i]+(vals[(i+1)%4]-vals[i])*k; }
|
||
return vals[i];
|
||
}
|
||
}
|
||
return vals[0];
|
||
}
|
||
/* Ночная доля освещения 0..1 (1 = глубокая ночь) — вода, звёзды, фонарь */
|
||
function nightMix(){ return phaseLerp([0,0,0.4,1]); }
|
||
let luminary={x:0,y:0,a:0,night:false}; // визуал: где солнце/луна и её яркость (для дорожки на воде)
|
||
function drawSky(){
|
||
const cols=skyColors();
|
||
const nm=nightMix();
|
||
const g=ctx.createLinearGradient(0,0,0,waterY);
|
||
g.addColorStop(0,cols[0]); g.addColorStop(1,cols[1]);
|
||
ctx.fillStyle=g; ctx.fillRect(0,0,W,waterY);
|
||
// звёзды — проступают с ночью, мерцают
|
||
if(nm>0.15){
|
||
for(const st of stars){
|
||
const a=nm*Math.max(0,Math.sin(time*st.sp+st.ph))*0.9;
|
||
if(a>0.03){
|
||
ctx.fillStyle='rgba(255,255,255,'+a.toFixed(3)+')';
|
||
ctx.beginPath(); ctx.arc(st.x,st.y,st.r,0,7); ctx.fill();
|
||
}
|
||
}
|
||
}
|
||
// M3: солнце/луна по дуге (визуал: мягкое свечение, гаснут/всходят в сумерках без наложения)
|
||
const ang=dayTime*2*Math.PI;
|
||
const bx=W/2+Math.cos(ang)*W*0.4, by=H*0.15-Math.sin(ang)*H*0.1;
|
||
luminary.x=bx; luminary.y=by; luminary.a=0; luminary.night=false;
|
||
if(nm<0.45){
|
||
const a=1-nm/0.45; // солнце садится к ночи
|
||
luminary.a=a;
|
||
ctx.globalAlpha=Math.max(0,a);
|
||
const glow=ctx.createRadialGradient(bx,by,6,bx,by,64);
|
||
glow.addColorStop(0,'rgba(255,225,130,0.5)'); glow.addColorStop(1,'rgba(255,225,130,0)');
|
||
ctx.fillStyle=glow; ctx.fillRect(bx-64,by-64,128,128);
|
||
ctx.fillStyle='#ffd700';
|
||
ctx.beginPath(); ctx.arc(bx,by,20,0,7); ctx.fill();
|
||
ctx.fillStyle='rgba(255,255,255,0.5)';
|
||
ctx.beginPath(); ctx.arc(bx-6,by-6,7,0,7); ctx.fill();
|
||
ctx.globalAlpha=1;
|
||
}else if(nm>0.55){
|
||
const a=(nm-0.55)/0.45; // луна всходит в глубине сумерек
|
||
luminary.a=a; luminary.night=true;
|
||
ctx.globalAlpha=Math.max(0,Math.min(1,a));
|
||
const glow=ctx.createRadialGradient(bx,by,8,bx,by,56);
|
||
glow.addColorStop(0,'rgba(220,230,255,0.32)'); glow.addColorStop(1,'rgba(220,230,255,0)');
|
||
ctx.fillStyle=glow; ctx.fillRect(bx-56,by-56,112,112);
|
||
ctx.fillStyle='#f5f5dc';
|
||
ctx.beginPath(); ctx.arc(bx,by,18,0,7); ctx.fill();
|
||
// кратеры
|
||
ctx.fillStyle='rgba(120,130,150,0.35)';
|
||
ctx.beginPath(); ctx.arc(bx+6,by+3,3.2,0,7); ctx.fill();
|
||
ctx.beginPath(); ctx.arc(bx-4,by+7,2.2,0,7); ctx.fill();
|
||
ctx.beginPath(); ctx.arc(bx-2,by-6,2.6,0,7); ctx.fill();
|
||
// серп — вырез цветом неба в точке луны
|
||
ctx.fillStyle=cols[0];
|
||
ctx.beginPath(); ctx.arc(bx+9,by-6,13.5,0,7); ctx.fill();
|
||
ctx.globalAlpha=1;
|
||
}
|
||
// облака (ночью приглушены)
|
||
ctx.fillStyle='rgba(255,255,255,'+(0.9-0.68*nm).toFixed(3)+')';
|
||
for(const c of clouds){
|
||
ctx.save(); ctx.translate(c.x,c.y); ctx.scale(c.s,c.s);
|
||
ctx.beginPath();
|
||
ctx.arc(0,0,14,0,7); ctx.arc(16,-6,12,0,7); ctx.arc(32,0,15,0,7); ctx.arc(16,6,13,0,7);
|
||
ctx.fill(); ctx.restore();
|
||
}
|
||
}
|
||
function drawWater(){
|
||
const nm=nightMix();
|
||
// градиент воды: день ↔ ночь, плавно (визуал)
|
||
const top=hexLerp('#2f9bd8','#0d2246',nm), bot=hexLerp('#0a2f52','#041226',nm);
|
||
const g=ctx.createLinearGradient(0,waterY,0,H);
|
||
g.addColorStop(0,top); g.addColorStop(0.55,hexLerp('#1f6fb0','#091c3a',nm)); g.addColorStop(1,bot);
|
||
ctx.fillStyle=g; ctx.fillRect(0,waterY,W,H-waterY);
|
||
// дорожка света от солнца/луны — мерцающие блики под светилом
|
||
if(luminary.a>0.05){
|
||
const depth=Math.min(1,Math.max(0,(luminary.y)/(waterY||1))); // светило низко — дорожка длиннее
|
||
const len=(H-waterY)*(0.18+0.3*depth);
|
||
const base=(luminary.night?0.12:0.2)*luminary.a;
|
||
ctx.fillStyle=luminary.night?'rgba(200,220,255,1)':'rgba(255,235,170,1)';
|
||
for(let y=waterY+6;y<waterY+len;y+=7){
|
||
const k=(y-waterY)/len;
|
||
const wob=Math.sin(time*2.2+y*0.55);
|
||
const a=Math.max(0,base*(1-k)*(0.45+0.55*wob));
|
||
if(a<0.02) continue;
|
||
ctx.globalAlpha=a;
|
||
const wdt=14+k*30+Math.sin(time*1.7+y)*4;
|
||
ctx.fillRect(luminary.x-wdt/2,y,wdt,2);
|
||
}
|
||
ctx.globalAlpha=1;
|
||
}
|
||
// блики
|
||
for(const s of sparkles){
|
||
const a=Math.max(0,Math.sin(time*1.5+s.ph))*0.25*(1-nm*0.5);
|
||
if(a>0.02){ ctx.fillStyle='rgba(255,255,255,'+a.toFixed(3)+')'; ctx.fillRect(s.x,s.y,3,1.5); }
|
||
}
|
||
// волна на поверхности + вторая, глубинная (визуал)
|
||
ctx.strokeStyle='rgba(255,255,255,'+(0.55-nm*0.25).toFixed(3)+')'; ctx.lineWidth=2;
|
||
ctx.beginPath();
|
||
for(let x=0;x<=W;x+=8){
|
||
const y=waterY+Math.sin(x*0.05+time*2)*2.5;
|
||
if(x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||
}
|
||
ctx.stroke();
|
||
ctx.strokeStyle='rgba(255,255,255,'+(0.2-nm*0.1).toFixed(3)+')'; ctx.lineWidth=1.5;
|
||
ctx.beginPath();
|
||
for(let x=0;x<=W;x+=10){
|
||
const y=waterY+7+Math.sin(x*0.04-time*1.4+1.7)*3;
|
||
if(x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
function drawFishShape(x,y,size,dir,hue,alpha,wob){
|
||
ctx.save(); ctx.translate(x,y); ctx.scale(dir,1);
|
||
const w=wob||0;
|
||
ctx.fillStyle='hsla('+hue+',55%,38%,'+alpha+')';
|
||
ctx.beginPath(); ctx.ellipse(0,0,size,size*0.45,0,0,7); ctx.fill();
|
||
// хвост качается (визуал)
|
||
ctx.save(); ctx.translate(-size*0.8,0); ctx.rotate(w);
|
||
ctx.beginPath();
|
||
ctx.moveTo(0,0); ctx.lineTo(-size*0.65,-size*0.42); ctx.lineTo(-size*0.65,size*0.42);
|
||
ctx.closePath(); ctx.fill();
|
||
ctx.restore();
|
||
// блик на спинке + глаз (визуал: живость, у силуэтов едва видны)
|
||
ctx.fillStyle='rgba(255,255,255,'+(alpha*0.22).toFixed(3)+')';
|
||
ctx.beginPath(); ctx.ellipse(-size*0.1,-size*0.16,size*0.55,size*0.18,-0.25,0,7); ctx.fill();
|
||
ctx.fillStyle='rgba(10,25,40,'+(alpha*0.8).toFixed(3)+')';
|
||
ctx.beginPath(); ctx.arc(size*0.6,-size*0.07,size*0.09,0,7); ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
function drawAmbientFish(){
|
||
for(const f of ambient){
|
||
const y=waterY+20+f.depth*(H-waterY-50)+Math.sin(time*2+f.ph)*2;
|
||
drawFishShape(f.x,y,f.size,f.dir,f.hue,0.22+0.2*(1-f.depth),Math.sin(time*5+f.ph)*0.28);
|
||
}
|
||
}
|
||
function drawBoat(){
|
||
const bx=boatX, by=waterY+Math.sin(time*1.5)*1.5;
|
||
// блик-отражение под лодкой (визуал)
|
||
ctx.fillStyle='rgba(0,20,40,0.28)';
|
||
ctx.beginPath(); ctx.ellipse(bx,by+17,34,4.5,0,0,7); ctx.fill();
|
||
// лодка
|
||
const bg=ctx.createLinearGradient(bx-32,by,bx+32,by+15);
|
||
bg.addColorStop(0,'#c9773c'); bg.addColorStop(1,'#96521f');
|
||
ctx.fillStyle=bg;
|
||
ctx.beginPath();
|
||
ctx.moveTo(bx-32,by); ctx.lineTo(bx+32,by); ctx.lineTo(bx+21,by+15); ctx.lineTo(bx-21,by+15);
|
||
ctx.closePath(); ctx.fill();
|
||
ctx.fillStyle='#8a4519'; ctx.fillRect(bx-32,by,64,4);
|
||
// рыбак
|
||
ctx.fillStyle='#34495e';
|
||
ctx.fillRect(bx-9,by-18,12,12);
|
||
ctx.fillStyle='#e8b88a';
|
||
ctx.beginPath(); ctx.arc(bx-3,by-24,6.5,0,7); ctx.fill();
|
||
ctx.fillStyle='#c0392b';
|
||
ctx.beginPath(); ctx.arc(bx-3,by-27,6.5,Math.PI,0); ctx.fill();
|
||
// удочка
|
||
const swing=state==='CHARGE'?-power*0.35:0;
|
||
ctx.strokeStyle='#5d4023'; ctx.lineWidth=3; ctx.lineCap='round';
|
||
ctx.beginPath(); ctx.moveTo(bx+2,by-14);
|
||
const tx=bx+34+swing*20, ty=by-38-swing*14;
|
||
ctx.lineTo(tx,ty); ctx.stroke();
|
||
tip={x:tx,y:ty};
|
||
// флажок
|
||
ctx.strokeStyle='#5d4023'; ctx.lineWidth=2;
|
||
ctx.beginPath(); ctx.moveTo(bx-24,by); ctx.lineTo(bx-24,by-22); ctx.stroke();
|
||
ctx.fillStyle='#f5a623';
|
||
const fl=Math.sin(time*4)*1.5; // флажок колышется (визуал)
|
||
ctx.beginPath(); ctx.moveTo(bx-24,by-22); ctx.lineTo(bx-12,by-18+fl); ctx.lineTo(bx-24,by-14); ctx.closePath(); ctx.fill();
|
||
// фонарь на носу ночью (визуал: тёплое пятно света)
|
||
if(nightMix()>0.35){
|
||
const a=(nightMix()-0.35)/0.65;
|
||
const lx=bx-24,ly=by-20;
|
||
const lg=ctx.createRadialGradient(lx,ly,2,lx,ly,46);
|
||
lg.addColorStop(0,'rgba(255,214,120,'+(0.4*a).toFixed(3)+')');
|
||
lg.addColorStop(1,'rgba(255,214,120,0)');
|
||
ctx.fillStyle=lg; ctx.fillRect(lx-46,ly-46,92,92);
|
||
ctx.fillStyle='rgba(255,224,150,'+(0.9*a).toFixed(3)+')';
|
||
ctx.beginPath(); ctx.arc(lx,ly,2.5,0,7); ctx.fill();
|
||
}
|
||
}
|
||
function drawLineTo(x,y,sag){
|
||
ctx.strokeStyle='rgba(230,240,255,0.8)'; ctx.lineWidth=1.2;
|
||
ctx.beginPath(); ctx.moveTo(tip.x,tip.y);
|
||
ctx.quadraticCurveTo((tip.x+x)/2,(tip.y+y)/2+sag,x,y);
|
||
ctx.stroke();
|
||
}
|
||
function drawBobber(){
|
||
const x=bobber.x,y=bobber.y;
|
||
// тёмная кромка воды под поплавком (визуал)
|
||
ctx.fillStyle='rgba(0,20,40,0.25)';
|
||
ctx.beginPath(); ctx.ellipse(x,y+6,7,1.8,0,0,7); ctx.fill();
|
||
ctx.fillStyle='#fff';
|
||
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.fill();
|
||
ctx.fillStyle='#e74c3c';
|
||
ctx.beginPath(); ctx.arc(x,y,5.5,Math.PI,0); ctx.fill();
|
||
// блик (визуал)
|
||
ctx.fillStyle='rgba(255,255,255,0.85)';
|
||
ctx.beginPath(); ctx.arc(x-1.8,y-1.8,1.4,0,7); ctx.fill();
|
||
ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=1.5;
|
||
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.stroke();
|
||
}
|
||
function drawLineAndBobber(){
|
||
// след поплавка: копим в забросе, чистим в остальных состояниях (визуал)
|
||
if(state==='CAST'){ bobberTrail.push({x:bobber.x,y:bobber.y}); if(bobberTrail.length>14) bobberTrail.shift(); }
|
||
else if(bobberTrail.length) bobberTrail.length=0;
|
||
if(state==='IDLE'||state==='CHARGE'||state==='MENU'||state==='CARD'){
|
||
// болтается грузило
|
||
const a=state==='CHARGE'?Math.sin(time*10)*power*4:Math.sin(time*1.2)*1.5;
|
||
ctx.strokeStyle='rgba(230,240,255,0.8)'; ctx.lineWidth=1.2;
|
||
ctx.beginPath(); ctx.moveTo(tip.x,tip.y); ctx.lineTo(tip.x+a,tip.y+22); ctx.stroke();
|
||
ctx.fillStyle='#e74c3c';
|
||
ctx.beginPath(); ctx.arc(tip.x+a,tip.y+25,3.5,0,7); ctx.fill();
|
||
return;
|
||
}
|
||
if(state==='SNAP'){
|
||
ctx.strokeStyle='rgba(230,240,255,0.8)'; ctx.lineWidth=1.2;
|
||
ctx.beginPath(); ctx.moveTo(tip.x,tip.y);
|
||
ctx.lineTo(tip.x+10+Math.sin(time*20)*6,tip.y+26); ctx.stroke();
|
||
ctx.globalAlpha=Math.min(1,snapT*2);
|
||
ctx.shadowColor='rgba(255,120,60,0.9)'; ctx.shadowBlur=14; // вспышка (визуал)
|
||
ctx.font='34px system-ui'; ctx.textAlign='center';
|
||
ctx.fillText('💥',castTo.x,waterY-20);
|
||
ctx.shadowBlur=0;
|
||
ctx.globalAlpha=1;
|
||
return;
|
||
}
|
||
if(state==='CAST'){
|
||
// след: затухающие точки по траектории (визуал)
|
||
for(let i=0;i<bobberTrail.length;i++){
|
||
const tp=bobberTrail[i];
|
||
ctx.fillStyle='rgba(255,255,255,'+(i/bobberTrail.length*0.35).toFixed(3)+')';
|
||
ctx.beginPath(); ctx.arc(tp.x,tp.y,1.6,0,7); ctx.fill();
|
||
}
|
||
drawLineTo(bobber.x,bobber.y,14); drawBobber(); return;
|
||
}
|
||
const sag=state==='REEL'?(reel.phase==='pull'?4:12):18+Math.sin(time*2)*3;
|
||
drawLineTo(bobber.x,bobber.y,sag);
|
||
drawBobber();
|
||
if(state==='BITE'){
|
||
const yy=bobber.y-24+Math.sin(time*12)*2;
|
||
ctx.font='800 28px system-ui'; ctx.textAlign='center';
|
||
ctx.lineWidth=4; ctx.strokeStyle='#fff';
|
||
ctx.strokeText('!',bobber.x,yy);
|
||
ctx.fillStyle='#ff3b30';
|
||
ctx.fillText('!',bobber.x,yy);
|
||
}
|
||
}
|
||
function drawHookedFish(){
|
||
if(state!=='REEL'||!reel) return;
|
||
const sp=reel.sp;
|
||
const k=reel.tired?0.35:1; // Спринт 4: уставшая рыба вялая — медленнее и амплитуда ниже
|
||
const x=castTo.x+Math.sin(time*7*k)*4*k;
|
||
const y=waterY+26+Math.sin(time*5*k)*3*k;
|
||
const dir=Math.sin(time*2.5*k)>0?1:-1;
|
||
const fs=8+(reel.w/sp.wMax)*10+sp.rarity;
|
||
if(reel.boss){ // босс: золотая аура (визуал)
|
||
const gg=ctx.createRadialGradient(x,y,4,x,y,fs*2.6);
|
||
gg.addColorStop(0,'rgba(255,215,94,0.35)'); gg.addColorStop(1,'rgba(255,215,94,0)');
|
||
ctx.fillStyle=gg; ctx.fillRect(x-fs*2.6,y-fs*2.6,fs*5.2,fs*5.2);
|
||
}
|
||
drawFishShape(x,y,reel.boss?fs*1.5:fs,dir,sp.hue,0.75,Math.sin(time*9*k)*0.3*k); // силуэт крупнее у тяжёлых, босс ×1.5
|
||
}
|
||
function drawParticles(){
|
||
for(const p of particles){
|
||
ctx.fillStyle='rgba('+p.color+','+(Math.max(0,p.life/p.max)).toFixed(3)+')';
|
||
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,7); ctx.fill();
|
||
}
|
||
}
|
||
function drawRipples(){
|
||
for(const r of ripples){
|
||
ctx.strokeStyle='rgba(255,255,255,'+Math.max(0,r.a).toFixed(3)+')';
|
||
ctx.lineWidth=1.5;
|
||
ctx.beginPath(); ctx.ellipse(r.x,r.y,r.r,r.r*0.3,0,0,7); ctx.stroke();
|
||
}
|
||
}
|
||
function drawMeters(){
|
||
if(state!=='REEL'||!reel) return;
|
||
const r=rod(), sp=reel.sp;
|
||
const pulling=reel.phase==='pull';
|
||
// M4: босс-бой — золотая полоса фазы над метрами (визуал: пульс + точки фаз)
|
||
if(reel.boss){
|
||
const pulse=1+Math.sin(time*5)*0.08;
|
||
ctx.textAlign='center';
|
||
ctx.font='800 '+(16*pulse).toFixed(1)+'px system-ui';
|
||
ctx.fillStyle='#ffd75e';
|
||
ctx.shadowColor='rgba(255,180,40,0.8)'; ctx.shadowBlur=10;
|
||
ctx.fillText('⚠️ БОСС-РЫБА · ФАЗА '+bossPhase+'/3',W/2,84);
|
||
ctx.shadowBlur=0;
|
||
for(let i=1;i<=3;i++){
|
||
ctx.beginPath();
|
||
ctx.arc(W/2-20+i*20,94,4,0,7);
|
||
ctx.fillStyle=i<=bossPhase?'#ffd75e':'rgba(255,255,255,0.25)';
|
||
ctx.fill();
|
||
}
|
||
}
|
||
// --- нагрузка (вертикально справа) ---
|
||
const bw=14, bh=H*0.36, bx=W-30, by=H*0.46-bh/2;
|
||
ctx.fillStyle='rgba(0,0,0,0.35)'; rr(bx,by,bw,bh,7); ctx.fill();
|
||
const ratio=Math.min(1,reel.tension/reel.line);
|
||
const col=ratio<0.5?'#2ecc71':ratio<0.8?'#f1c40f':'#e74c3c';
|
||
if(ratio>0.01){
|
||
ctx.fillStyle=col;
|
||
if(ratio>0.8){ ctx.shadowColor='rgba(231,76,60,0.85)'; ctx.shadowBlur=12; } // перегруз: тревожное свечение
|
||
const fh=bh*ratio;
|
||
rr(bx+2,by+bh-2-fh,bw-4,Math.max(2,fh-2),5); ctx.fill();
|
||
ctx.shadowBlur=0;
|
||
}
|
||
ctx.strokeStyle='rgba(255,255,255,0.6)'; ctx.lineWidth=1.5;
|
||
ctx.beginPath(); ctx.moveTo(bx-3,by); ctx.lineTo(bx+bw+3,by); ctx.stroke();
|
||
// Спринт 4: черта drag — выше неё при удержании леска скользит
|
||
const dgy=by+bh*(1-Math.min(1,r.drag/reel.line));
|
||
ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=2;
|
||
ctx.beginPath(); ctx.moveTo(bx-2,dgy); ctx.lineTo(bx+bw+2,dgy); ctx.stroke();
|
||
const Fm=fishForce(reel,reel.aggr,pulling);
|
||
const slipping=reel.holding&&Fm-r.drag>0;
|
||
ctx.font='16px system-ui'; ctx.textAlign='center';
|
||
ctx.fillText(slipping?'🌀':pulling?'⚡':(reel.holding?'💪':'·'),bx+bw/2,by-8);
|
||
// --- дистанция (снизу) ---
|
||
const dw=W-96, dx=22, dy=H-52, dh=13;
|
||
ctx.fillStyle='rgba(0,0,0,0.35)'; rr(dx,dy,dw,dh,7); ctx.fill();
|
||
// escape: красная заливка справа налево (рыба ускользает)
|
||
if(reel.escape>0.01){
|
||
const ew=(dw-4)*reel.escape;
|
||
ctx.fillStyle='rgba(231,76,60,0.7)';
|
||
rr(dx+dw-2-ew,dy+2,ew,dh-4,5); ctx.fill();
|
||
}
|
||
// rem = доля оставшейся дистанции: полоска «сгорает» к лодке, рыбка на её конце
|
||
const rem=Math.max(0,Math.min(1,reel.d/reel.d0));
|
||
if(rem>0.01){
|
||
const dg=ctx.createLinearGradient(dx,0,dx+dw,0);
|
||
dg.addColorStop(0,'#2e86c8'); dg.addColorStop(1,'#3fa7e8');
|
||
ctx.fillStyle=dg;
|
||
rr(dx+2,dy+2,Math.max(2,(dw-4)*rem),dh-4,5); ctx.fill();
|
||
}
|
||
// рыбка-маркер на конце полоски
|
||
const fx=dx+2+(dw-4)*rem;
|
||
ctx.font='20px system-ui'; ctx.textAlign='center';
|
||
ctx.fillText('🐟',fx,dy-6+Math.sin(time*10)*1.5);
|
||
ctx.font='18px system-ui';
|
||
ctx.fillText('⛵',dx+4,dy+dh+18);
|
||
// Спринт 4: выносливость — тонкая полоска под дистанцией (затухает к нулю)
|
||
// T2b: начинается после эмодзи ⛵ (dx+18), чтобы не перекрывать лодку
|
||
const sdh=5, sdy=dy+dh+3;
|
||
ctx.fillStyle='rgba(0,0,0,0.3)'; rr(dx+18,sdy,dw-18,sdh,2.5); ctx.fill();
|
||
if(reel.st>0.01){
|
||
ctx.fillStyle=reel.tired?'#7d5ba6':'#b07cc6';
|
||
rr(dx+19.5,sdy+1.5,Math.max(1.5,(dw-21)*reel.st),sdh-3,2); ctx.fill();
|
||
}
|
||
if(reel.tired){
|
||
ctx.font='13px system-ui'; ctx.textAlign='left';
|
||
ctx.fillText('💤',dx+dw+6,sdy+sdh-1);
|
||
}
|
||
// статус
|
||
ctx.font='700 15px system-ui'; ctx.textAlign='center';
|
||
if(pulling){
|
||
ctx.fillStyle='#ff6b5e';
|
||
ctx.shadowColor='rgba(255,60,40,'+(0.5+0.4*Math.sin(time*10)).toFixed(3)+')'; ctx.shadowBlur=8;
|
||
ctx.fillText('⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡',W/2,dy-30);
|
||
ctx.shadowBlur=0;
|
||
}else if(reel.tired){
|
||
ctx.fillStyle='#c9a0f5';
|
||
ctx.shadowColor='rgba(120,60,180,0.6)'; ctx.shadowBlur=6;
|
||
ctx.fillText('💤 Рыба устала — втягивай!',W/2,dy-30);
|
||
ctx.shadowBlur=0;
|
||
}else{
|
||
ctx.fillStyle='#7ef0a8';
|
||
ctx.shadowColor='rgba(0,0,0,0.6)'; ctx.shadowBlur=4;
|
||
ctx.fillText('Держи палец — втягивай!',W/2,dy-30);
|
||
ctx.shadowBlur=0;
|
||
}
|
||
}
|
||
function drawPowerBar(){
|
||
if(state!=='CHARGE') return;
|
||
const w=W*0.7,h=16,x=(W-w)/2,y=H-64;
|
||
ctx.fillStyle='rgba(0,0,0,0.4)'; rr(x,y,w,h,8); ctx.fill();
|
||
const g=ctx.createLinearGradient(x,0,x+w,0);
|
||
g.addColorStop(0,'#2ecc71'); g.addColorStop(0.6,'#f1c40f'); g.addColorStop(1,'#e74c3c');
|
||
ctx.fillStyle=g;
|
||
if(power>0.01){
|
||
const fw=Math.max(2,(w-4)*power);
|
||
rr(x+2,y+2,fw,h-4,6); ctx.fill();
|
||
// бегунок на конце заливки (визуал)
|
||
ctx.fillStyle='rgba(255,255,255,0.9)';
|
||
ctx.beginPath(); ctx.arc(x+2+fw-3,y+h/2,4,0,7); ctx.fill();
|
||
}
|
||
ctx.font='700 14px system-ui'; ctx.textAlign='center';
|
||
ctx.fillStyle='#fff';
|
||
ctx.shadowColor='rgba(0,0,0,0.65)'; ctx.shadowBlur=4;
|
||
ctx.fillText('Отпусти — бросок!',W/2,y-10);
|
||
ctx.shadowBlur=0;
|
||
}
|
||
function drawHint(){
|
||
if(state!=='IDLE'||save.casts>=5) return;
|
||
const a=0.55+Math.sin(time*3)*0.25;
|
||
ctx.globalAlpha=a;
|
||
ctx.font='700 17px system-ui'; ctx.textAlign='center';
|
||
ctx.fillStyle='#fff';
|
||
ctx.shadowColor='rgba(0,0,0,0.65)'; ctx.shadowBlur=5;
|
||
ctx.fillText('👆 Держи палец — заряжай бросок',W/2,H-70);
|
||
ctx.shadowBlur=0;
|
||
ctx.globalAlpha=1;
|
||
}
|
||
function draw(){
|
||
ctx.clearRect(0,0,W,H);
|
||
ctx.save();
|
||
if(shake>0){
|
||
ctx.translate(rand(-1,1)*shake*8,rand(-1,1)*shake*8);
|
||
}
|
||
drawSky();
|
||
drawWater();
|
||
drawAmbientFish();
|
||
if(biteFish&&biteFish.mode!=='out') drawFishShape(biteFish.x,biteFish.y,12+biteFish.sp.rarity*2.5,biteFish.dir,biteFish.sp.hue,0.8,Math.sin(time*8)*0.25);
|
||
else if(biteFish&&biteFish.mode==='out') drawFishShape(biteFish.x,biteFish.y,12+biteFish.sp.rarity*2.5,biteFish.dir,biteFish.sp.hue,0.5,Math.sin(time*8)*0.25);
|
||
drawBoat();
|
||
drawLineAndBobber();
|
||
drawHookedFish();
|
||
drawRipples();
|
||
drawParticles();
|
||
drawMeters();
|
||
drawPowerBar();
|
||
drawHint();
|
||
ctx.restore();
|
||
}
|
||
|
||
/* =========================================================
|
||
ЦИКЛ
|
||
========================================================= */
|
||
let last=performance.now();
|
||
function frame(now){
|
||
const dt=Math.min(0.05,(now-last)/1000); last=now;
|
||
time+=dt;
|
||
setDayTime(dayTime+dt/DAY_LENGTH); // M3: день/ночь идут в реальном времени
|
||
updateDecor(dt);
|
||
if(!uiOpen) updateGame(dt);
|
||
draw();
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
/* =========================================================
|
||
ВВОД
|
||
========================================================= */
|
||
function onDown(){
|
||
ac();
|
||
if(uiOpen) return;
|
||
if(state==='IDLE'){ state='CHARGE'; charge=0; power=0; }
|
||
else if(state==='APPROACH'||state==='BITE'){ hook(); }
|
||
else if(state==='REEL'&&reel){ reel.holding=true; }
|
||
}
|
||
function onUp(){
|
||
if(state==='CHARGE') doCast();
|
||
else if(state==='REEL'&&reel) reel.holding=false;
|
||
}
|
||
canvas.addEventListener('pointerdown',e=>{ e.preventDefault(); onDown(); });
|
||
window.addEventListener('pointerup',onUp);
|
||
window.addEventListener('pointercancel',onUp);
|
||
document.addEventListener('contextmenu',e=>e.preventDefault());
|
||
|
||
/* =========================================================
|
||
UI: МАГАЗИН / ДНЕВНИК / КАРТОЧКА
|
||
========================================================= */
|
||
let shopTab='tackle';
|
||
function openShop(){ uiOpen=true; $('shop').classList.remove('hidden'); renderShop(); sfx('click'); }
|
||
function closeShop(){ uiOpen=false; $('shop').classList.add('hidden'); sfx('click'); }
|
||
function setTab(t){ shopTab=t; renderShop();
|
||
$('tabTackle').classList.toggle('active',t==='tackle');
|
||
$('tabJournal').classList.toggle('active',t==='journal');
|
||
$('tabRewards').classList.toggle('active',t==='rewards');
|
||
$('tabAch').classList.toggle('active',t==='ach');
|
||
}
|
||
function itemRow(item,i,type,owned,cur){
|
||
let btn;
|
||
if(cur) btn='<button class="buybtn current" disabled>✓ Выбрано</button>';
|
||
else if(owned) btn='<button class="buybtn owned" data-act="use" data-type="'+type+'" data-i="'+i+'">Выбрать</button>';
|
||
else btn='<button class="buybtn" data-act="buy" data-type="'+type+'" data-i="'+i+'">'+item.cost+' 🪙</button>';
|
||
const tag=item.nightOnly?' <span style="color:#8fb8dd;font-size:12px">ночь</span>':''; // M3: пометка «ночь»
|
||
return '<div class="item"><div><div class="itemname">'+item.name+tag+'</div>'+
|
||
'<div class="itemdesc">'+item.desc+'</div></div>'+btn+'</div>';
|
||
}
|
||
function renderShop(){
|
||
const body=$('shopBody');
|
||
if(shopTab==='tackle'){
|
||
let h='<div class="section-title">Удилища</div>';
|
||
RODS.forEach((r,i)=>{ h+=itemRow(r,i,'rod',save.rods.indexOf(i)>=0,save.rod===i); });
|
||
h+='<div class="section-title">Приманки</div>';
|
||
LURES.forEach((l,i)=>{ h+=itemRow(l,i,'lure',save.lures.indexOf(i)>=0,save.lure===i); });
|
||
body.innerHTML=h;
|
||
}else if(shopTab==='journal'){
|
||
let h='<div class="statsline">Бросков: '+save.casts+' · Заработано: '+save.earned+' 🪙</div>';
|
||
for(const s of SPECIES){
|
||
const c=save.caught[s.id]||{n:0,best:0,sn:0};
|
||
if(c.n>0){
|
||
const sn=c.sn||0;
|
||
h+='<div class="jrow"><span class="je">'+s.emoji+'</span><span class="jn"><b>'+s.name+'</b>'+(sn>0?' <span class="jsn">✨'+sn+'</span>':'')+'<br>'+
|
||
'<span style="font-size:11px;color:'+RARITY_COLORS[s.rarity]+'">'+RARITY_NAMES[s.rarity]+' · '+s.price+' 🪙/кг</span></span>'+
|
||
'<span class="jc">×'+c.n+'<br>макс '+c.best.toFixed(1)+' кг</span></div>';
|
||
}else{
|
||
h+='<div class="jrow unknown"><span class="je">❓</span><span class="jn">???</span>'+
|
||
'<span class="jc">не пойман</span></div>';
|
||
}
|
||
}
|
||
body.innerHTML=h;
|
||
}else if(shopTab==='rewards'){
|
||
// 🎯 Награды: стрик входа + квесты дня
|
||
const st=save.streak;
|
||
const idx=st.days>0?streakIdx(st.days):0;
|
||
let h='<div class="section-title">Стрик входа</div><div class="streak">';
|
||
for(let i=1;i<=7;i++){
|
||
h+='<span class="st-dot'+(st.days>0&&i<=idx?' on':'')+(st.days>0&&i===idx?' cur':'')+'">'+STREAK_REWARDS[i-1]+'</span>';
|
||
}
|
||
h+='</div>';
|
||
h+='<div class="statsline">День '+(st.days||0)+' · далее +'+nextStreakReward(st.days||1)+' 🪙</div>';
|
||
h+='<div class="section-title">Квесты дня</div>';
|
||
const ql=(save.quests&&save.quests.list)||[];
|
||
if(ql.length){
|
||
for(const q of ql){
|
||
h+='<div class="jrow'+(q.done?' qdone':'')+'"><span class="je">'+questIcon(q)+'</span>'+
|
||
'<span class="jn"><b>'+questText(q)+'</b></span>'+
|
||
'<span class="jc">'+(q.done?'✓ +'+q.reward+' 🪙':q.prog+'/'+q.goal)+'</span></div>';
|
||
}
|
||
}else{
|
||
h+='<div class="statsline">Квесты на сегодня закончились — заходи завтра!</div>';
|
||
}
|
||
body.innerHTML=h;
|
||
}else if(shopTab==='ach'){
|
||
// 🏅 Достижения: выданные — яркие, остальные приглушённые
|
||
let h='<div class="section-title">Достижения</div>';
|
||
for(const a of ACHIEVEMENTS){
|
||
const got=!(!(save.ach&&save.ach[a.id]));
|
||
h+='<div class="jrow'+(got?' got':' locked')+'"><span class="je">'+a.icon+'</span>'+
|
||
'<span class="jn"><b>'+a.name+'</b><br><span class="adesc">'+a.desc+' · '+bonusText(a.bonus)+'</span></span>'+
|
||
(got?'<span class="jc">✓</span>':'')+'</div>';
|
||
}
|
||
body.innerHTML=h;
|
||
}
|
||
}
|
||
function shopClick(e){
|
||
const b=e.target.closest('[data-act]');
|
||
if(!b) return;
|
||
const type=b.dataset.type, i=+b.dataset.i;
|
||
const list=type==='rod'?RODS:LURES;
|
||
const owned=type==='rod'?save.rods:save.lures;
|
||
const item=list[i];
|
||
if(b.dataset.act==='buy'){
|
||
if(save.coins<item.cost){ sfx('no'); toast('Не хватает монет 🪙'); return; }
|
||
save.coins-=item.cost; owned.push(i);
|
||
if(type==='rod') save.rod=i; else save.lure=i;
|
||
persist(); updateHUD(); sfx('coin'); renderShop();
|
||
toast('Куплено: '+item.name+'!');
|
||
checkAchievements();
|
||
}else{
|
||
if(type==='rod') save.rod=i; else save.lure=i;
|
||
persist(); updateHUD(); sfx('click'); renderShop();
|
||
}
|
||
}
|
||
function showCard(sp,w,val,rec,shiny,boss){
|
||
$('cardRarity').textContent=RARITY_NAMES[sp.rarity];
|
||
$('cardRarity').style.color=RARITY_COLORS[sp.rarity];
|
||
$('cardEmoji').textContent=sp.emoji;
|
||
$('cardName').textContent=sp.name+(boss?'-БОСС':''); // M4: имя босса
|
||
$('cardSub').textContent=w.toFixed(1)+' кг · '+sp.price+' 🪙/кг';
|
||
$('cardVal').textContent='+'+val+' 🪙';
|
||
$('cardRecord').classList.toggle('hidden',!rec);
|
||
// шини: бейдж + золотое оформление карточки
|
||
$('cardShiny').classList.toggle('hidden',!shiny);
|
||
$('card').classList.toggle('shiny-card',!!shiny);
|
||
// M4: босс: бейдж 🏆 + золотое оформление карточки
|
||
$('cardBoss').classList.toggle('hidden',!boss);
|
||
$('card').classList.toggle('boss-card',!!boss);
|
||
$('card').classList.remove('hidden');
|
||
}
|
||
|
||
/* =========================================================
|
||
КНОПКИ
|
||
========================================================= */
|
||
$('btnPlay').addEventListener('click',()=>{
|
||
ac(); sfx('click');
|
||
$('menu').classList.add('hidden');
|
||
uiOpen=false; state='IDLE';
|
||
});
|
||
$('btnShop').addEventListener('click',openShop);
|
||
$('btnCloseShop').addEventListener('click',closeShop);
|
||
$('tabTackle').addEventListener('click',()=>{ setTab('tackle'); sfx('click'); });
|
||
$('tabJournal').addEventListener('click',()=>{ setTab('journal'); sfx('click'); });
|
||
$('tabRewards').addEventListener('click',()=>{ setTab('rewards'); sfx('click'); });
|
||
$('tabAch').addEventListener('click',()=>{ setTab('ach'); sfx('click'); });
|
||
$('shopBody').addEventListener('click',shopClick);
|
||
$('btnMute').addEventListener('click',()=>{
|
||
save.muted=!save.muted; persist(); updateHUD(); sfx('click');
|
||
});
|
||
$('btnRecall').addEventListener('click',()=>{ recall(); });
|
||
$('btnReset').addEventListener('click',()=>{
|
||
sfx('click');
|
||
uiOpen=true; // пауза, пока идёт подтверждение
|
||
$('resetDlg').classList.remove('hidden');
|
||
});
|
||
$('btnResetNo').addEventListener('click',()=>{
|
||
sfx('click');
|
||
$('resetDlg').classList.add('hidden');
|
||
uiOpen=(state==='MENU');
|
||
});
|
||
$('btnResetYes').addEventListener('click',()=>{
|
||
const muted=save.muted; // настройка звука — не игровые данные, оставляем
|
||
save=freshSave(muted);
|
||
persist();
|
||
refreshQuests(); // квесты дня; стрик начнётся с первой загрузки (чистый старт, coins=0)
|
||
reel=null; biteFish=null;
|
||
stateBoss=false; bossPhase=0; // M4: босс-бой сброшен
|
||
forceBite=false; forceBoss=false; // T2b: тест-форсы тоже сбрасываем (симметрия)
|
||
$('resetDlg').classList.add('hidden');
|
||
$('shop').classList.add('hidden');
|
||
$('card').classList.add('hidden');
|
||
state='MENU'; uiOpen=true;
|
||
$('menu').classList.remove('hidden');
|
||
updateHUD(); renderShop();
|
||
sfx('no'); vib(50);
|
||
toast('Прогресс сброшен 🔄');
|
||
});
|
||
$('btnCardOk').addEventListener('click',()=>{
|
||
sfx('click');
|
||
$('card').classList.add('hidden');
|
||
state='IDLE';
|
||
});
|
||
|
||
/* =========================================================
|
||
СТАРТ
|
||
========================================================= */
|
||
loadSave();
|
||
resize();
|
||
initFish();
|
||
updateHUD();
|
||
requestAnimationFrame(frame);
|
||
</script>
|
||
</body>
|
||
</html>
|