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:
1 parent
a61ef772db
commit
13bea64bb6
7 files changed
+110
-46
No files matched your search
@@ -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" />;
|
||||
}
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user