Визуал: живая оснастка — крючок под водой, топление поплавка при клёве, диагональное вываживание. 1) Оснастка (WAIT/APPROACH/BITE): поводок от поплавка вниз, крючок (дуга+жало) и наживка; глубина крючка — 42–54% водной колонны по дальности заброса (hookY()); новый глобал castDist (дальность 0..0.97 из doCast), hookPos (позиция крючка, экспорт для отрисовки); эмодзи наживки с крючка убран. 2) Поклёвка: в APPROACH дрожание поплавка растёт по мере приближения рыбы, цель подплывания — hookPos (рыба идёт диагональю от поверхности к наживке, не телепортируется); в BITE поплавок топится (bobber.y=waterY+9, кромка воды поверх нижней половины — drawBobber(sink)), рыба дёргается у крючка в такт топлению (time*22). 3) Вываживание: fishReelPos() — рыба по диагонали от глубины крючка (castTo.x, hookY()) к лодке у поверхности (boatX+24, waterY+10) по прогрессу d/d0 (ease), подводный сегмент лески bobber→рыба; при holding линия удочка-поплавок-рыба натянута (sag=0, поплавок — точка пересечения прямой tip→fish с поверхностью, сглаживание dt*8), при отпускании провис (pull 16 / calm 12); риплы/брызги в REEL/BITE — по позиции поплавка, не castTo.x. Только canvas: механика (updateReel/startBite/hook/catchFish), сейв, баланс и имена тест-глобалов из AGENTS.md не тронуты. tests/shots.js: кадр reel — reel.holding=true + пауза 650 мс (поплавок доходит до натянутого положения). 129/129, exit 0; кадры wait/bite/reel проверены по скриншотам, свежие в screenshots/.

