From 13bea64bb63b5082882b41be22a0a1c501b588fc Mon Sep 17 00:00:00 2001 From: Oleg Date: Sat, 1 Aug 2026 10:54:38 +0000 Subject: [PATCH] Mobile: bigger HUD text, wider canvas, on-screen D-pad for Maze/Snake Games were unreadably small on phones (fixed-px HUD text scaled down by the Phaser FIT canvas) and Maze/Snake were unplayable on touch (keyboard-only input). Bumps HUD font sizes ~1.4x across all 4 scenes, tightens play-session's mobile padding so the canvas fills more of the screen, and adds a tappable D-pad wired through a new registry "touchDirection" event that feeds the same direction-buffering logic the keyboard already uses. --- src/components/game-canvas.tsx | 50 ++++++++++++++++----------- src/components/play-session.tsx | 11 ++++-- src/components/touch-dpad.tsx | 43 +++++++++++++++++++++++ src/lib/games/scenes/clicker-scene.ts | 16 ++++----- src/lib/games/scenes/maze-scene.ts | 11 +++--- src/lib/games/scenes/quiz-scene.ts | 20 +++++------ src/lib/games/scenes/snake-scene.ts | 5 +-- 7 files changed, 110 insertions(+), 46 deletions(-) create mode 100644 src/components/touch-dpad.tsx diff --git a/src/components/game-canvas.tsx b/src/components/game-canvas.tsx index 495b715..96c128c 100644 --- a/src/components/game-canvas.tsx +++ b/src/components/game-canvas.tsx @@ -1,29 +1,37 @@ "use client"; -import { useEffect, useRef } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import type Phaser from "phaser"; import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types"; import type { ClickerAccountBonuses } from "@/lib/games/clicker"; -export function GameCanvas({ - templateType, - definition, - initialState, - accountBonuses, - onFinish, - onProgress, -}: { - templateType: TemplateType; - definition: GameDefinition; - initialState?: GameProgress; - accountBonuses?: ClickerAccountBonuses; - onFinish?: (result: GameResult) => void; - onProgress?: (state: GameProgress) => void; -}) { +export type TouchDirection = "up" | "down" | "left" | "right"; + +export type GameCanvasHandle = { + sendDirection: (dir: TouchDirection) => void; +}; + +export const GameCanvas = forwardRef< + GameCanvasHandle, + { + templateType: TemplateType; + definition: GameDefinition; + initialState?: GameProgress; + accountBonuses?: ClickerAccountBonuses; + onFinish?: (result: GameResult) => void; + onProgress?: (state: GameProgress) => void; + } +>(function GameCanvas({ templateType, definition, initialState, accountBonuses, onFinish, onProgress }, ref) { const containerRef = useRef(null); + const gameRef = useRef(null); + + useImperativeHandle(ref, () => ({ + sendDirection: (dir) => { + gameRef.current?.registry.events.emit("touchDirection", dir); + }, + })); useEffect(() => { - let game: Phaser.Game | null = null; let cancelled = false; (async () => { @@ -35,7 +43,7 @@ export function GameCanvas({ const SceneClass = sceneClassFor(templateType); - game = new PhaserLib.Game({ + const game = new PhaserLib.Game({ type: PhaserLib.AUTO, parent: containerRef.current, backgroundColor: "#0a0b14", @@ -47,6 +55,7 @@ export function GameCanvas({ }, scene: [SceneClass], }); + gameRef.current = game; game.registry.set("definition", definition); game.registry.set("initialState", initialState); @@ -57,7 +66,8 @@ export function GameCanvas({ return () => { cancelled = true; - game?.destroy(true); + gameRef.current?.destroy(true); + gameRef.current = null; }; // Intentionally only re-creating the game when the template changes — // callers that need a fresh definition/result loaded should force a @@ -66,4 +76,4 @@ export function GameCanvas({ }, [templateType]); return
; -} +}); diff --git a/src/components/play-session.tsx b/src/components/play-session.tsx index 3698268..6292002 100644 --- a/src/components/play-session.tsx +++ b/src/components/play-session.tsx @@ -3,7 +3,8 @@ import { useEffect, useRef, useState, useCallback } from "react"; import Link from "next/link"; import { RotateCcw, Trophy, XCircle, Save, Clock, Gem } from "lucide-react"; -import { GameCanvas } from "./game-canvas"; +import { GameCanvas, type GameCanvasHandle } from "./game-canvas"; +import { TouchDpad } from "./touch-dpad"; import { MANUAL_SAVE_COST } from "@/lib/wallet/economy"; import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types"; import type { ClickerAccountBonuses } from "@/lib/games/clicker"; @@ -37,6 +38,7 @@ export function PlaySession({ const [saveError, setSaveError] = useState(null); const latestStateRef = useRef(initialState ?? null); + const gameCanvasRef = useRef(null); const handleProgress = useCallback((state: GameProgress) => { latestStateRef.current = state; @@ -98,9 +100,10 @@ export function PlaySession({ }, [result, gameId, templateType, canSave]); return ( -
+
+ {(templateType === "maze" || templateType === "snake") && ( + gameCanvasRef.current?.sendDirection(dir)} /> + )} + {canSave && (
diff --git a/src/components/touch-dpad.tsx b/src/components/touch-dpad.tsx new file mode 100644 index 0000000..8874884 --- /dev/null +++ b/src/components/touch-dpad.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from "lucide-react"; +import type { TouchDirection } from "./game-canvas"; + +const BUTTON_CLASS = + "flex h-14 w-14 touch-manipulation items-center justify-center rounded-lg border border-border bg-surface text-text active:bg-surface-hover"; + +export function TouchDpad({ onDirection }: { onDirection: (dir: TouchDirection) => void }) { + const press = (dir: TouchDirection) => (e: React.PointerEvent) => { + e.preventDefault(); + onDirection(dir); + }; + + return ( +
+
+ +
+ + +
+ + +
+ +
+
+ ); +} diff --git a/src/lib/games/scenes/clicker-scene.ts b/src/lib/games/scenes/clicker-scene.ts index e25b60d..d9c3a9e 100644 --- a/src/lib/games/scenes/clicker-scene.ts +++ b/src/lib/games/scenes/clicker-scene.ts @@ -82,12 +82,12 @@ export class ClickerScene extends Phaser.Scene { this.cameras.main.setBackgroundColor(COLORS.bg); - this.levelText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#a3a8c6" }); + this.levelText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#a3a8c6" }); this.goldText = this.add - .text(940, 15, "", { fontFamily: "sans-serif", fontSize: "16px", color: "#fbbf24", fontStyle: "bold" }) + .text(940, 15, "", { fontFamily: "sans-serif", fontSize: "22px", color: "#fbbf24", fontStyle: "bold" }) .setOrigin(1, 0); this.dpsText = this.add - .text(940, 38, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#a3a8c6" }) + .text(940, 38, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#a3a8c6" }) .setOrigin(1, 0); this.flashCircle = this.add.circle(MONSTER_X, MONSTER_Y, 95, COLORS.flash, 0).setDepth(1); @@ -100,13 +100,13 @@ export class ClickerScene extends Phaser.Scene { this.monsterEmojiText.on("pointerdown", (pointer: Phaser.Input.Pointer) => this.handleClick(pointer)); this.monsterNameText = this.add - .text(MONSTER_X, 228, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#818cf8" }) + .text(MONSTER_X, 228, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#818cf8" }) .setOrigin(0.5); this.hpBarBg = this.add.rectangle(MONSTER_X, HP_BAR_Y, HP_BAR_WIDTH, 18, COLORS.hpBarBg); this.hpBarFill = this.add.rectangle(MONSTER_X - HP_BAR_WIDTH / 2, HP_BAR_Y, HP_BAR_WIDTH, 18, COLORS.hpHigh).setOrigin(0, 0.5); this.hpValueText = this.add - .text(MONSTER_X, HP_BAR_Y, "", { fontFamily: "sans-serif", fontSize: "12px", color: "#eef0fb" }) + .text(MONSTER_X, HP_BAR_Y, "", { fontFamily: "sans-serif", fontSize: "17px", color: "#eef0fb" }) .setOrigin(0.5); this.bossBarBg = this.add.rectangle(MONSTER_X, 283, HP_BAR_WIDTH, 8, COLORS.bossTimerBg).setVisible(false); @@ -117,11 +117,11 @@ export class ClickerScene extends Phaser.Scene { this.definition.heroes.forEach((_, index) => { const y = HEROES_START_Y + index * HERO_ROW_GAP; - const bg = this.add.rectangle(480, y, 780, 24, COLORS.heroRow).setStrokeStyle(1, 0x4f46e5).setInteractive({ + const bg = this.add.rectangle(480, y, 780, 26, COLORS.heroRow).setStrokeStyle(1, 0x4f46e5).setInteractive({ useHandCursor: true, }); const label = this.add - .text(480, y, "", { fontFamily: "sans-serif", fontSize: "12px", color: "#eef0fb" }) + .text(480, y, "", { fontFamily: "sans-serif", fontSize: "17px", color: "#eef0fb" }) .setOrigin(0.5); bg.on("pointerdown", () => this.buyHero(index)); this.heroRows.push({ bg, label }); @@ -244,7 +244,7 @@ export class ClickerScene extends Phaser.Scene { const dmgText = this.add .text(x, y - 40, `-${Math.round(amount)}`, { fontFamily: "sans-serif", - fontSize: "18px", + fontSize: "24px", color: "#f87171", fontStyle: "bold", }) diff --git a/src/lib/games/scenes/maze-scene.ts b/src/lib/games/scenes/maze-scene.ts index 7c04975..f666438 100644 --- a/src/lib/games/scenes/maze-scene.ts +++ b/src/lib/games/scenes/maze-scene.ts @@ -165,10 +165,10 @@ export class MazeScene extends Phaser.Scene { this.player = this.add.circle(playerPx.x, playerPx.y, PLAYER_RADIUS, COLORS.player).setDepth(3); this.tweens.add({ targets: this.player, scale: 0.82, duration: 140, yoyo: true, repeat: -1 }); - this.livesText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" }); - this.scoreText = this.add.text(20, 38, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#a3a8c6" }); + this.livesText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" }); + this.scoreText = this.add.text(20, 38, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#a3a8c6" }); this.dotsText = this.add - .text(940, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" }) + .text(940, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" }) .setOrigin(1, 0); this.frightenBarBg = this.add.rectangle(480, 95, 500, 8, 0x292d4a).setVisible(false); @@ -180,7 +180,7 @@ export class MazeScene extends Phaser.Scene { if (this.definition.timeLimitSec > 0) { this.remainingMs = initialState?.remainingMs ?? this.definition.timeLimitSec * 1000; this.timerText = this.add - .text(480, 15, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#a3a8c6" }) + .text(480, 15, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#a3a8c6" }) .setOrigin(0.5, 0); this.time.addEvent({ delay: 100, @@ -201,6 +201,9 @@ export class MazeScene extends Phaser.Scene { this.input.keyboard!.on("keydown-RIGHT", () => (this.desiredDir = DIRS.right)); this.input.keyboard!.on("keydown-UP", () => (this.desiredDir = DIRS.up)); this.input.keyboard!.on("keydown-DOWN", () => (this.desiredDir = DIRS.down)); + this.registry.events.on("touchDirection", (dir: "up" | "down" | "left" | "right") => { + this.desiredDir = DIRS[dir]; + }); this.time.addEvent({ delay: PLAYER_STEP_MS, loop: true, callback: () => this.playerTick() }); this.time.addEvent({ delay: GHOST_STEP_MS, loop: true, callback: () => this.ghostTick() }); diff --git a/src/lib/games/scenes/quiz-scene.ts b/src/lib/games/scenes/quiz-scene.ts index 93305ec..be7a48f 100644 --- a/src/lib/games/scenes/quiz-scene.ts +++ b/src/lib/games/scenes/quiz-scene.ts @@ -86,17 +86,17 @@ export class QuizScene extends Phaser.Scene { this.cameras.main.setBackgroundColor(COLORS.bg); - this.levelText = this.add.text(30, 18, "", { fontFamily: "sans-serif", fontSize: "16px", color: "#a3a8c6" }); + this.levelText = this.add.text(30, 18, "", { fontFamily: "sans-serif", fontSize: "22px", color: "#a3a8c6" }); this.prizeText = this.add - .text(930, 18, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#eef0fb", fontStyle: "bold" }) + .text(930, 18, "", { fontFamily: "sans-serif", fontSize: "24px", color: "#eef0fb", fontStyle: "bold" }) .setOrigin(1, 0); this.categoryText = this.add - .text(480, 18, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#818cf8" }) + .text(480, 18, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#818cf8" }) .setOrigin(0.5, 0); this.questionText = this.add .text(480, 55, "", { fontFamily: "sans-serif", - fontSize: "21px", + fontSize: "28px", color: "#eef0fb", align: "center", wordWrap: { width: 840 }, @@ -108,7 +108,7 @@ export class QuizScene extends Phaser.Scene { .setStrokeStyle(1, 0x4f46e5) .setInteractive({ useHandCursor: true }); this.walkAwayLabel = this.add - .text(880, 60, "Забрать", { fontFamily: "sans-serif", fontSize: "13px", color: "#eef0fb" }) + .text(880, 60, "Забрать", { fontFamily: "sans-serif", fontSize: "18px", color: "#eef0fb" }) .setOrigin(0.5); this.walkAwayButton.on("pointerdown", () => this.walkAway()); @@ -122,7 +122,7 @@ export class QuizScene extends Phaser.Scene { .rectangle(x, 425, 130, 36, COLORS.lifeline) .setStrokeStyle(1, 0x4f46e5) .setInteractive({ useHandCursor: true }); - this.add.text(x, 425, label, { fontFamily: "sans-serif", fontSize: "14px", color: "#eef0fb" }).setOrigin(0.5); + this.add.text(x, 425, label, { fontFamily: "sans-serif", fontSize: "19px", color: "#eef0fb" }).setOrigin(0.5); bg.on("pointerdown", () => this.useLifeline(kind)); this.lifelineButtons.set(kind, bg); }); @@ -183,7 +183,7 @@ export class QuizScene extends Phaser.Scene { const label = this.add .text(140, y, hidden ? "" : `${OPTION_LETTERS[i]}: ${option}`, { fontFamily: "sans-serif", - fontSize: "16px", + fontSize: "22px", color: "#eef0fb", }) .setOrigin(0, 0.5); @@ -286,7 +286,7 @@ export class QuizScene extends Phaser.Scene { const bg = this.add.rectangle(480, 505, 780, 70, COLORS.overlayBg).setStrokeStyle(1, 0x292d4a); this.overlayObjects.push(bg); const title = this.add - .text(480, 478, "Помощь зала", { fontFamily: "sans-serif", fontSize: "12px", color: "#a3a8c6" }) + .text(480, 478, "Помощь зала", { fontFamily: "sans-serif", fontSize: "17px", color: "#a3a8c6" }) .setOrigin(0.5); this.overlayObjects.push(title); @@ -298,7 +298,7 @@ export class QuizScene extends Phaser.Scene { const label = this.add .text(x, 495, `${OPTION_LETTERS[optIndex]}: ${Math.round(pct * 100)}%`, { fontFamily: "sans-serif", - fontSize: "13px", + fontSize: "18px", color: "#eef0fb", }) .setOrigin(0.5); @@ -317,7 +317,7 @@ export class QuizScene extends Phaser.Scene { const text = this.add .text(480, 500, `Друг на телефоне: «Я почти уверен, это ${OPTION_LETTERS[suggestedIndex]}»`, { fontFamily: "sans-serif", - fontSize: "14px", + fontSize: "19px", color: "#eef0fb", align: "center", wordWrap: { width: 720 }, diff --git a/src/lib/games/scenes/snake-scene.ts b/src/lib/games/scenes/snake-scene.ts index 6280902..ecfd1b0 100644 --- a/src/lib/games/scenes/snake-scene.ts +++ b/src/lib/games/scenes/snake-scene.ts @@ -95,15 +95,16 @@ export class SnakeScene extends Phaser.Scene { this.foodShape = this.add.circle(0, 0, this.cellSize * 0.4, COLORS.food); this.updateFoodPosition(); - this.scoreText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" }); + this.scoreText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" }); this.lengthText = this.add - .text(940, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" }) + .text(940, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" }) .setOrigin(1, 0); this.input.keyboard!.on("keydown-LEFT", () => this.setDesiredDirection("left")); this.input.keyboard!.on("keydown-RIGHT", () => this.setDesiredDirection("right")); this.input.keyboard!.on("keydown-UP", () => this.setDesiredDirection("up")); this.input.keyboard!.on("keydown-DOWN", () => this.setDesiredDirection("down")); + this.registry.events.on("touchDirection", (dir: Direction) => this.setDesiredDirection(dir)); this.updateHud(); this.reportProgress();