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.
This commit is contained in:
ogrechko committed 2026-08-01 10:54:38 +00:00
1 parent a61ef772db
commit 13bea64bb6
7 files changed
+110 -46

No files matched your search

+30 -20
View File
@@ -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<HTMLDivElement>(null);
const gameRef = useRef<Phaser.Game | null>(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 <div ref={containerRef} className="aspect-[8/5] w-full overflow-hidden rounded-lg bg-black" />;
}
});
+9 -2
View File
@@ -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<string | null>(null);
const latestStateRef = useRef<GameProgress | null>(initialState ?? null);
const gameCanvasRef = useRef<GameCanvasHandle>(null);
const handleProgress = useCallback((state: GameProgress) => {
latestStateRef.current = state;
@@ -98,9 +100,10 @@ export function PlaySession({
}, [result, gameId, templateType, canSave]);
return (
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
<div className="mx-auto w-full max-w-4xl flex-1 px-1 py-4 sm:px-4 sm:py-6">
<div className="relative">
<GameCanvas
ref={gameCanvasRef}
key={attempt}
templateType={templateType}
definition={definition}
@@ -146,6 +149,10 @@ export function PlaySession({
)}
</div>
{(templateType === "maze" || templateType === "snake") && (
<TouchDpad onDirection={(dir) => gameCanvasRef.current?.sendDirection(dir)} />
)}
{canSave && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-1.5 text-text-muted">
+43
View File
@@ -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 (
<div className="mt-3 grid w-fit grid-cols-3 grid-rows-3 gap-1.5 select-none">
<div />
<button type="button" aria-label="Вверх" onPointerDown={press("up")} className={`${BUTTON_CLASS} col-start-2`}>
<ArrowUp size={22} />
</button>
<div />
<button type="button" aria-label="Влево" onPointerDown={press("left")} className={BUTTON_CLASS}>
<ArrowLeft size={22} />
</button>
<div />
<button type="button" aria-label="Вправо" onPointerDown={press("right")} className={BUTTON_CLASS}>
<ArrowRight size={22} />
</button>
<div />
<button
type="button"
aria-label="Вниз"
onPointerDown={press("down")}
className={`${BUTTON_CLASS} col-start-2`}
>
<ArrowDown size={22} />
</button>
<div />
</div>
);
}
+8 -8
View File
@@ -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",
})
+7 -4
View File
@@ -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() });
+10 -10
View File
@@ -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 },
+3 -2
View File
@@ -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();