Upgrade Clicker to a Clicker Heroes-style monster battler

Full redesign of the Clicker template (same templateType: "clicker" —
plain text column, no migration needed): a battlefield with an emoji
monster that has real HP, click/DPS damage, boss fights every 5th
level (HP x4, 20s timer — timing out just resets the boss rather than
punishing progress), and heroes that level up repeatedly with scaling
cost (Clicker Heroes' defining mechanic, replacing the old one-time
"buy an upgrade" model).

No image pipeline available, so the "2D graphics" ask is met with
large emoji as monster art backed by real animation instead: an
HP bar that shifts green -> yellow -> red, camera shake + a red flash
+ a squish tween on every hit, floating "-N" damage numbers, and a
small coin burst on every kill.

Verified the HP/boss/gold/hero-cost curve with a standalone script
before wiring it into the scene (same approach used for the quiz
checkpoint math): boss multiplier lands only on levels divisible by 5,
HP growth and hero cost compounding both check out. Builder form and
AI-generation prompt updated to match the new shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-01 02:36:44 +00:00
1 parent ff1b2ff6f9
commit f5833a1988
5 files changed
+417 -153

No files matched your search

+92 -48
View File
@@ -3,12 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Plus, Trash2, Save } from "lucide-react"; import { Plus, Trash2, Save } from "lucide-react";
import { import { clickerDefinitionSchema, DEFAULT_CLICKER_DEFINITION, type ClickerDefinition, type ClickerHero } from "@/lib/games/clicker";
clickerDefinitionSchema,
DEFAULT_CLICKER_DEFINITION,
type ClickerDefinition,
type ClickerUpgrade,
} from "@/lib/games/clicker";
export function ClickerBuilder({ export function ClickerBuilder({
mode, mode,
@@ -26,32 +21,47 @@ export function ClickerBuilder({
const router = useRouter(); const router = useRouter();
const [title, setTitle] = useState(initialTitle ?? ""); const [title, setTitle] = useState(initialTitle ?? "");
const [description, setDescription] = useState(initialDescription ?? ""); const [description, setDescription] = useState(initialDescription ?? "");
const [targetLabel, setTargetLabel] = useState(initialDefinition?.targetLabel ?? DEFAULT_CLICKER_DEFINITION.targetLabel); const [theme, setTheme] = useState(initialDefinition?.theme ?? DEFAULT_CLICKER_DEFINITION.theme);
const [targetScore, setTargetScore] = useState(initialDefinition?.targetScore ?? DEFAULT_CLICKER_DEFINITION.targetScore); const [targetLevel, setTargetLevel] = useState(initialDefinition?.targetLevel ?? DEFAULT_CLICKER_DEFINITION.targetLevel);
const [startingPointsPerClick, setStartingPointsPerClick] = useState( const [startingClickDamage, setStartingClickDamage] = useState(
initialDefinition?.startingPointsPerClick ?? DEFAULT_CLICKER_DEFINITION.startingPointsPerClick, initialDefinition?.startingClickDamage ?? DEFAULT_CLICKER_DEFINITION.startingClickDamage,
); );
const [upgrades, setUpgrades] = useState<ClickerUpgrade[]>( const [baseMonsterHp, setBaseMonsterHp] = useState(initialDefinition?.baseMonsterHp ?? DEFAULT_CLICKER_DEFINITION.baseMonsterHp);
initialDefinition?.upgrades ?? DEFAULT_CLICKER_DEFINITION.upgrades, const [monsterEmojisText, setMonsterEmojisText] = useState(
(initialDefinition?.monsterEmojis ?? DEFAULT_CLICKER_DEFINITION.monsterEmojis).join(", "),
); );
const [bossEmoji, setBossEmoji] = useState(initialDefinition?.bossEmoji ?? DEFAULT_CLICKER_DEFINITION.bossEmoji);
const [heroes, setHeroes] = useState<ClickerHero[]>(initialDefinition?.heroes ?? DEFAULT_CLICKER_DEFINITION.heroes);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
function updateUpgrade(index: number, patch: Partial<ClickerUpgrade>) { function updateHero(index: number, patch: Partial<ClickerHero>) {
setUpgrades((ups) => ups.map((u, i) => (i === index ? { ...u, ...patch } : u))); setHeroes((hs) => hs.map((h, i) => (i === index ? { ...h, ...patch } : h)));
} }
function addUpgrade() { function addHero() {
setUpgrades((ups) => [...ups, { name: "", cost: 10, pointsPerClickBonus: 0, pointsPerSecondBonus: 0 }]); setHeroes((hs) => [...hs, { name: "", baseCost: 10, clickDamageBonus: 0, dpsBonus: 0 }]);
} }
function removeUpgrade(index: number) { function removeHero(index: number) {
setUpgrades((ups) => ups.filter((_, i) => i !== index)); setHeroes((hs) => hs.filter((_, i) => i !== index));
} }
async function handleSave() { async function handleSave() {
setError(null); setError(null);
const definition: ClickerDefinition = { targetLabel, targetScore, startingPointsPerClick, upgrades }; const monsterEmojis = monsterEmojisText
.split(",")
.map((e) => e.trim())
.filter((e) => e.length > 0);
const definition: ClickerDefinition = {
theme,
targetLevel,
startingClickDamage,
baseMonsterHp,
monsterEmojis,
bossEmoji,
heroes,
};
const parsed = clickerDefinitionSchema.safeParse(definition); const parsed = clickerDefinitionSchema.safeParse(definition);
if (!parsed.success) { if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте настройки — что-то заполнено неверно"); setError(parsed.error.issues[0]?.message ?? "Проверьте настройки — что-то заполнено неверно");
@@ -110,85 +120,119 @@ export function ClickerBuilder({
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent" className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/> />
</label> </label>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-2 gap-3">
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название цели</span> <span className="mb-1 block font-medium text-text-muted">Тема</span>
<input <input
value={targetLabel} value={theme}
onChange={(e) => setTargetLabel(e.target.value)} onChange={(e) => setTheme(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent" className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/> />
</label> </label>
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Очков для победы</span> <span className="mb-1 block font-medium text-text-muted">Уровней до победы (5–200)</span>
<input <input
type="number" type="number"
min={10} min={5}
value={targetScore} max={200}
onChange={(e) => setTargetScore(Number(e.target.value))} value={targetLevel}
onChange={(e) => setTargetLevel(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent" className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/> />
</label> </label>
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Очков за клик (старт)</span> <span className="mb-1 block font-medium text-text-muted">Урон за клик (старт)</span>
<input <input
type="number" type="number"
min={1} min={1}
value={startingPointsPerClick} value={startingClickDamage}
onChange={(e) => setStartingPointsPerClick(Number(e.target.value))} onChange={(e) => setStartingClickDamage(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">HP первого монстра</span>
<input
type="number"
min={1}
value={baseMonsterHp}
onChange={(e) => setBaseMonsterHp(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent" className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/> />
</label> </label>
</div> </div>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Эмодзи монстров (через запятую, чередуются по уровням)</span>
<input
value={monsterEmojisText}
onChange={(e) => setMonsterEmojisText(e.target.value)}
placeholder="👹, 👺, 💀, 🧟"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="w-32 text-sm">
<span className="mb-1 block font-medium text-text-muted">Эмодзи босса</span>
<input
value={bossEmoji}
onChange={(e) => setBossEmoji(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<p className="text-xs text-text-faint">
Каждый 5-й уровень — босс (HP ×4, таймер на удар). Герои покупаются многократно — каждый следующий уровень
дороже и сильнее.
</p>
</div> </div>
<div className="card flex flex-col gap-3 p-4"> <div className="card flex flex-col gap-3 p-4">
<span className="text-sm font-semibold text-text-muted">Улучшения</span> <span className="text-sm font-semibold text-text-muted">Герои</span>
{upgrades.map((upgrade, index) => ( {heroes.map((hero, index) => (
<div key={index} className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-2"> <div key={index} className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-2">
<input <input
value={upgrade.name} value={hero.name}
onChange={(e) => updateUpgrade(index, { name: e.target.value })} onChange={(e) => updateHero(index, { name: e.target.value })}
placeholder="Название" placeholder="Название"
className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent" className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent"
/> />
<input <input
type="number" type="number"
min={1} min={1}
value={upgrade.cost} value={hero.baseCost}
onChange={(e) => updateUpgrade(index, { cost: Number(e.target.value) })} onChange={(e) => updateHero(index, { baseCost: Number(e.target.value) })}
title="Цена" title="Базовая цена"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent" className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/> />
<input <input
type="number" type="number"
min={0} min={0}
value={upgrade.pointsPerClickBonus} value={hero.clickDamageBonus}
onChange={(e) => updateUpgrade(index, { pointsPerClickBonus: Number(e.target.value) })} onChange={(e) => updateHero(index, { clickDamageBonus: Number(e.target.value) })}
title="Бонус за клик" title="Урон за клик за уровень"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent" className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/> />
<input <input
type="number" type="number"
min={0} min={0}
value={upgrade.pointsPerSecondBonus} value={hero.dpsBonus}
onChange={(e) => updateUpgrade(index, { pointsPerSecondBonus: Number(e.target.value) })} onChange={(e) => updateHero(index, { dpsBonus: Number(e.target.value) })}
title="Бонус в секунду" title="Урон в секунду за уровень"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent" className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/> />
<button onClick={() => removeUpgrade(index)} className="text-text-faint hover:text-danger"> <button onClick={() => removeHero(index)} className="text-text-faint hover:text-danger">
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> </div>
))} ))}
<div className="flex gap-4 text-xs text-text-faint"> <div className="flex gap-4 text-xs text-text-faint">
<span>Цена</span> <span>Цена</span>
<span className="ml-auto">За клик</span> <span className="ml-auto">Урон/клик</span>
<span>За сек</span> <span>Урон/сек</span>
</div> </div>
<button onClick={addUpgrade} className="btn btn-ghost self-start"> <button onClick={addHero} className="btn btn-ghost self-start">
<Plus size={15} /> <Plus size={15} />
Добавить улучшение Добавить героя
</button> </button>
</div> </div>
+1 -1
View File
@@ -6,7 +6,7 @@ import { templateSchemas, type TemplateType, type GameDefinition } from "@/lib/g
const SYSTEM_PROMPTS: Record<TemplateType, string> = { const SYSTEM_PROMPTS: Record<TemplateType, string> = {
quiz: "Ты создаёшь вопросы для викторины в формате «Кто хочет стать миллионером?» — 15-уровневая денежная лестница с нарастающей сложностью. Каждый вопрос должен использовать ТОЛЬКО реальные, проверяемые факты (даты, столицы, авторов, научные факты и т.п.) — ничего не выдумывай и не искажай. У каждого вопроса ровно 4 варианта ответа и один правильный. Поле band (1-5) — сложность: 1 — очень лёгкий вопрос, 5 — очень сложный. Обязательно должен быть хотя бы один вопрос на каждый band от 1 до 5. Категория (category) должна отражать тему вопроса.", quiz: "Ты создаёшь вопросы для викторины в формате «Кто хочет стать миллионером?» — 15-уровневая денежная лестница с нарастающей сложностью. Каждый вопрос должен использовать ТОЛЬКО реальные, проверяемые факты (даты, столицы, авторов, научные факты и т.п.) — ничего не выдумывай и не искажай. У каждого вопроса ровно 4 варианта ответа и один правильный. Поле band (1-5) — сложность: 1 — очень лёгкий вопрос, 5 — очень сложный. Обязательно должен быть хотя бы один вопрос на каждый band от 1 до 5. Категория (category) должна отражать тему вопроса.",
clicker: clicker:
"Ты создаёшь простую кликер-игру (idle game) для игрового движка. Придумай тему, набор улучшений и целевой счёт по описанию пользователя. Улучшения должны быть сбалансированы: чем дороже — тем сильнее эффект.", "Ты создаёшь RPG-кликер в духе Clicker Heroes для игрового движка: игрок бьёт монстра по клику, монстр имеет HP, каждые 5 уровней — босс с таймером. Придумай тему (theme), название босса через bossEmoji (один эмодзи), пул из 5-10 эмодзи обычных монстров (monsterEmojis, каждый — один эмодзи-символ) и 4-8 героев (heroes) по описанию пользователя — герои покупаются многократно (уровни), у каждого либо бонус к урону за клик (clickDamageBonus), либо к урону в секунду (dpsBonus), либо оба понемногу. Более поздние герои в списке должны быть дороже (baseCost) и сильнее.",
maze: "Ты создаёшь лабиринт для простого игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Ровно один символ S (старт), хотя бы один E (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.", maze: "Ты создаёшь лабиринт для простого игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Ровно один символ S (старт), хотя бы один E (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.",
}; };
+60 -20
View File
@@ -1,37 +1,77 @@
import { z } from "zod"; import { z } from "zod";
export const clickerUpgradeSchema = z.object({ /** Per-level monster HP growth. */
name: z.string().min(1).max(60), export const HP_GROWTH = 1.18;
cost: z.number().int().min(1), /** Gold reward per point of monster max HP. */
pointsPerClickBonus: z.number().min(0).default(0), export const GOLD_PER_HP = 0.5;
pointsPerSecondBonus: z.number().min(0).default(0), /** Every Nth level is a boss level. */
export const BOSS_EVERY = 5;
/** Boss monsters have this many times a normal monster's HP at that level. */
export const BOSS_HP_MULTIPLIER = 4;
/** Boss levels give the player this long to land the killing blow. */
export const BOSS_TIME_LIMIT_MS = 20000;
/** Each hero level costs this much more than the last. */
export const HERO_COST_GROWTH = 1.15;
export const clickerHeroSchema = z.object({
name: z.string().min(1).max(40),
baseCost: z.number().min(1),
clickDamageBonus: z.number().min(0).default(0),
dpsBonus: z.number().min(0).default(0),
}); });
export const clickerDefinitionSchema = z.object({ export const clickerDefinitionSchema = z.object({
targetLabel: z.string().min(1).max(60).default("Клик!"), theme: z.string().min(1).max(60).default("Подземелье"),
targetScore: z.number().int().min(10), targetLevel: z.number().int().min(5).max(200).default(30),
startingPointsPerClick: z.number().min(1).default(1), startingClickDamage: z.number().min(1).default(5),
upgrades: z.array(clickerUpgradeSchema).max(20).default([]), baseMonsterHp: z.number().min(1).default(20),
monsterEmojis: z.array(z.string().min(1).max(8)).min(1).max(12).default(["👹"]),
bossEmoji: z.string().min(1).max(8).default("🐉"),
heroes: z.array(clickerHeroSchema).min(1).max(20),
}); });
export type ClickerUpgrade = z.infer<typeof clickerUpgradeSchema>; export type ClickerHero = z.infer<typeof clickerHeroSchema>;
export type ClickerDefinition = z.infer<typeof clickerDefinitionSchema>; export type ClickerDefinition = z.infer<typeof clickerDefinitionSchema>;
export function isBossLevel(level: number): boolean {
return level % BOSS_EVERY === 0;
}
export function monsterHpForLevel(level: number, def: Pick<ClickerDefinition, "baseMonsterHp">): number {
const hp = def.baseMonsterHp * Math.pow(HP_GROWTH, level - 1);
return Math.round(isBossLevel(level) ? hp * BOSS_HP_MULTIPLIER : hp);
}
export function goldForMonster(hp: number): number {
return Math.max(1, Math.round(hp * GOLD_PER_HP));
}
/** Cost to buy the hero's (currentLevel + 1)th level. */
export function heroCostAtLevel(hero: ClickerHero, currentLevel: number): number {
return Math.round(hero.baseCost * Math.pow(HERO_COST_GROWTH, currentLevel));
}
export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = { export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
targetLabel: "Монета", theme: "Тёмное подземелье",
targetScore: 100, targetLevel: 30,
startingPointsPerClick: 1, startingClickDamage: 5,
upgrades: [ baseMonsterHp: 20,
{ name: "Крепкий клик", cost: 20, pointsPerClickBonus: 1, pointsPerSecondBonus: 0 }, monsterEmojis: ["👹", "👺", "💀", "🧟", "🐗", "🦂", "🐍", "🦇", "👻", "🕷️"],
{ name: "Автокликер", cost: 50, pointsPerClickBonus: 0, pointsPerSecondBonus: 1 }, bossEmoji: "🐉",
heroes: [
{ name: "Оруженосец", baseCost: 15, clickDamageBonus: 2, dpsBonus: 0 },
{ name: "Лучник", baseCost: 40, clickDamageBonus: 0, dpsBonus: 3 },
{ name: "Кузнец", baseCost: 100, clickDamageBonus: 8, dpsBonus: 0 },
{ name: "Маг", baseCost: 250, clickDamageBonus: 0, dpsBonus: 12 },
{ name: "Паладин", baseCost: 600, clickDamageBonus: 25, dpsBonus: 10 },
{ name: "Дракон", baseCost: 1500, clickDamageBonus: 0, dpsBonus: 60 },
], ],
}; };
export const clickerProgressSchema = z.object({ export const clickerProgressSchema = z.object({
score: z.number().min(0), level: z.number().int().min(1),
pointsPerClick: z.number().min(0), gold: z.number().min(0),
pointsPerSecond: z.number().min(0), heroLevels: z.array(z.number().int().min(0)).default([]),
ownedUpgradeIndices: z.array(z.number().int().min(0)).default([]),
}); });
export type ClickerProgress = z.infer<typeof clickerProgressSchema>; export type ClickerProgress = z.infer<typeof clickerProgressSchema>;
+262 -82
View File
@@ -1,33 +1,65 @@
import Phaser from "phaser"; import Phaser from "phaser";
import type { ClickerDefinition, ClickerUpgrade, ClickerProgress, GameResult } from "../types"; import {
isBossLevel,
monsterHpForLevel,
goldForMonster,
heroCostAtLevel,
BOSS_TIME_LIMIT_MS,
} from "../clicker";
import type { ClickerDefinition, ClickerProgress, GameResult } from "../types";
const COLORS = { const COLORS = {
bg: 0x14162a, bg: 0x14162a,
target: 0x4f46e5, hpBarBg: 0x292d4a,
row: 0x23264a, hpHigh: 0x0d9488,
rowOwned: 0x0d3d38, hpMid: 0xd97706,
rowDisabled: 0x1a1c33, hpLow: 0xdc2626,
bossTimerBg: 0x292d4a,
bossTimerFill: 0xdc2626,
heroRow: 0x23264a,
heroRowUnaffordable: 0x181a30,
coin: 0xfbbf24,
flash: 0xdc2626,
}; };
interface UpgradeRow { const MONSTER_X = 480;
bg: Phaser.GameObjects.Rectangle; const MONSTER_Y = 155;
label: Phaser.GameObjects.Text; const HP_BAR_Y = 258;
upgrade: ClickerUpgrade; const HP_BAR_WIDTH = 500;
index: number; const HEROES_START_Y = 330;
} const HERO_ROW_GAP = 42;
export class ClickerScene extends Phaser.Scene { export class ClickerScene extends Phaser.Scene {
private definition!: ClickerDefinition; private definition!: ClickerDefinition;
private onFinish?: (result: GameResult) => void; private onFinish?: (result: GameResult) => void;
private onProgress?: (state: ClickerProgress) => void; private onProgress?: (state: ClickerProgress) => void;
private score = 0;
private pointsPerClick = 1; private level = 1;
private pointsPerSecond = 0; private gold = 0;
private ownedUpgrades = new Set<number>(); private heroLevels: number[] = [];
private scoreText!: Phaser.GameObjects.Text; private clickDamage = 0;
private upgradeRows: UpgradeRow[] = []; private dps = 0;
private monsterMaxHp = 0;
private monsterHp = 0;
private isBoss = false;
private bossRemainingMs = 0;
private bossTimerEvent?: Phaser.Time.TimerEvent;
private finished = false; private finished = false;
private levelText!: Phaser.GameObjects.Text;
private goldText!: Phaser.GameObjects.Text;
private dpsText!: Phaser.GameObjects.Text;
private monsterEmojiText!: Phaser.GameObjects.Text;
private monsterNameText!: Phaser.GameObjects.Text;
private hpBarBg!: Phaser.GameObjects.Rectangle;
private hpBarFill!: Phaser.GameObjects.Rectangle;
private hpValueText!: Phaser.GameObjects.Text;
private bossBarBg!: Phaser.GameObjects.Rectangle;
private bossBarFill!: Phaser.GameObjects.Rectangle;
private flashCircle!: Phaser.GameObjects.Arc;
private heroRows: { bg: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text }[] = [];
constructor() { constructor() {
super("ClickerScene"); super("ClickerScene");
} }
@@ -38,95 +70,243 @@ export class ClickerScene extends Phaser.Scene {
this.onProgress = this.registry.get("onProgress"); this.onProgress = this.registry.get("onProgress");
const initialState = this.registry.get("initialState") as ClickerProgress | undefined; const initialState = this.registry.get("initialState") as ClickerProgress | undefined;
const validUpgradeIndices = (initialState?.ownedUpgradeIndices ?? []).filter( this.level = Math.max(1, initialState?.level ?? 1);
(i) => i >= 0 && i < this.definition.upgrades.length, this.gold = initialState?.gold ?? 0;
); this.heroLevels = this.definition.heroes.map((_, i) => initialState?.heroLevels[i] ?? 0);
this.score = initialState?.score ?? 0;
this.pointsPerClick = initialState?.pointsPerClick ?? this.definition.startingPointsPerClick;
this.pointsPerSecond = initialState?.pointsPerSecond ?? 0;
this.ownedUpgrades = new Set(validUpgradeIndices);
this.upgradeRows = [];
this.finished = false; this.finished = false;
this.cameras.main.setBackgroundColor(COLORS.bg); this.cameras.main.setBackgroundColor(COLORS.bg);
this.scoreText = this.add this.levelText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#a3a8c6" });
.text(480, 30, "", { fontFamily: "sans-serif", fontSize: "24px", color: "#eef0fb", align: "center" }) this.goldText = this.add
.setOrigin(0.5, 0); .text(940, 15, "", { fontFamily: "sans-serif", fontSize: "16px", color: "#fbbf24", fontStyle: "bold" })
.setOrigin(1, 0);
this.dpsText = this.add
.text(940, 38, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#a3a8c6" })
.setOrigin(1, 0);
const target = this.add.circle(480, 200, 90, COLORS.target).setInteractive({ useHandCursor: true }); this.flashCircle = this.add.circle(MONSTER_X, MONSTER_Y, 95, COLORS.flash, 0).setDepth(1);
const targetLabel = this.add
.text(480, 200, this.definition.targetLabel, { fontFamily: "sans-serif", fontSize: "20px", color: "#ffffff" }) this.monsterEmojiText = this.add
.text(MONSTER_X, MONSTER_Y, "", { fontSize: "130px" })
.setOrigin(0.5)
.setDepth(2)
.setInteractive({ useHandCursor: true });
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" })
.setOrigin(0.5); .setOrigin(0.5);
target.on("pointerdown", () => { this.hpBarBg = this.add.rectangle(MONSTER_X, HP_BAR_Y, HP_BAR_WIDTH, 18, COLORS.hpBarBg);
if (this.finished) return; 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.score += this.pointsPerClick; this.hpValueText = this.add
this.tweens.add({ targets: [target, targetLabel], scale: 0.92, duration: 60, yoyo: true }); .text(MONSTER_X, HP_BAR_Y, "", { fontFamily: "sans-serif", fontSize: "12px", color: "#eef0fb" })
this.refresh(); .setOrigin(0.5);
this.bossBarBg = this.add.rectangle(MONSTER_X, 283, HP_BAR_WIDTH, 8, COLORS.bossTimerBg).setVisible(false);
this.bossBarFill = this.add
.rectangle(MONSTER_X - HP_BAR_WIDTH / 2, 283, HP_BAR_WIDTH, 8, COLORS.bossTimerFill)
.setOrigin(0, 0.5)
.setVisible(false);
this.definition.heroes.forEach((_, index) => {
const y = HEROES_START_Y + index * HERO_ROW_GAP;
const bg = this.add.rectangle(480, y, 780, 34, COLORS.heroRow).setStrokeStyle(1, 0x4f46e5).setInteractive({
useHandCursor: true,
});
const label = this.add
.text(480, y, "", { fontFamily: "sans-serif", fontSize: "14px", color: "#eef0fb" })
.setOrigin(0.5);
bg.on("pointerdown", () => this.buyHero(index));
this.heroRows.push({ bg, label });
}); });
this.definition.upgrades.forEach((upgrade, index) => { this.recomputeStats();
const y = 340 + index * 54; this.refreshHud();
const bg = this.add this.refreshHeroPanel();
.rectangle(480, y, 700, 44, COLORS.row) this.startLevel();
.setStrokeStyle(1, 0x4f46e5)
.setInteractive({ useHandCursor: true });
const label = this.add
.text(480, y, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" })
.setOrigin(0.5);
bg.on("pointerdown", () => this.buyUpgrade(index));
this.upgradeRows.push({ bg, label, upgrade, index });
});
this.time.addEvent({ this.time.addEvent({
delay: 1000, delay: 1000,
loop: true, loop: true,
callback: () => { callback: () => {
if (this.finished) return; if (!this.finished && this.dps > 0) this.damage(this.dps, false);
this.score += this.pointsPerSecond;
this.refresh();
}, },
}); });
this.refresh();
} }
private buyUpgrade(index: number) { private recomputeStats() {
if (this.finished || this.ownedUpgrades.has(index)) return; this.clickDamage = this.definition.startingClickDamage;
const upgrade = this.definition.upgrades[index]; this.dps = 0;
if (this.score < upgrade.cost) return; this.definition.heroes.forEach((hero, i) => {
this.score -= upgrade.cost; const lvl = this.heroLevels[i] ?? 0;
this.pointsPerClick += upgrade.pointsPerClickBonus; this.clickDamage += lvl * hero.clickDamageBonus;
this.pointsPerSecond += upgrade.pointsPerSecondBonus; this.dps += lvl * hero.dpsBonus;
this.ownedUpgrades.add(index); });
this.refresh();
} }
private refresh() { private startLevel() {
this.onProgress?.({ this.bossTimerEvent?.remove();
score: this.score, this.isBoss = isBossLevel(this.level);
pointsPerClick: this.pointsPerClick, this.monsterMaxHp = monsterHpForLevel(this.level, this.definition);
pointsPerSecond: this.pointsPerSecond, this.monsterHp = this.monsterMaxHp;
ownedUpgradeIndices: Array.from(this.ownedUpgrades),
const emoji = this.isBoss
? this.definition.bossEmoji
: this.definition.monsterEmojis[(this.level - 1) % this.definition.monsterEmojis.length];
this.monsterEmojiText.setText(emoji).setFontSize(this.isBoss ? 160 : 130);
this.monsterNameText.setText(this.isBoss ? `БОСС — уровень ${this.level}` : `Уровень ${this.level}`);
if (this.isBoss) {
this.bossRemainingMs = BOSS_TIME_LIMIT_MS;
this.bossBarBg.setVisible(true);
this.bossBarFill.setVisible(true);
this.updateBossBar();
this.bossTimerEvent = this.time.addEvent({
delay: 100,
loop: true,
callback: () => {
if (this.finished) return;
this.bossRemainingMs -= 100;
if (this.bossRemainingMs <= 0) {
this.monsterHp = this.monsterMaxHp;
this.bossRemainingMs = BOSS_TIME_LIMIT_MS;
this.updateHpBar();
}
this.updateBossBar();
},
});
} else {
this.bossBarBg.setVisible(false);
this.bossBarFill.setVisible(false);
}
this.updateHpBar();
this.refreshHud();
this.reportProgress();
}
private updateHpBar() {
const pct = Math.max(0, this.monsterHp / this.monsterMaxHp);
this.hpBarFill.setScale(pct, 1);
const color = pct > 0.5 ? COLORS.hpHigh : pct > 0.2 ? COLORS.hpMid : COLORS.hpLow;
this.hpBarFill.setFillStyle(color);
this.hpValueText.setText(`${Math.ceil(this.monsterHp)} / ${this.monsterMaxHp}`);
}
private updateBossBar() {
const pct = Math.max(0, this.bossRemainingMs / BOSS_TIME_LIMIT_MS);
this.bossBarFill.setScale(pct, 1);
}
private refreshHud() {
this.levelText.setText(`${this.definition.theme} — уровень ${this.level}/${this.definition.targetLevel}`);
this.goldText.setText(`💰 ${Math.floor(this.gold)}`);
this.dpsText.setText(`⚔ ${this.clickDamage}/клик • ${this.dps}/сек`);
}
private refreshHeroPanel() {
this.heroRows.forEach(({ bg, label }, index) => {
const hero = this.definition.heroes[index];
const lvl = this.heroLevels[index] ?? 0;
const cost = heroCostAtLevel(hero, lvl);
const affordable = this.gold >= cost;
label.setText(`${hero.name} • ур.${lvl} → ${cost}💰`);
bg.setFillStyle(affordable ? COLORS.heroRow : COLORS.heroRowUnaffordable);
}); });
}
this.scoreText.setText( private handleClick(pointer: Phaser.Input.Pointer) {
`${Math.floor(this.score)} / ${this.definition.targetScore}\n+${this.pointsPerClick}/клик +${this.pointsPerSecond}/сек`, if (this.finished) return;
); this.damage(this.clickDamage, true, pointer.x, pointer.y);
}
this.upgradeRows.forEach(({ bg, label, upgrade, index }) => { private damage(amount: number, fromClick: boolean, x = MONSTER_X, y = MONSTER_Y) {
const owned = this.ownedUpgrades.has(index); if (this.finished || amount <= 0) return;
const affordable = this.score >= upgrade.cost; this.monsterHp = Math.max(0, this.monsterHp - amount);
label.setText(owned ? `${upgrade.name} — куплено` : `${upgrade.name} — ${upgrade.cost}`); this.updateHpBar();
bg.setFillStyle(owned ? COLORS.rowOwned : affordable ? COLORS.row : COLORS.rowDisabled);
bg.disableInteractive();
if (!owned && affordable) bg.setInteractive({ useHandCursor: true });
});
if (!this.finished && this.score >= this.definition.targetScore) { if (fromClick) {
this.finished = true; this.flashCircle.setAlpha(0.45);
this.onFinish?.({ won: true, score: Math.floor(this.score) }); this.tweens.add({ targets: this.flashCircle, alpha: 0, duration: 150 });
this.cameras.main.shake(80, 0.004);
this.tweens.add({ targets: this.monsterEmojiText, scale: 0.85, duration: 70, yoyo: true });
const dmgText = this.add
.text(x, y - 40, `-${Math.round(amount)}`, {
fontFamily: "sans-serif",
fontSize: "18px",
color: "#f87171",
fontStyle: "bold",
})
.setOrigin(0.5)
.setDepth(3);
this.tweens.add({
targets: dmgText,
y: y - 90,
alpha: 0,
duration: 500,
onComplete: () => dmgText.destroy(),
});
}
if (this.monsterHp <= 0) this.onMonsterDefeated();
}
private onMonsterDefeated() {
this.gold += goldForMonster(this.monsterMaxHp);
this.spawnCoinBurst();
this.refreshHud();
this.refreshHeroPanel();
if (this.level >= this.definition.targetLevel) {
this.finish(true, Math.floor(this.gold), `Подземелье пройдено! Собрано золота: ${Math.floor(this.gold)}.`);
return;
}
this.level += 1;
this.startLevel();
}
private spawnCoinBurst() {
for (let i = 0; i < 6; i++) {
const coin = this.add.circle(MONSTER_X, MONSTER_Y, 6, COLORS.coin).setDepth(3);
const angle = (Math.PI * 2 * i) / 6 + Math.random() * 0.4;
const dist = 60 + Math.random() * 30;
this.tweens.add({
targets: coin,
x: MONSTER_X + Math.cos(angle) * dist,
y: MONSTER_Y + Math.sin(angle) * dist - 20,
alpha: 0,
duration: 500,
onComplete: () => coin.destroy(),
});
} }
} }
private buyHero(index: number) {
if (this.finished) return;
const hero = this.definition.heroes[index];
const lvl = this.heroLevels[index] ?? 0;
const cost = heroCostAtLevel(hero, lvl);
if (this.gold < cost) return;
this.gold -= cost;
this.heroLevels[index] = lvl + 1;
this.recomputeStats();
this.refreshHud();
this.refreshHeroPanel();
this.reportProgress();
}
private reportProgress() {
this.onProgress?.({ level: this.level, gold: this.gold, heroLevels: [...this.heroLevels] });
}
private finish(won: boolean, score: number, message: string) {
this.finished = true;
this.bossTimerEvent?.remove();
this.onFinish?.({ won, score, message });
}
} }
+2 -2
View File
@@ -13,7 +13,7 @@ export const TEMPLATE_LABELS: Record<TemplateType, string> = {
export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = { export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = {
quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.", quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.",
clicker: "Кликай, зарабатывай очки, покупай улучшения.", clicker: "Бей монстров, качай героев, проходи уровни и боссов — RPG-кликер.",
maze: "Собери предметы и дойди до выхода по лабиринту.", maze: "Собери предметы и дойди до выхода по лабиринту.",
}; };
@@ -55,7 +55,7 @@ export interface GameResult {
} }
export type { QuizDefinition, QuizQuestion, QuizProgress } from "./quiz"; export type { QuizDefinition, QuizQuestion, QuizProgress } from "./quiz";
export type { ClickerDefinition, ClickerUpgrade, ClickerProgress } from "./clicker"; export type { ClickerDefinition, ClickerHero, ClickerProgress } from "./clicker";
export type { MazeDefinition, MazeProgress } from "./maze"; export type { MazeDefinition, MazeProgress } from "./maze";
import type { QuizDefinition, QuizProgress } from "./quiz"; import type { QuizDefinition, QuizProgress } from "./quiz";