This commit is contained in:
2026-09-08 12:25:05 +03:00
parent 126e402dee
commit cacd956e31
5 changed files with 89 additions and 16 deletions
+87 -15
View File
@@ -833,6 +833,8 @@ 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 castDist=0; // дальность заброса 0..0.97 (визуал: глубина крючка оснастки)
let hookPos={x:0,y:0}; // позиция крючка (визуал; пригодится lane поклёвки/вываживания)
let biteTimer=0, biteT=0, biteFish=null, rippleTimer=0;
let reel=null;
let snapT=0, shake=0;
@@ -1009,6 +1011,7 @@ function fishForce(r,ag,pull){
function doCast(){
const p=Math.max(0.12,power);
const dist=Math.min(0.97,(0.10+0.80*p)*lure().cast);
castDist=dist; // запоминаем дальность (визуал: глубина крючка оснастки)
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');
@@ -1050,7 +1053,7 @@ function updateReel(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);
if(Math.random()<0.3) splash(bobber.x,waterY,1,40); // брызги у поплавка (он едет за рыбой)
}else{
reel.tension-=38*dt;
if(pulling) reel.d+=F*0.32*dt; // свободный бег: рыба реально отходит (стоимость отпускания — дистанция)
@@ -1193,6 +1196,7 @@ function updateGame(dt){
}
case 'APPROACH':
updateBiteFishIn(dt);
bobber.y=waterY-3+Math.sin(time*2)*1.5+Math.sin(time*14)*1.5*Math.min(1,biteFish?biteFish.t:0); // предвестники: дрожание, сильнее по мере приближения рыбы
if(biteFish){
if(biteFish.t>=1){
state='BITE';
@@ -1204,8 +1208,8 @@ function updateGame(dt){
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);
bobber.y=waterY+9+Math.sin(time*22)*3; // топление: глубже и резче (синхронно с дёрганием рыбы у крючка)
if(Math.random()<0.2) addRipple(bobber.x,waterY+3,26);
if(biteT<=0){
if(biteFish) biteFish.mode='out';
state='WAIT'; biteTimer=rand(5,11)/lure().bite;
@@ -1215,9 +1219,20 @@ function updateGame(dt){
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);
const fp=fishReelPos();
let tx,ty;
if(reel.holding){
// натянуто: поплавок на пересечении прямой tip→fish с поверхностью (tip–поплавок–рыба коллинеарны, fp.y>waterY>tip.y)
const k=(waterY-tip.y)/(fp.y-tip.y);
tx=tip.x+(fp.x-tip.x)*k; ty=waterY-2;
}else{
// отпускание: провис, поплавок едет за рыбой с отставанием (покачивание на поверхности)
tx=castTo.x+(fp.x-castTo.x)*0.55; ty=waterY-2+Math.sin(time*9)*2;
}
const sm=Math.min(1,dt*8); // сглаживание, чтобы не телепортировалось
bobber.x+=(tx-bobber.x)*sm;
bobber.y+=(ty-bobber.y)*sm;
if(Math.random()<0.25) addRipple(bobber.x,waterY+3,24);
}
break;
case 'SNAP':
@@ -1241,9 +1256,10 @@ 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;
// рыба плывёт диагональю от поверхности вниз к крючку/наживке (hookPos — кадр прошлого, паттерн как раньше)
biteFish.x=biteFish.sx+(hookPos.x-biteFish.sx)*e;
biteFish.y=biteFish.sy+(hookPos.y-biteFish.sy)*e;
biteFish.dir=hookPos.x>biteFish.sx?1:-1;
}
function updateBiteFishOut(dt){
biteFish.t+=dt/0.6;
@@ -1528,7 +1544,7 @@ function drawLineTo(x,y,sag){
ctx.quadraticCurveTo((tip.x+x)/2,(tip.y+y)/2+sag,x,y);
ctx.stroke();
}
function drawBobber(){
function drawBobber(sink){
const x=bobber.x,y=bobber.y;
// тёмная кромка воды под поплавком (визуал)
ctx.fillStyle='rgba(0,20,40,0.25)';
@@ -1548,6 +1564,33 @@ function drawBobber(){
ctx.strokeStyle=skin===2?'#77cc00':'rgba(255,255,255,0.9)'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.stroke();
if(skin===2) ctx.restore();
// топление (визуал, lane 2): в BITE нижняя половина уходит под воду — полупрозрачная кромка воды поверх низа поплавка
// рисуем ВНЕ save/restore неона, чтобы свечение не перетекло на слой воды
if(sink){
ctx.fillStyle='rgba(10,30,60,0.35)';
ctx.beginPath(); ctx.arc(x,y,5.6,0,Math.PI); ctx.closePath(); ctx.fill(); // нижняя половина под водой
ctx.strokeStyle='rgba(150,200,235,0.55)'; ctx.lineWidth=1; // тонкая светлая линия уровня воды
ctx.beginPath(); ctx.moveTo(x-5.6,y); ctx.lineTo(x+5.6,y); ctx.stroke();
}
}
function hookY(){
// глубина крючка на середине воды: доля глубины 0.42..0.54 по дальности заброса (визуал)
return waterY+(H-waterY)*(0.42+castDist*0.12);
}
function drawRig(){
// Живой оснастки (lane 1): поводок от поплавка вниз, крючок и наживка.
// Только визуал — механика/баланс не трогается. hookPos экспортируем для следующих шагов.
const hy=hookY(); // глубина крючка (середина воды, доля 0.42..0.54 по дальности)
const hx=bobber.x+Math.sin(time*2.2)*3; // лёгкое покачивание в противофазе к поплавку
hookPos={x:hx,y:hy};
// вертикальный бледный поводок (бледнее надводной лески 0.8 → 0.35)
ctx.strokeStyle='rgba(230,240,255,0.35)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(bobber.x,bobber.y); ctx.lineTo(hx,hy); ctx.stroke();
// крючок: дуга + жало (серый ~5px)
ctx.strokeStyle='#cfd6dd'; ctx.lineWidth=1.4; ctx.lineCap='round';
ctx.beginPath(); ctx.arc(hx,hy+4,4.5,Math.PI*0.1,Math.PI*1.6); ctx.stroke();
const eb=Math.PI*1.6, ex=hx+4.5*Math.cos(eb), ey=(hy+4)+4.5*Math.sin(eb);
ctx.beginPath(); ctx.moveTo(ex,ey); ctx.lineTo(ex+2.5,ey-2); ctx.stroke(); // жало
}
function drawLineAndBobber(){
// след поплавка: копим в забросе, чистим в остальных состояниях (визуал)
@@ -1583,9 +1626,20 @@ function drawLineAndBobber(){
}
drawLineTo(bobber.x,bobber.y,14); drawBobber(); return;
}
const sag=state==='REEL'?(reel.phase==='pull'?4:12):18+Math.sin(time*2)*3;
let sag;
if(state==='REEL'){
sag=reel.holding?0:(reel.phase==='pull'?16:12); // holding — натянуто (прямая tip→поплавок); отпускание — провис (рывок больше)
}else{
sag=18+Math.sin(time*2)*3;
}
drawLineTo(bobber.x,bobber.y,sag);
drawBobber();
drawBobber(state==='BITE'?1:0); // топление: в BITE нижняя половина под водой, в WAIT/APPROACH/REEL — нет
if(state==='REEL'&&reel){ // подводный сегмент лески bobber→рыба (бледнее надводной), чтобы леска шла к рыбе
const fp=fishReelPos();
ctx.strokeStyle='rgba(230,240,255,0.35)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(bobber.x,bobber.y); ctx.lineTo(fp.x,fp.y); ctx.stroke();
}
if(state==='WAIT'||state==='APPROACH'||state==='BITE') drawRig(); // живая оснастка (в REEL крючок «в рыбе» — не рисуем)
if(state==='BITE'){
const yy=bobber.y-24+Math.sin(time*12)*2;
ctx.font='800 28px system-ui'; ctx.textAlign='center';
@@ -1595,12 +1649,22 @@ function drawLineAndBobber(){
ctx.fillText('!',bobber.x,yy);
}
}
function fishReelPos(){
// lane 3: позиция пойманной рыбы в REEL по прогрессу вываживания — по диагонали от глубины крючка (середина воды) к лодке у поверхности (d0>0 гарантировано)
const p=Math.max(0,Math.min(1,1-reel.d/reel.d0));
const pe=p*p*(3-2*p); // лёгкое ease — плавный подход к лодке
const k=reel.tired?0.35:1; // уставшая рыба вялая — амплитуда покачивания ниже
const hy=hookY(); // старт пути — точка крючка (середина воды)
const x=castTo.x+(boatX+24-castTo.x)*pe + Math.sin(time*7*k)*4*k; // сохраняем покачивание
const y=hy+(waterY+10-hy)*pe + Math.sin(time*5*k)*3*k; // диагональ: из глубины к лодке у поверхности
return {x:x,y:y};
}
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 fp=fishReelPos();
const x=fp.x, y=fp.y;
const dir=Math.sin(time*2.5*k)>0?1:-1;
const fs=8+(reel.w/sp.wMax)*10+sp.rarity;
if(reel.boss){ // босс: золотая аура (визуал)
@@ -1787,7 +1851,15 @@ function draw(){
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);
if(biteFish&&biteFish.mode!=='out'){
if(state==='BITE'){
// топление (lane 2): рыба дёргается у крючка, синхронно с фазами топления поплавка (time*22)
drawFishShape(hookPos.x+Math.sin(time*25)*7, hookPos.y+Math.cos(time*22)*5, 12+biteFish.sp.rarity*2.5, Math.sin(time*25)>0?1:-1, biteFish.sp.hue, 0.8, Math.sin(time*8)*0.25);
}else{
// APPROACH: подплывает к поплавку как раньше
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();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 139 KiB

+2 -1
View File
@@ -91,7 +91,8 @@ async function snap(page, name) {
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
// покажем фазу «тянет» для наглядности
await page.evaluate(() => { reel.phase = 'pull'; reel.phaseT = 5; reel.tension = 0.55 * rod().line; reel.d = reel.d0 * 0.55; });
await page.evaluate(() => { reel.phase = 'pull'; reel.phaseT = 5; reel.tension = 0.55 * rod().line; reel.d = reel.d0 * 0.55; reel.holding = true; });
await sleep(650); // время ИГРЫ: поплавок доходит до натянутого положения (tip–поплавок–рыба в одну линию)
await waitPaint(page);
await snap(page, 'reel');
// доловим