Phase 2 DoD closeout: curve physics, AI delta, share, setup UX

- Curve bumper: spin-modifier ±35° на отскоке (детерминированный знак на bumper)
  + визуальный вихрь-индикатор. Теперь curve механически отличается от standard.
- Easy/Medium AI delta: распознавательная дистанция (Easy 150px / Medium 280px),
  reliability (Easy 0.75 / Medium 0.95). difficulty прокидывается из каждой
  personality в shouldActivateFlipperBase. Hard остаётся placeholder для Phase 3.
- Share button на ResultScene: snapshot canvas → PNG blob → platform.shareImage().
  Mock логирует, YG использует SDK share. Telemetry: share_clicked / share_failed.
- Setup UX переписан: палитра типов сверху (4 типа + trash) + tap-палитра-tap-слот
  (unified mobile/desktop) + drag-from-palette ghost (desktop). Long-tap слота без
  выбранного типа удаляет содержимое (быстрый shortcut). Заменил cycle-on-click
  на normalуй setup, как в DoD.

Closes codex finding #2 (Phase 2 DoD gap).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
aevgarik
2026-05-24 03:50:16 +03:00
parent 8e49e088b0
commit 4e3172de81
8 changed files with 281 additions and 146 deletions

View File

@@ -38,29 +38,32 @@ export function shouldActivateFlipperBase(
side: 'left' | 'right',
params: AIDifficultyParams,
lastReactionTime: number,
difficulty: AIDifficulty = 'medium',
): boolean {
const ballPos = ctx.ball.position;
const ballVel = ctx.ball.velocity;
// AI находится сверху → реагирует на мяч, летящий вверх (vy < 0)
// У AI ворота сверху → нужно отбить мяч когда он близко к AI-флипперам.
// Player flippers внизу, AI flippers вверху.
// Для AI «приближение к воротам» = мяч с маленьким Y И направлением вверх.
const aiFlippersY = 50;
const aiFlippersY = 50; // примерное location (Y ворот AI)
const ballNearGoal = ballPos.y < aiFlippersY + 200;
// Distance threshold: Easy реагирует поздно, Medium — заранее (predictive).
const triggerDist = difficulty === 'easy' ? 150 : 280;
const ballNearGoal = ballPos.y < aiFlippersY + triggerDist;
const ballHeadingToAIGoal = ballVel.y < -50;
if (!ballNearGoal || !ballHeadingToAIGoal) return false;
// Выбор стороны: если мяч слева от центра → left flipper, справа → right
// (упрощённо; для Hard сделаем точнее в Phase 3)
// Выбор стороны
const isBallOnLeft = ballPos.x < 360;
if (side === 'left' && !isBallOnLeft) return false;
if (side === 'right' && isBallOnLeft) return false;
// Reaction delay + jitter (см. bot-ai-design.md#базовая-реакция)
// Reaction delay + jitter
const now = performance.now();
const sinceLastReaction = now - lastReactionTime;
const reactionDelay = params.reactionMs + (Math.random() * 2 - 1) * params.jitterMs;
return sinceLastReaction > reactionDelay;
if (sinceLastReaction <= reactionDelay) return false;
// Reliability: Easy иногда «промахивается» (75%), Medium — 95%, Hard — Phase 3
const reliability = difficulty === 'easy' ? 0.75 : 0.95;
return Math.random() < reliability;
}

View File

@@ -33,6 +33,7 @@ export class AggressiveAI implements AIPlayer {
'left',
this.params,
this.lastReactionTimeLeft,
this.difficulty,
);
if (shouldLeft && ctx.leftFlipper.activate()) {
this.lastReactionTimeLeft = performance.now();
@@ -42,6 +43,7 @@ export class AggressiveAI implements AIPlayer {
'right',
this.params,
this.lastReactionTimeRight,
this.difficulty,
);
if (shouldRight && ctx.rightFlipper.activate()) {
this.lastReactionTimeRight = performance.now();

View File

@@ -35,6 +35,7 @@ export class DefensiveAI implements AIPlayer {
'left',
this.params,
this.lastReactionTimeLeft,
this.difficulty,
);
if (shouldLeft) {
if (ctx.leftFlipper.activate()) {
@@ -46,6 +47,7 @@ export class DefensiveAI implements AIPlayer {
'right',
this.params,
this.lastReactionTimeRight,
this.difficulty,
);
if (shouldRight) {
if (ctx.rightFlipper.activate()) {

View File

@@ -41,6 +41,7 @@ export class GhostAI implements AIPlayer {
'left',
this.params,
this.lastReactionTimeLeft,
this.difficulty,
);
if (shouldLeft && ctx.leftFlipper.activate()) {
this.lastReactionTimeLeft = performance.now();
@@ -50,6 +51,7 @@ export class GhostAI implements AIPlayer {
'right',
this.params,
this.lastReactionTimeRight,
this.difficulty,
);
if (shouldRight && ctx.rightFlipper.activate()) {
this.lastReactionTimeRight = performance.now();

View File

@@ -38,6 +38,7 @@ export class TricksterAI implements AIPlayer {
'left',
modifiedParams,
this.lastReactionTimeLeft,
this.difficulty,
);
if (shouldLeft && ctx.leftFlipper.activate()) {
this.lastReactionTimeLeft = performance.now();
@@ -47,6 +48,7 @@ export class TricksterAI implements AIPlayer {
'right',
modifiedParams,
this.lastReactionTimeRight,
this.difficulty,
);
if (shouldRight && ctx.rightFlipper.activate()) {
this.lastReactionTimeRight = performance.now();