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:
1 parent
ff1b2ff6f9
commit
f5833a1988
5 files changed
+416
-152
No files matched your search
@@ -3,12 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Trash2, Save } from "lucide-react";
|
||||
import {
|
||||
clickerDefinitionSchema,
|
||||
DEFAULT_CLICKER_DEFINITION,
|
||||
type ClickerDefinition,
|
||||
type ClickerUpgrade,
|
||||
} from "@/lib/games/clicker";
|
||||
import { clickerDefinitionSchema, DEFAULT_CLICKER_DEFINITION, type ClickerDefinition, type ClickerHero } from "@/lib/games/clicker";
|
||||
|
||||
export function ClickerBuilder({
|
||||
mode,
|
||||
@@ -26,32 +21,47 @@ export function ClickerBuilder({
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState(initialTitle ?? "");
|
||||
const [description, setDescription] = useState(initialDescription ?? "");
|
||||
const [targetLabel, setTargetLabel] = useState(initialDefinition?.targetLabel ?? DEFAULT_CLICKER_DEFINITION.targetLabel);
|
||||
const [targetScore, setTargetScore] = useState(initialDefinition?.targetScore ?? DEFAULT_CLICKER_DEFINITION.targetScore);
|
||||
const [startingPointsPerClick, setStartingPointsPerClick] = useState(
|
||||
initialDefinition?.startingPointsPerClick ?? DEFAULT_CLICKER_DEFINITION.startingPointsPerClick,
|
||||
const [theme, setTheme] = useState(initialDefinition?.theme ?? DEFAULT_CLICKER_DEFINITION.theme);
|
||||
const [targetLevel, setTargetLevel] = useState(initialDefinition?.targetLevel ?? DEFAULT_CLICKER_DEFINITION.targetLevel);
|
||||
const [startingClickDamage, setStartingClickDamage] = useState(
|
||||
initialDefinition?.startingClickDamage ?? DEFAULT_CLICKER_DEFINITION.startingClickDamage,
|
||||
);
|
||||
const [upgrades, setUpgrades] = useState<ClickerUpgrade[]>(
|
||||
initialDefinition?.upgrades ?? DEFAULT_CLICKER_DEFINITION.upgrades,
|
||||
const [baseMonsterHp, setBaseMonsterHp] = useState(initialDefinition?.baseMonsterHp ?? DEFAULT_CLICKER_DEFINITION.baseMonsterHp);
|
||||
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 [saving, setSaving] = useState(false);
|
||||
|
||||
function updateUpgrade(index: number, patch: Partial<ClickerUpgrade>) {
|
||||
setUpgrades((ups) => ups.map((u, i) => (i === index ? { ...u, ...patch } : u)));
|
||||
function updateHero(index: number, patch: Partial<ClickerHero>) {
|
||||
setHeroes((hs) => hs.map((h, i) => (i === index ? { ...h, ...patch } : h)));
|
||||
}
|
||||
|
||||
function addUpgrade() {
|
||||
setUpgrades((ups) => [...ups, { name: "", cost: 10, pointsPerClickBonus: 0, pointsPerSecondBonus: 0 }]);
|
||||
function addHero() {
|
||||
setHeroes((hs) => [...hs, { name: "", baseCost: 10, clickDamageBonus: 0, dpsBonus: 0 }]);
|
||||
}
|
||||
|
||||
function removeUpgrade(index: number) {
|
||||
setUpgrades((ups) => ups.filter((_, i) => i !== index));
|
||||
function removeHero(index: number) {
|
||||
setHeroes((hs) => hs.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
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);
|
||||
if (!parsed.success) {
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
value={targetLabel}
|
||||
onChange={(e) => setTargetLabel(e.target.value)}
|
||||
value={theme}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="number"
|
||||
min={10}
|
||||
value={targetScore}
|
||||
onChange={(e) => setTargetScore(Number(e.target.value))}
|
||||
min={5}
|
||||
max={200}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
type="number"
|
||||
min={1}
|
||||
value={startingPointsPerClick}
|
||||
onChange={(e) => setStartingPointsPerClick(Number(e.target.value))}
|
||||
value={startingClickDamage}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
</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 className="card flex flex-col gap-3 p-4">
|
||||
<span className="text-sm font-semibold text-text-muted">Улучшения</span>
|
||||
{upgrades.map((upgrade, index) => (
|
||||
<span className="text-sm font-semibold text-text-muted">Герои</span>
|
||||
{heroes.map((hero, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-2">
|
||||
<input
|
||||
value={upgrade.name}
|
||||
onChange={(e) => updateUpgrade(index, { name: e.target.value })}
|
||||
value={hero.name}
|
||||
onChange={(e) => updateHero(index, { name: e.target.value })}
|
||||
placeholder="Название"
|
||||
className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={upgrade.cost}
|
||||
onChange={(e) => updateUpgrade(index, { cost: Number(e.target.value) })}
|
||||
title="Цена"
|
||||
value={hero.baseCost}
|
||||
onChange={(e) => updateHero(index, { baseCost: Number(e.target.value) })}
|
||||
title="Базовая цена"
|
||||
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={upgrade.pointsPerClickBonus}
|
||||
onChange={(e) => updateUpgrade(index, { pointsPerClickBonus: Number(e.target.value) })}
|
||||
title="Бонус за клик"
|
||||
value={hero.clickDamageBonus}
|
||||
onChange={(e) => updateHero(index, { clickDamageBonus: Number(e.target.value) })}
|
||||
title="Урон за клик за уровень"
|
||||
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={upgrade.pointsPerSecondBonus}
|
||||
onChange={(e) => updateUpgrade(index, { pointsPerSecondBonus: Number(e.target.value) })}
|
||||
title="Бонус в секунду"
|
||||
value={hero.dpsBonus}
|
||||
onChange={(e) => updateHero(index, { dpsBonus: Number(e.target.value) })}
|
||||
title="Урон в секунду за уровень"
|
||||
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} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-4 text-xs text-text-faint">
|
||||
<span>Цена</span>
|
||||
<span className="ml-auto">За клик</span>
|
||||
<span>За сек</span>
|
||||
<span className="ml-auto">Урон/клик</span>
|
||||
<span>Урон/сек</span>
|
||||
</div>
|
||||
<button onClick={addUpgrade} className="btn btn-ghost self-start">
|
||||
<button onClick={addHero} className="btn btn-ghost self-start">
|
||||
<Plus size={15} />
|
||||
Добавить улучшение
|
||||
Добавить героя
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { templateSchemas, type TemplateType, type GameDefinition } from "@/lib/g
|
||||
const SYSTEM_PROMPTS: Record<TemplateType, string> = {
|
||||
quiz: "Ты создаёшь вопросы для викторины в формате «Кто хочет стать миллионером?» — 15-уровневая денежная лестница с нарастающей сложностью. Каждый вопрос должен использовать ТОЛЬКО реальные, проверяемые факты (даты, столицы, авторов, научные факты и т.п.) — ничего не выдумывай и не искажай. У каждого вопроса ровно 4 варианта ответа и один правильный. Поле band (1-5) — сложность: 1 — очень лёгкий вопрос, 5 — очень сложный. Обязательно должен быть хотя бы один вопрос на каждый band от 1 до 5. Категория (category) должна отражать тему вопроса.",
|
||||
clicker:
|
||||
"Ты создаёшь простую кликер-игру (idle game) для игрового движка. Придумай тему, набор улучшений и целевой счёт по описанию пользователя. Улучшения должны быть сбалансированы: чем дороже — тем сильнее эффект.",
|
||||
"Ты создаёшь RPG-кликер в духе Clicker Heroes для игрового движка: игрок бьёт монстра по клику, монстр имеет HP, каждые 5 уровней — босс с таймером. Придумай тему (theme), название босса через bossEmoji (один эмодзи), пул из 5-10 эмодзи обычных монстров (monsterEmojis, каждый — один эмодзи-символ) и 4-8 героев (heroes) по описанию пользователя — герои покупаются многократно (уровни), у каждого либо бонус к урону за клик (clickDamageBonus), либо к урону в секунду (dpsBonus), либо оба понемногу. Более поздние герои в списке должны быть дороже (baseCost) и сильнее.",
|
||||
maze: "Ты создаёшь лабиринт для простого игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Ровно один символ S (старт), хотя бы один E (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.",
|
||||
};
|
||||
|
||||
|
||||
+60
-20
@@ -1,37 +1,77 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const clickerUpgradeSchema = z.object({
|
||||
name: z.string().min(1).max(60),
|
||||
cost: z.number().int().min(1),
|
||||
pointsPerClickBonus: z.number().min(0).default(0),
|
||||
pointsPerSecondBonus: z.number().min(0).default(0),
|
||||
/** Per-level monster HP growth. */
|
||||
export const HP_GROWTH = 1.18;
|
||||
/** Gold reward per point of monster max HP. */
|
||||
export const GOLD_PER_HP = 0.5;
|
||||
/** 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({
|
||||
targetLabel: z.string().min(1).max(60).default("Клик!"),
|
||||
targetScore: z.number().int().min(10),
|
||||
startingPointsPerClick: z.number().min(1).default(1),
|
||||
upgrades: z.array(clickerUpgradeSchema).max(20).default([]),
|
||||
theme: z.string().min(1).max(60).default("Подземелье"),
|
||||
targetLevel: z.number().int().min(5).max(200).default(30),
|
||||
startingClickDamage: z.number().min(1).default(5),
|
||||
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 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 = {
|
||||
targetLabel: "Монета",
|
||||
targetScore: 100,
|
||||
startingPointsPerClick: 1,
|
||||
upgrades: [
|
||||
{ name: "Крепкий клик", cost: 20, pointsPerClickBonus: 1, pointsPerSecondBonus: 0 },
|
||||
{ name: "Автокликер", cost: 50, pointsPerClickBonus: 0, pointsPerSecondBonus: 1 },
|
||||
theme: "Тёмное подземелье",
|
||||
targetLevel: 30,
|
||||
startingClickDamage: 5,
|
||||
baseMonsterHp: 20,
|
||||
monsterEmojis: ["👹", "👺", "💀", "🧟", "🐗", "🦂", "🐍", "🦇", "👻", "🕷️"],
|
||||
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({
|
||||
score: z.number().min(0),
|
||||
pointsPerClick: z.number().min(0),
|
||||
pointsPerSecond: z.number().min(0),
|
||||
ownedUpgradeIndices: z.array(z.number().int().min(0)).default([]),
|
||||
level: z.number().int().min(1),
|
||||
gold: z.number().min(0),
|
||||
heroLevels: z.array(z.number().int().min(0)).default([]),
|
||||
});
|
||||
|
||||
export type ClickerProgress = z.infer<typeof clickerProgressSchema>;
|
||||
@@ -1,33 +1,65 @@
|
||||
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 = {
|
||||
bg: 0x14162a,
|
||||
target: 0x4f46e5,
|
||||
row: 0x23264a,
|
||||
rowOwned: 0x0d3d38,
|
||||
rowDisabled: 0x1a1c33,
|
||||
hpBarBg: 0x292d4a,
|
||||
hpHigh: 0x0d9488,
|
||||
hpMid: 0xd97706,
|
||||
hpLow: 0xdc2626,
|
||||
bossTimerBg: 0x292d4a,
|
||||
bossTimerFill: 0xdc2626,
|
||||
heroRow: 0x23264a,
|
||||
heroRowUnaffordable: 0x181a30,
|
||||
coin: 0xfbbf24,
|
||||
flash: 0xdc2626,
|
||||
};
|
||||
|
||||
interface UpgradeRow {
|
||||
bg: Phaser.GameObjects.Rectangle;
|
||||
label: Phaser.GameObjects.Text;
|
||||
upgrade: ClickerUpgrade;
|
||||
index: number;
|
||||
}
|
||||
const MONSTER_X = 480;
|
||||
const MONSTER_Y = 155;
|
||||
const HP_BAR_Y = 258;
|
||||
const HP_BAR_WIDTH = 500;
|
||||
const HEROES_START_Y = 330;
|
||||
const HERO_ROW_GAP = 42;
|
||||
|
||||
export class ClickerScene extends Phaser.Scene {
|
||||
private definition!: ClickerDefinition;
|
||||
private onFinish?: (result: GameResult) => void;
|
||||
private onProgress?: (state: ClickerProgress) => void;
|
||||
private score = 0;
|
||||
private pointsPerClick = 1;
|
||||
private pointsPerSecond = 0;
|
||||
private ownedUpgrades = new Set<number>();
|
||||
private scoreText!: Phaser.GameObjects.Text;
|
||||
private upgradeRows: UpgradeRow[] = [];
|
||||
|
||||
private level = 1;
|
||||
private gold = 0;
|
||||
private heroLevels: number[] = [];
|
||||
private clickDamage = 0;
|
||||
private dps = 0;
|
||||
|
||||
private monsterMaxHp = 0;
|
||||
private monsterHp = 0;
|
||||
private isBoss = false;
|
||||
private bossRemainingMs = 0;
|
||||
private bossTimerEvent?: Phaser.Time.TimerEvent;
|
||||
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() {
|
||||
super("ClickerScene");
|
||||
}
|
||||
@@ -38,95 +70,243 @@ export class ClickerScene extends Phaser.Scene {
|
||||
this.onProgress = this.registry.get("onProgress");
|
||||
|
||||
const initialState = this.registry.get("initialState") as ClickerProgress | undefined;
|
||||
const validUpgradeIndices = (initialState?.ownedUpgradeIndices ?? []).filter(
|
||||
(i) => i >= 0 && i < this.definition.upgrades.length,
|
||||
);
|
||||
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.level = Math.max(1, initialState?.level ?? 1);
|
||||
this.gold = initialState?.gold ?? 0;
|
||||
this.heroLevels = this.definition.heroes.map((_, i) => initialState?.heroLevels[i] ?? 0);
|
||||
this.finished = false;
|
||||
|
||||
this.cameras.main.setBackgroundColor(COLORS.bg);
|
||||
|
||||
this.scoreText = this.add
|
||||
.text(480, 30, "", { fontFamily: "sans-serif", fontSize: "24px", color: "#eef0fb", align: "center" })
|
||||
.setOrigin(0.5, 0);
|
||||
this.levelText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#a3a8c6" });
|
||||
this.goldText = this.add
|
||||
.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 });
|
||||
const targetLabel = this.add
|
||||
.text(480, 200, this.definition.targetLabel, { fontFamily: "sans-serif", fontSize: "20px", color: "#ffffff" })
|
||||
.setOrigin(0.5);
|
||||
this.flashCircle = this.add.circle(MONSTER_X, MONSTER_Y, 95, COLORS.flash, 0).setDepth(1);
|
||||
|
||||
target.on("pointerdown", () => {
|
||||
if (this.finished) return;
|
||||
this.score += this.pointsPerClick;
|
||||
this.tweens.add({ targets: [target, targetLabel], scale: 0.92, duration: 60, yoyo: true });
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
this.definition.upgrades.forEach((upgrade, index) => {
|
||||
const y = 340 + index * 54;
|
||||
const bg = this.add
|
||||
.rectangle(480, y, 700, 44, COLORS.row)
|
||||
.setStrokeStyle(1, 0x4f46e5)
|
||||
this.monsterEmojiText = this.add
|
||||
.text(MONSTER_X, MONSTER_Y, "", { fontSize: "130px" })
|
||||
.setOrigin(0.5)
|
||||
.setDepth(2)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const label = this.add
|
||||
.text(480, y, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" })
|
||||
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);
|
||||
bg.on("pointerdown", () => this.buyUpgrade(index));
|
||||
this.upgradeRows.push({ bg, label, upgrade, index });
|
||||
|
||||
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" })
|
||||
.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.recomputeStats();
|
||||
this.refreshHud();
|
||||
this.refreshHeroPanel();
|
||||
this.startLevel();
|
||||
|
||||
this.time.addEvent({
|
||||
delay: 1000,
|
||||
loop: true,
|
||||
callback: () => {
|
||||
if (this.finished) return;
|
||||
this.score += this.pointsPerSecond;
|
||||
this.refresh();
|
||||
if (!this.finished && this.dps > 0) this.damage(this.dps, false);
|
||||
},
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private buyUpgrade(index: number) {
|
||||
if (this.finished || this.ownedUpgrades.has(index)) return;
|
||||
const upgrade = this.definition.upgrades[index];
|
||||
if (this.score < upgrade.cost) return;
|
||||
this.score -= upgrade.cost;
|
||||
this.pointsPerClick += upgrade.pointsPerClickBonus;
|
||||
this.pointsPerSecond += upgrade.pointsPerSecondBonus;
|
||||
this.ownedUpgrades.add(index);
|
||||
this.refresh();
|
||||
private recomputeStats() {
|
||||
this.clickDamage = this.definition.startingClickDamage;
|
||||
this.dps = 0;
|
||||
this.definition.heroes.forEach((hero, i) => {
|
||||
const lvl = this.heroLevels[i] ?? 0;
|
||||
this.clickDamage += lvl * hero.clickDamageBonus;
|
||||
this.dps += lvl * hero.dpsBonus;
|
||||
});
|
||||
}
|
||||
|
||||
private refresh() {
|
||||
this.onProgress?.({
|
||||
score: this.score,
|
||||
pointsPerClick: this.pointsPerClick,
|
||||
pointsPerSecond: this.pointsPerSecond,
|
||||
ownedUpgradeIndices: Array.from(this.ownedUpgrades),
|
||||
private startLevel() {
|
||||
this.bossTimerEvent?.remove();
|
||||
this.isBoss = isBossLevel(this.level);
|
||||
this.monsterMaxHp = monsterHpForLevel(this.level, this.definition);
|
||||
this.monsterHp = this.monsterMaxHp;
|
||||
|
||||
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.scoreText.setText(
|
||||
`${Math.floor(this.score)} / ${this.definition.targetScore}\n+${this.pointsPerClick}/клик +${this.pointsPerSecond}/сек`,
|
||||
);
|
||||
this.updateHpBar();
|
||||
this.refreshHud();
|
||||
this.reportProgress();
|
||||
}
|
||||
|
||||
this.upgradeRows.forEach(({ bg, label, upgrade, index }) => {
|
||||
const owned = this.ownedUpgrades.has(index);
|
||||
const affordable = this.score >= upgrade.cost;
|
||||
label.setText(owned ? `${upgrade.name} — куплено` : `${upgrade.name} — ${upgrade.cost}`);
|
||||
bg.setFillStyle(owned ? COLORS.rowOwned : affordable ? COLORS.row : COLORS.rowDisabled);
|
||||
bg.disableInteractive();
|
||||
if (!owned && affordable) bg.setInteractive({ useHandCursor: true });
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.finished && this.score >= this.definition.targetScore) {
|
||||
private handleClick(pointer: Phaser.Input.Pointer) {
|
||||
if (this.finished) return;
|
||||
this.damage(this.clickDamage, true, pointer.x, pointer.y);
|
||||
}
|
||||
|
||||
private damage(amount: number, fromClick: boolean, x = MONSTER_X, y = MONSTER_Y) {
|
||||
if (this.finished || amount <= 0) return;
|
||||
this.monsterHp = Math.max(0, this.monsterHp - amount);
|
||||
this.updateHpBar();
|
||||
|
||||
if (fromClick) {
|
||||
this.flashCircle.setAlpha(0.45);
|
||||
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.onFinish?.({ won: true, score: Math.floor(this.score) });
|
||||
}
|
||||
this.bossTimerEvent?.remove();
|
||||
this.onFinish?.({ won, score, message });
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export const TEMPLATE_LABELS: Record<TemplateType, string> = {
|
||||
|
||||
export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = {
|
||||
quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.",
|
||||
clicker: "Кликай, зарабатывай очки, покупай улучшения.",
|
||||
clicker: "Бей монстров, качай героев, проходи уровни и боссов — RPG-кликер.",
|
||||
maze: "Собери предметы и дойди до выхода по лабиринту.",
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface GameResult {
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
import type { QuizDefinition, QuizProgress } from "./quiz";
|
||||
|
||||
Reference in new issue
Block a user