diff --git a/src/components/builders/clicker-builder.tsx b/src/components/builders/clicker-builder.tsx index b1e2853..b86e9a8 100644 --- a/src/components/builders/clicker-builder.tsx +++ b/src/components/builders/clicker-builder.tsx @@ -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( - 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(initialDefinition?.heroes ?? DEFAULT_CLICKER_DEFINITION.heroes); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); - function updateUpgrade(index: number, patch: Partial) { - setUpgrades((ups) => ups.map((u, i) => (i === index ? { ...u, ...patch } : u))); + function updateHero(index: number, patch: Partial) { + 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" /> -
+ +
+
+ + + +

+ Каждый 5-й уровень — босс (HP ×4, таймер на удар). Герои покупаются многократно — каждый следующий уровень + дороже и сильнее. +

- Улучшения - {upgrades.map((upgrade, index) => ( + Герои + {heroes.map((hero, index) => (
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" /> 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" /> 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" /> 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" /> -
))}
Цена - За клик - За сек + Урон/клик + Урон/сек
-
diff --git a/src/lib/ai/generate.ts b/src/lib/ai/generate.ts index 1abddb5..1b59b57 100644 --- a/src/lib/ai/generate.ts +++ b/src/lib/ai/generate.ts @@ -6,7 +6,7 @@ import { templateSchemas, type TemplateType, type GameDefinition } from "@/lib/g const SYSTEM_PROMPTS: Record = { 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 (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.", }; diff --git a/src/lib/games/clicker.ts b/src/lib/games/clicker.ts index 2b8e2eb..3d2194c 100644 --- a/src/lib/games/clicker.ts +++ b/src/lib/games/clicker.ts @@ -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; +export type ClickerHero = z.infer; export type ClickerDefinition = z.infer; +export function isBossLevel(level: number): boolean { + return level % BOSS_EVERY === 0; +} + +export function monsterHpForLevel(level: number, def: Pick): 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; diff --git a/src/lib/games/scenes/clicker-scene.ts b/src/lib/games/scenes/clicker-scene.ts index bf9050f..2eb8dd0 100644 --- a/src/lib/games/scenes/clicker-scene.ts +++ b/src/lib/games/scenes/clicker-scene.ts @@ -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(); - 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" }) + this.flashCircle = this.add.circle(MONSTER_X, MONSTER_Y, 95, COLORS.flash, 0).setDepth(1); + + 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); - 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.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.definition.upgrades.forEach((upgrade, index) => { - const y = 340 + index * 54; - const bg = this.add - .rectangle(480, y, 700, 44, COLORS.row) - .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.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.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( - `${Math.floor(this.score)} / ${this.definition.targetScore}\n+${this.pointsPerClick}/клик +${this.pointsPerSecond}/сек`, - ); + private handleClick(pointer: Phaser.Input.Pointer) { + if (this.finished) return; + this.damage(this.clickDamage, true, pointer.x, pointer.y); + } - 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 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 (!this.finished && this.score >= this.definition.targetScore) { - this.finished = true; - this.onFinish?.({ won: true, score: Math.floor(this.score) }); + 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.bossTimerEvent?.remove(); + this.onFinish?.({ won, score, message }); + } } diff --git a/src/lib/games/types.ts b/src/lib/games/types.ts index 85f0386..78be01d 100644 --- a/src/lib/games/types.ts +++ b/src/lib/games/types.ts @@ -13,7 +13,7 @@ export const TEMPLATE_LABELS: Record = { export const TEMPLATE_DESCRIPTIONS: Record = { 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";