Merge pull request #4 from VovaManul/feature/new-game-plus
Merged feature/new-game-plus into main via gh.
@@ -5,6 +5,10 @@ Top-down рогалик: процедурный данжен из комнат,
|
|||||||
наклонная камера, пол лежит плашмя, персонажи — вертикальные спрайты-биллборды
|
наклонная камера, пол лежит плашмя, персонажи — вертикальные спрайты-биллборды
|
||||||
(как в Isaac). Сборка — **Bun**.
|
(как в Isaac). Сборка — **Bun**.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./docs/assets/binding_fignyashka.png" alt="Биндим Фигняшку" width="720" />
|
||||||
|
</p>
|
||||||
|
|
||||||
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
|
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
|
||||||
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
|
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
|
||||||
Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — PNG-ассеты в
|
Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — PNG-ассеты в
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if (!result.success) {
|
|||||||
|
|
||||||
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
|
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
|
||||||
|
|
||||||
// Копируем картинки в dist/assets (если папка есть).
|
// Копируем картинки в dist/<asset-pack> (если папка есть).
|
||||||
try {
|
try {
|
||||||
await mkdir('./dist/assets', { recursive: true });
|
await mkdir('./dist/assets', { recursive: true });
|
||||||
await cp('./src/assets', './dist/assets', { recursive: true });
|
await cp('./src/assets', './dist/assets', { recursive: true });
|
||||||
@@ -29,4 +29,11 @@ try {
|
|||||||
// src/assets ещё нет — не страшно, рендер откатится на процедурную графику.
|
// src/assets ещё нет — не страшно, рендер откатится на процедурную графику.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir('./dist/assets-binding-2', { recursive: true });
|
||||||
|
await cp('./src/assets-binding-2', './dist/assets-binding-2', { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Второй пак ассетов опционален.
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Сборка готова → dist/ (открой dist/index.html)');
|
console.log('Сборка готова → dist/ (открой dist/index.html)');
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ const server = Bun.serve({
|
|||||||
if (url.pathname === '/' || url.pathname === '/index.html') {
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
||||||
return new Response(Bun.file('./index.html'));
|
return new Response(Bun.file('./index.html'));
|
||||||
}
|
}
|
||||||
// Картинки отдаём прямо из src/assets/ — положил PNG → сразу подхватился (без пересборки).
|
// Картинки отдаём прямо из src/<asset-pack>/ — положил PNG → сразу подхватился.
|
||||||
if (url.pathname.startsWith('/assets/')) {
|
if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/assets-binding-2/')) {
|
||||||
const asset = Bun.file('./src' + url.pathname);
|
const asset = Bun.file('./src' + url.pathname);
|
||||||
if (await asset.exists()) return new Response(asset);
|
if (await asset.exists()) return new Response(asset);
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ LevelRules ──► Game(rules) ──► RoomMap(rng, rules) // размер
|
|||||||
└──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)
|
└──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
В бесконечном спуске выбранный `rules` остаётся базовым пресетом забега, а
|
||||||
|
`Game` хранит отдельный активный снимок правил текущего этажа. Новая карта и
|
||||||
|
спавн врагов должны брать именно активные правила этажа, иначе данжен растёт, но
|
||||||
|
враги остаются с балансом первого этажа.
|
||||||
|
|
||||||
Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
|
Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
|
||||||
`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
|
`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
|
||||||
`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
|
`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
# Промпты для ассетов: «Биндинг 2.0»
|
||||||
|
|
||||||
|
Этот файл нужен, чтобы сгенерировать второй набор ассетов в стиле постера
|
||||||
|
`docs/assets/binding_fignyashka.png`, но в формате, который легко положить в игру
|
||||||
|
и при необходимости вырезать руками.
|
||||||
|
|
||||||
|
## Как пользоваться
|
||||||
|
|
||||||
|
1. В генераторе сначала загрузи постер `docs/assets/binding_fignyashka.png` как
|
||||||
|
style reference, если сервис это поддерживает.
|
||||||
|
2. Скопируй **базовый промпт** ниже в начало каждой генерации.
|
||||||
|
3. Ниже по файлу копируй промпт конкретного ассета.
|
||||||
|
4. Сохраняй результат ровно с указанным именем файла.
|
||||||
|
|
||||||
|
Если генератор плохо делает прозрачный фон, проси однотонный chroma key:
|
||||||
|
`pure #00ff00 background, no shadow, no glow touching the background`, затем
|
||||||
|
удаляй фон любым инструментом. Для игры лучше PNG с alpha.
|
||||||
|
|
||||||
|
## Базовый промпт
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use the uploaded poster "Биндим Фигняшку" as the style reference only.
|
||||||
|
|
||||||
|
Create one game asset for a top-down roguelike called "Биндинг 2.0".
|
||||||
|
Style: grotesque dark cartoon dungeon art, chunky hand-painted shapes, high contrast, juicy slime colors, thick uneven black-purple outlines, expressive silly-horror faces, grimy stone dungeon details, saturated neon accents, readable silhouette at small size.
|
||||||
|
|
||||||
|
Technical requirements:
|
||||||
|
- One isolated asset only, centered.
|
||||||
|
- Transparent PNG background with clean alpha.
|
||||||
|
- If transparent background is not possible: pure #00ff00 chroma key background, no gradients, no contact shadow, no glow touching the background.
|
||||||
|
- No text, no letters, no watermark, no UI frame, no mockup, no floor under the object, no baked shadow, unless the specific prompt asks for logo/menu background/floor/wall/shadow.
|
||||||
|
- Keep the whole object inside the canvas with 8-12% padding.
|
||||||
|
- Use the requested exact canvas size and filename.
|
||||||
|
- Front-facing billboard sprite for characters, slightly top-down camera feel, feet aligned near the bottom edge.
|
||||||
|
- Same lighting, palette, outline thickness, and detail density across all assets.
|
||||||
|
- Make it easy to cut out: crisp silhouette, transparent outside pixels, no loose dust outside the silhouette.
|
||||||
|
|
||||||
|
Negative prompt:
|
||||||
|
photorealistic, realistic gore, anime, flat vector icon, pixel art, low contrast, blurry edges, tiny unreadable details, background scene, multiple objects, text, signature, watermark, drop shadow, floor shadow, square UI border, cropped body, cut off feet
|
||||||
|
```
|
||||||
|
|
||||||
|
## Персонажи и враги
|
||||||
|
|
||||||
|
### `player-ranged.png` — игрок с дальним оружием, 192x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: player-ranged.png, canvas 192x256.
|
||||||
|
|
||||||
|
Small grotesque hero, huge uneven eyes, patched purple hood with little horns, anxious grin, compact body, holding a weird toy-like slime pistol or water gun pointed diagonally down-right. Blue/cyan weapon glow, tiny dungeon grime, funny horror expression. Full body, front-facing billboard sprite, feet at the bottom edge, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `player-melee.png` — игрок с ближним оружием, 192x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: player-melee.png, canvas 192x256.
|
||||||
|
|
||||||
|
Same hero as player-ranged, same face and purple hood, but holding a ridiculous melee weapon: toilet brush, rusty knife, or plunger club. Warmer red/orange accents, aggressive grin, ready to swing. Full body, front-facing billboard sprite, feet at the bottom edge, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `enemy-normal.png` — обычный враг, 192x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: enemy-normal.png, canvas 192x256.
|
||||||
|
|
||||||
|
Round lumpy dungeon monster, terracotta-pink skin, one lazy eye and one tiny eye, small teeth, stubby legs, goofy but hostile. Thick black-purple outline, slime spots, readable chunky silhouette. Full body, front-facing billboard sprite, feet at the bottom edge, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `enemy-fast.png` — быстрый враг, 160x213
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: enemy-fast.png, canvas 160x213.
|
||||||
|
|
||||||
|
Small twitchy fast monster, red bug-like gobbet, long thin legs, bulging eyes, frantic expression, motion-ready pose but not blurred. Bright red and magenta accents, sharp silhouette. Full body, front-facing billboard sprite, feet at the bottom edge, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `enemy-boss.png` — босс, 288x384
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: enemy-boss.png, canvas 288x384.
|
||||||
|
|
||||||
|
Huge grotesque boss head-body hybrid, swollen pink-brown flesh, mismatched giant eyes, crown made of junk metal, open screaming mouth, tiny arms with a plunger hammer. Dark dungeon grime, purple slime rim light, very readable massive silhouette. Full body, front-facing billboard sprite, base at bottom edge, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `enemy-charger.png` — рывковый враг, 192x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: enemy-charger.png, canvas 192x256.
|
||||||
|
|
||||||
|
Aggressive charger monster, red-orange flesh, low hunched body, one horn or metal spike on forehead, angry slit eyes, oversized jaw, pose leaning forward like it is about to rush. Thick outline, neon orange highlights. Full body, front-facing billboard sprite, feet at bottom edge, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `enemy-tank.png` — тяжёлый враг, 224x288
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: enemy-tank.png, canvas 224x288.
|
||||||
|
|
||||||
|
Heavy tank monster, bulky stone-and-flesh body, cracked skull plates, slow stupid face, tiny eyes, big square jaw, metal rivets embedded in skin. Dark brown, grey, bruised purple palette. Full body, front-facing billboard sprite, feet/base at bottom edge, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `enemy-shooter.png` — стрелок, 192x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: enemy-shooter.png, canvas 192x256.
|
||||||
|
|
||||||
|
Shooter monster, bluish sickly body, huge single watery eye, tiny nozzle mouth or fish-gun growth, cheeks full of glowing slime projectile. Cyan/green spit glow, nervous mean expression. Full body, front-facing billboard sprite, feet at bottom edge, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Окружение
|
||||||
|
|
||||||
|
### `floor.png` — бесшовный пол, 256x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: floor.png, canvas 256x256.
|
||||||
|
|
||||||
|
Seamless tileable dungeon floor texture, top-down stone slabs from the poster style, dark grey-brown stones, subtle purple grime, tiny cracks, small slime stains, no strong directional light, no objects, no text. Must tile perfectly on all edges.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `wall.png` — бесшовная стена, 256x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: wall.png, canvas 256x256.
|
||||||
|
|
||||||
|
Seamless tileable dungeon wall brick texture, cold blue-grey bricks, black-purple mortar, moss and slime in cracks, hand-painted chunky style, no perspective object, no text. Must tile perfectly on all edges.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `door-closed.png` — закрытая дверь, 192x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: door-closed.png, canvas 192x256.
|
||||||
|
|
||||||
|
Closed dungeon doorway sprite, arched stone frame, chunky wooden planks, heavy golden latch/bar, purple slime outline, front-facing vertical billboard object. Transparent background outside the door frame, no floor, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `door-open.png` — открытая дверь, 192x256
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: door-open.png, canvas 192x256.
|
||||||
|
|
||||||
|
Open dungeon doorway sprite, arched stone frame, dark black interior void, broken wooden bits, purple slime rim light, front-facing vertical billboard object. Transparent background outside the door frame, no floor, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `chest.png` — сундук, 192x192
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: chest.png, canvas 192x192.
|
||||||
|
|
||||||
|
Treasure chest object, red-brown wood, gold metal bands, one goofy eye-shaped lock, tiny slime drips, cute-horror style from the poster. Isolated object, three-quarter top-down object view, transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pickup.png` — универсальный пикап, 128x128
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: pickup.png, canvas 128x128.
|
||||||
|
|
||||||
|
Floating weapon pickup marker, glowing golden-purple diamond with slime sparkle, chunky outline, readable at small size. Isolated object, transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Снаряды и эффекты
|
||||||
|
|
||||||
|
### `tear.png` — слеза, 64x64
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: tear.png, canvas 64x64.
|
||||||
|
|
||||||
|
Single glowing blue tear projectile, chunky droplet shape, white highlight, cyan edge glow contained inside transparent canvas. No trail, no shadow, transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fireball.png` — огненный шар, 64x64
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: fireball.png, canvas 64x64.
|
||||||
|
|
||||||
|
Single grotesque fireball projectile, orange-red slime flame orb, white-yellow hot center, small black-purple outline, contained glow, transparent background, no trail.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `beam.png` — луч лазера, 128x128
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: beam.png, canvas 128x128.
|
||||||
|
|
||||||
|
Laser impact/beam blob sprite, bright white-cyan center with electric blue-purple rim, circular radial energy burst suitable for additive blending, transparent background, no hard square edges.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `muzzle.png` — вспышка выстрела, 128x128
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: muzzle.png, canvas 128x128.
|
||||||
|
|
||||||
|
Muzzle flash effect sprite, white-hot center, yellow-orange starburst, purple edge sparks, soft radial alpha, designed for additive blending. Transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `spark.png` — искра попадания, 128x128
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: spark.png, canvas 128x128.
|
||||||
|
|
||||||
|
Hit spark effect sprite, jagged white-yellow impact burst with tiny purple fragments, clear center, fades to transparent edges, designed for additive blending. Transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `puff.png` — дымок смерти, 128x128
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: puff.png, canvas 128x128.
|
||||||
|
|
||||||
|
Cartoon death puff cloud, grey-white smoke with purple grime, soft edges, funny splat-cloud shape, fades to transparent, no hard outline outside the cloud. Transparent background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `shadow.png` — тень, 128x64
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: shadow.png, canvas 128x64.
|
||||||
|
|
||||||
|
Soft oval blob shadow, black center fading smoothly to full transparency, no colored tint, no hard edge. Transparent PNG, exactly one horizontal ellipse.
|
||||||
|
```
|
||||||
|
|
||||||
|
## HUD и меню
|
||||||
|
|
||||||
|
### `logo.png` — логотип, 900x220
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: logo.png, canvas 900x220.
|
||||||
|
|
||||||
|
Logo text in Russian: "Биндим Фигняшку".
|
||||||
|
Use the exact poster lettering vibe: chunky slime letters, green top word "Биндим", orange-yellow bottom word "Фигняшку", purple slime outline, black thick shadow. Transparent background. No extra characters, no subtitle, no watermark.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `menu-bg.png` — фон меню, 880x660
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: menu-bg.png, canvas 880x660.
|
||||||
|
|
||||||
|
Dark dungeon menu background in the same poster style, top-down room fragments, dim torches, purple slime, scattered silly-horror props. No text, no logo, no centered character, leave the central area readable for UI overlay. Full rectangular background, not transparent.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `heart-full.png` — полное сердце, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: heart-full.png, canvas 48x48.
|
||||||
|
|
||||||
|
Full HP heart icon, red fleshy cartoon heart, thick dark outline, tiny slime highlight, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `heart-half.png` — половина сердца, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: heart-half.png, canvas 48x48.
|
||||||
|
|
||||||
|
Half HP heart icon matching heart-full, left half full red flesh, right half dark empty husk, thick dark outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `heart-empty.png` — пустое сердце, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: heart-empty.png, canvas 48x48.
|
||||||
|
|
||||||
|
Empty HP heart icon matching heart-full, dark cracked heart outline with hollow center, subtle purple grime, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `icon-ranged.png` — иконка дальнего режима, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: icon-ranged.png, canvas 48x48.
|
||||||
|
|
||||||
|
Ranged mode HUD icon, tiny goofy slime pistol firing one blue tear, thick outline, high contrast, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `icon-melee.png` — иконка ближнего режима, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: icon-melee.png, canvas 48x48.
|
||||||
|
|
||||||
|
Melee mode HUD icon, tiny ridiculous knife/plunger/brush weapon, red-orange accent, thick outline, high contrast, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Иконки оружия
|
||||||
|
|
||||||
|
### `weapon-icon-tears.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-tears.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Слёзы": single blue tear orb with white highlight, chunky outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weapon-icon-melee.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-melee.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Кулак": goofy clenched fist, bruised peach color, thick dark outline, tiny slime detail, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weapon-icon-shotgun.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-shotgun.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Дробовик": three small glowing pellets spreading outward, yellow-orange centers, dark outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weapon-icon-axe.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-axe.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Топор": rusty cartoon axe with chipped metal head and short handle, purple grime, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weapon-icon-staff.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-staff.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Посох": crooked wooden staff with glowing orange fire gem, tiny slime drips, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weapon-icon-whip.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-whip.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Хлыст": curled leather whip, exaggerated curve, warm brown with purple rim, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weapon-icon-bomb.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-bomb.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Бомба": round black cartoon bomb with lit fuse, orange spark, silly eye-like rivet, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weapon-icon-boomerang.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-boomerang.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Бумеранг": crooked wooden boomerang with teeth marks and purple slime stripe, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `weapon-icon-laser.png`, 48x48
|
||||||
|
|
||||||
|
```text
|
||||||
|
Asset: weapon-icon-laser.png, canvas 48x48.
|
||||||
|
|
||||||
|
Weapon icon for "Лазер": compact blue-white laser emitter crystal or ray gun barrel, cyan glow contained inside icon, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||||
|
```
|
||||||
|
|
||||||
|
## После генерации
|
||||||
|
|
||||||
|
Сложи файлы второго набора в отдельную папку, например:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/assets-binding-2/
|
||||||
|
```
|
||||||
|
|
||||||
|
Потом можно добавить выбор в меню:
|
||||||
|
|
||||||
|
- `Классика` -> текущая папка `src/assets/`;
|
||||||
|
- `Биндинг 2.0` -> новая папка `src/assets-binding-2/`.
|
||||||
|
|
||||||
|
Когда ассеты будут готовы, самый чистый следующий шаг в коде: дать `Assets` параметр
|
||||||
|
`packPath` и прокинуть выбранный пак из `StartMenu` в `ThreeRenderer`/`HudOverlay`.
|
||||||
|
After Width: | Height: | Size: 372 KiB |
@@ -36,6 +36,16 @@
|
|||||||
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; margin-bottom: 4px; }
|
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; margin-bottom: 4px; }
|
||||||
#menu-logo { display: block; width: min(560px, 80%); height: auto; }
|
#menu-logo { display: block; width: min(560px, 80%); height: auto; }
|
||||||
.menu-sub { color: #888; margin-bottom: 6px; }
|
.menu-sub { color: #888; margin-bottom: 6px; }
|
||||||
|
.menu-packs { display: flex; gap: 8px; margin-bottom: 2px; }
|
||||||
|
.menu-pack {
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(12,12,18,0.86); color: #aaa;
|
||||||
|
border: 1px solid #333; border-radius: 4px;
|
||||||
|
padding: 7px 12px; font-family: monospace; font-size: 12px;
|
||||||
|
transition: background .12s, border-color .12s, color .12s;
|
||||||
|
}
|
||||||
|
.menu-pack:hover { background: #1d1d2c; border-color: #5a78c0; color: #ddd; }
|
||||||
|
.menu-pack.is-selected { color: #f0d887; border-color: #8f6f25; background: rgba(42,32,12,0.88); }
|
||||||
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
|
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
|
||||||
.menu-preset {
|
.menu-preset {
|
||||||
display: flex; flex-direction: column; gap: 3px;
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -124,6 +124,9 @@ export const ENEMY_STATS = {
|
|||||||
normal: { size: 32, hp: 3, speed: 1.15, damage: 1 },
|
normal: { size: 32, hp: 3, speed: 1.15, damage: 1 },
|
||||||
fast: { size: 26, hp: 2, speed: 1.9, damage: 1 },
|
fast: { size: 26, hp: 2, speed: 1.9, damage: 1 },
|
||||||
boss: { size: 46, hp: 10, speed: 0.9, damage: 2 },
|
boss: { size: 46, hp: 10, speed: 0.9, damage: 2 },
|
||||||
|
charger: { size: 32, hp: 5, speed: 1.6, damage: 1 },
|
||||||
|
tank: { size: 44, hp: 10, speed: 0.6, damage: 2 },
|
||||||
|
shooter: { size: 28, hp: 3, speed: 1.0, damage: 1 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ENEMY = {
|
export const ENEMY = {
|
||||||
@@ -147,3 +150,23 @@ export const SPAWN = {
|
|||||||
treasureChance: 0.12, // шанс комнаты-сокровищницы
|
treasureChance: 0.12, // шанс комнаты-сокровищницы
|
||||||
bossChance: 0.2, // шанс назначить комнату боссом
|
bossChance: 0.2, // шанс назначить комнату боссом
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Сундук (сокровищница)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
export const CHEST = {
|
||||||
|
size: 40,
|
||||||
|
hp: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Масштабирование этажей (New Game+)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
export const FLOOR_SCALING = {
|
||||||
|
roomsPerFloor: 2, // + комнат на этаж
|
||||||
|
hpMulPerFloor: 0.15, // + множитель HP врагов на этаж
|
||||||
|
speedMulPerFloor: 0.05, // + множитель скорости на этаж
|
||||||
|
densityMulPerFloor: 0.1, // + множитель плотности на этаж
|
||||||
|
bossHpMulPerFloor: 0.2, // + множитель HP босса на этаж
|
||||||
|
fastChancePerFloor: 0.03, // + доля быстрых врагов на этаж
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
DIR, DOOR, OX, OY, TILE, COLS, ROWS, T_WALL,
|
DIR, DOOR, OX, OY, TILE, COLS, ROWS, T_WALL,
|
||||||
MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE,
|
MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE, PROJECTILE,
|
||||||
} from '../config';
|
} from '../config';
|
||||||
import type { Dir } from './types';
|
import type { Dir } from './types';
|
||||||
import { Rng } from './rng';
|
import { Rng } from './rng';
|
||||||
import { dist, overlap } from './util';
|
import { dist, overlap } from './util';
|
||||||
import { Player } from './entities/Player';
|
import { Player } from './entities/Player';
|
||||||
|
import { Enemy } from './entities/Enemy';
|
||||||
import { Projectile } from './entities/Projectile';
|
import { Projectile } from './entities/Projectile';
|
||||||
import { MeleeSwing } from './entities/MeleeSwing';
|
import { MeleeSwing } from './entities/MeleeSwing';
|
||||||
import { RoomMap } from './world/RoomMap';
|
import { RoomMap } from './world/RoomMap';
|
||||||
import type { Room } from './world/Room';
|
import type { Room } from './world/Room';
|
||||||
import { collidesWall } from './systems/collision';
|
import { collidesWall } from './systems/collision';
|
||||||
import { spawnEnemies } from './systems/spawner';
|
import { spawnEnemies, spawnChest, pickChestWeapon } from './systems/spawner';
|
||||||
import { DEFAULT_RULES, type LevelRules } from './rules';
|
import { WeaponPickup } from './entities/WeaponPickup';
|
||||||
|
import type { WeaponDef } from './weapons';
|
||||||
|
import { DEFAULT_RULES, scaleRulesForFloor, type LevelRules } from './rules';
|
||||||
import type { InputState } from '../input/InputState';
|
import type { InputState } from '../input/InputState';
|
||||||
import { pressingDir } from '../input/InputState';
|
import { pressingDir } from '../input/InputState';
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ import { pressingDir } from '../input/InputState';
|
|||||||
*/
|
*/
|
||||||
export class Game {
|
export class Game {
|
||||||
readonly rules: LevelRules;
|
readonly rules: LevelRules;
|
||||||
|
private floorRules: LevelRules;
|
||||||
rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида
|
rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида
|
||||||
roomMap: RoomMap;
|
roomMap: RoomMap;
|
||||||
player: Player;
|
player: Player;
|
||||||
@@ -36,6 +40,9 @@ export class Game {
|
|||||||
meleeSwing: MeleeSwing | null = null;
|
meleeSwing: MeleeSwing | null = null;
|
||||||
gameOver = false;
|
gameOver = false;
|
||||||
won = false;
|
won = false;
|
||||||
|
floor = 1;
|
||||||
|
inventoryOpen = false;
|
||||||
|
elapsedSteps = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт».
|
* @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт».
|
||||||
@@ -43,9 +50,11 @@ export class Game {
|
|||||||
*/
|
*/
|
||||||
constructor(rules: LevelRules = DEFAULT_RULES, rng?: Rng) {
|
constructor(rules: LevelRules = DEFAULT_RULES, rng?: Rng) {
|
||||||
this.rules = rules;
|
this.rules = rules;
|
||||||
|
this.floorRules = rules;
|
||||||
this.rng = rng ?? new Rng(rules.seed);
|
this.rng = rng ?? new Rng(rules.seed);
|
||||||
this.player = new Player(rules.player);
|
this.player = new Player(rules.player);
|
||||||
this.roomMap = new RoomMap(this.rng, rules);
|
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||||
|
this.roomMap = new RoomMap(this.rng, this.floorRules);
|
||||||
this.enterRoom('up');
|
this.enterRoom('up');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,19 +65,40 @@ export class Game {
|
|||||||
|
|
||||||
// ── Публичный контракт цикла ──────────────────────────────
|
// ── Публичный контракт цикла ──────────────────────────────
|
||||||
|
|
||||||
/** Однократные действия (смена оружия, рестарт). Вызывать раз в кадр. */
|
/** Однократные действия (смена оружия, рестарт, инвентарь). Вызывать раз в кадр. */
|
||||||
consumeActions(input: InputState): void {
|
consumeActions(input: InputState): void {
|
||||||
|
if (input.openInventory && !this.gameOver && !this.won) {
|
||||||
|
this.inventoryOpen = !this.inventoryOpen;
|
||||||
|
}
|
||||||
|
if (this.inventoryOpen) return;
|
||||||
if (input.toggleWeapon && !this.gameOver && !this.won) {
|
if (input.toggleWeapon && !this.gameOver && !this.won) {
|
||||||
this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED;
|
const p = this.player;
|
||||||
|
p.equipped = p.equipped === 0 ? 1 : 0;
|
||||||
|
p.mode = p.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||||
}
|
}
|
||||||
if (input.restart && (this.gameOver || this.won)) {
|
if (input.restart && (this.gameOver || this.won)) {
|
||||||
this.reset();
|
this.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Выбрать слот 0 или 1 (из main.ts при открытом инвентаре). */
|
||||||
|
equipSlot(slot: number): void {
|
||||||
|
if (slot !== 0 && slot !== 1) return;
|
||||||
|
this.player.equipped = slot;
|
||||||
|
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||||
|
this.inventoryOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Закрыть инвентарь без прямой мутации поля снаружи Game. */
|
||||||
|
closeInventory(): void {
|
||||||
|
this.inventoryOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Один фиксированный шаг симуляции (= 1/60 c). */
|
/** Один фиксированный шаг симуляции (= 1/60 c). */
|
||||||
step(input: InputState): void {
|
step(input: InputState): void {
|
||||||
if (this.gameOver || this.won) return;
|
if (this.gameOver || this.won || this.inventoryOpen) return;
|
||||||
|
|
||||||
|
this.elapsedSteps++;
|
||||||
|
|
||||||
const room = this.curRoom;
|
const room = this.curRoom;
|
||||||
const p = this.player;
|
const p = this.player;
|
||||||
@@ -87,6 +117,8 @@ export class Game {
|
|||||||
this.handleAttack(input, room, p);
|
this.handleAttack(input, room, p);
|
||||||
this.updateMelee(room);
|
this.updateMelee(room);
|
||||||
this.updateTears(room);
|
this.updateTears(room);
|
||||||
|
this.updateChest(room);
|
||||||
|
this.updatePickup(room, p);
|
||||||
const aliveCount = this.updateEnemies(room, p);
|
const aliveCount = this.updateEnemies(room, p);
|
||||||
if (this.gameOver) return;
|
if (this.gameOver) return;
|
||||||
|
|
||||||
@@ -104,10 +136,15 @@ export class Game {
|
|||||||
reset(): void {
|
reset(): void {
|
||||||
this.gameOver = false;
|
this.gameOver = false;
|
||||||
this.won = false;
|
this.won = false;
|
||||||
|
this.floor = 1;
|
||||||
|
this.inventoryOpen = false;
|
||||||
|
this.elapsedSteps = 0;
|
||||||
|
this.floorRules = this.rules;
|
||||||
// Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз.
|
// Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз.
|
||||||
this.rng = new Rng(this.rules.seed);
|
this.rng = new Rng(this.rules.seed);
|
||||||
this.roomMap = new RoomMap(this.rng, this.rules);
|
this.roomMap = new RoomMap(this.rng, this.floorRules);
|
||||||
this.player = new Player(this.rules.player);
|
this.player = new Player(this.rules.player);
|
||||||
|
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||||
this.cc = 0;
|
this.cc = 0;
|
||||||
this.cr = 0;
|
this.cr = 0;
|
||||||
this.meleeSwing = null;
|
this.meleeSwing = null;
|
||||||
@@ -135,7 +172,11 @@ export class Game {
|
|||||||
room.tears = [];
|
room.tears = [];
|
||||||
|
|
||||||
if (!room.cleared && room.type !== 'spawn') {
|
if (!room.cleared && room.type !== 'spawn') {
|
||||||
room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.rules);
|
room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.floorRules);
|
||||||
|
// Сундук в сокровищнице.
|
||||||
|
if (room.type === 'treasure' && !room.chest) {
|
||||||
|
room.chest = spawnChest(room, this.rng);
|
||||||
|
}
|
||||||
// Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу,
|
// Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу,
|
||||||
// иначе двери никогда не появятся и игрок застрянет.
|
// иначе двери никогда не появятся и игрок застрянет.
|
||||||
if (room.enemies.length === 0) room.cleared = true;
|
if (room.enemies.length === 0) room.cleared = true;
|
||||||
@@ -174,18 +215,52 @@ export class Game {
|
|||||||
|
|
||||||
private handleAttack(input: InputState, room: Room, p: Player): void {
|
private handleAttack(input: InputState, room: Room, p: Player): void {
|
||||||
let dir: Dir | null = null;
|
let dir: Dir | null = null;
|
||||||
if (input.aimDir) dir = input.aimDir; // прицельная стрельба стрелками
|
if (input.aimDir) dir = input.aimDir;
|
||||||
else if (input.attackHeld) dir = p.moveDir; // пробел — по ходу движения
|
else if (input.attackHeld) dir = p.moveDir;
|
||||||
|
|
||||||
if (!dir || p.atkCD > 0) return;
|
if (!dir || p.atkCD > 0) return;
|
||||||
|
|
||||||
p.facing = dir;
|
p.facing = dir;
|
||||||
p.atkCD = p.mode === MODE_RANGED ? PLAYER.rangedCooldown : PLAYER.meleeCooldown;
|
const w = p.currentWeapon;
|
||||||
|
p.atkCD = w.cooldown;
|
||||||
const [nx, ny] = DIR[dir];
|
const [nx, ny] = DIR[dir];
|
||||||
if (p.mode === MODE_RANGED) {
|
|
||||||
room.tears.push(new Projectile(p.x, p.y, nx, ny));
|
if (w.type === 'ranged') {
|
||||||
|
if (w.projectileType === 'beam') {
|
||||||
|
// Лазерный луч — стационарная зона поражения.
|
||||||
|
const range = 70;
|
||||||
|
const t = new Projectile(p.x + nx * range, p.y + ny * range, 0, 0, 'beam');
|
||||||
|
t.speed = 0;
|
||||||
|
t.life = w.beamLife ?? 10;
|
||||||
|
t.damage = w.beamTickDmg ?? 2;
|
||||||
|
t.beamRadius = w.beamRadius ?? 44;
|
||||||
|
room.tears.push(t);
|
||||||
|
} else if (w.spreadCount && w.spreadCount > 1) {
|
||||||
|
const spread = 0.15;
|
||||||
|
const perpX = -ny;
|
||||||
|
const perpY = nx;
|
||||||
|
const projectileType = w.projectileType ?? 'tear';
|
||||||
|
for (let i = 0; i < w.spreadCount; i++) {
|
||||||
|
const off = (i - (w.spreadCount - 1) / 2) * spread;
|
||||||
|
const sx = nx + perpX * off;
|
||||||
|
const sy = ny + perpY * off;
|
||||||
|
const len = Math.hypot(sx, sy) || 1;
|
||||||
|
const t = new Projectile(p.x, p.y, sx / len, sy / len, projectileType);
|
||||||
|
this.applyWeaponProjectileStats(t, w);
|
||||||
|
room.tears.push(t);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.meleeSwing = new MeleeSwing(p.x, p.y, dir);
|
const t = new Projectile(p.x, p.y, nx, ny, w.projectileType ?? 'tear');
|
||||||
|
this.applyWeaponProjectileStats(t, w);
|
||||||
|
room.tears.push(t);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.meleeSwing = new MeleeSwing(p.x, p.y, dir, {
|
||||||
|
damage: w.damage,
|
||||||
|
knockback: w.knockback,
|
||||||
|
life: w.swingLife,
|
||||||
|
sizeMul: w.swingSizeMul,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,17 +273,54 @@ export class Game {
|
|||||||
if (!e.alive || e.hitTimer > 0) continue;
|
if (!e.alive || e.hitTimer > 0) continue;
|
||||||
if (overlap(e.box, this.meleeSwing.box)) {
|
if (overlap(e.box, this.meleeSwing.box)) {
|
||||||
e.hp -= this.meleeSwing.damage;
|
e.hp -= this.meleeSwing.damage;
|
||||||
e.hitTimer = MELEE.life; // защита от повторного удара тем же взмахом
|
e.hitTimer = MELEE.life;
|
||||||
const [dx, dy] = DIR[this.meleeSwing.dir];
|
const [dx, dy] = DIR[this.meleeSwing.dir];
|
||||||
e.knx = dx * this.meleeSwing.kb;
|
e.knx = dx * this.meleeSwing.kb;
|
||||||
e.kny = dy * this.meleeSwing.kb;
|
e.kny = dy * this.meleeSwing.kb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Удар по сундуку.
|
||||||
|
if (room.chest?.alive && overlap(room.chest.box, this.meleeSwing.box)) {
|
||||||
|
room.chest.hp -= this.meleeSwing.damage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снаряд запоминает свойства оружия при выстреле, а не при попадании. */
|
||||||
|
private applyWeaponProjectileStats(t: Projectile, w: WeaponDef): void {
|
||||||
|
t.damage = w.damage;
|
||||||
|
t.burnDamage = w.fireDmg ?? 1;
|
||||||
|
t.burnInterval = w.fireInterval ?? 10;
|
||||||
|
t.burnDuration = w.fireDuration ?? 0;
|
||||||
|
t.explosionRadius = w.explosionRadius ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateTears(room: Room): void {
|
private updateTears(room: Room): void {
|
||||||
for (const t of room.tears) {
|
for (const t of room.tears) {
|
||||||
if (!t.alive) continue;
|
if (!t.alive) continue;
|
||||||
|
|
||||||
|
// Лазерный луч: стоит на месте, жжёт врагов каждые 2 тика.
|
||||||
|
if (t.type === 'beam') {
|
||||||
|
t.life--;
|
||||||
|
if (t.life <= 0) continue;
|
||||||
|
if (t.life % 2 === 0) {
|
||||||
|
const radius = t.beamRadius || 44;
|
||||||
|
for (const e of room.enemies) {
|
||||||
|
if (!e.alive) continue;
|
||||||
|
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + radius) {
|
||||||
|
e.hp -= t.damage;
|
||||||
|
e.hitTimer = ENEMY.hitFlash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Бумеранг: один раз на половине жизни разворачивается.
|
||||||
|
if (t.type === 'boomerang' && t.life === Math.floor(PROJECTILE.life / 2)) {
|
||||||
|
t.dx = -t.dx;
|
||||||
|
t.dy = -t.dy;
|
||||||
|
}
|
||||||
|
|
||||||
t.x += t.dx * t.speed;
|
t.x += t.dx * t.speed;
|
||||||
t.y += t.dy * t.speed;
|
t.y += t.dy * t.speed;
|
||||||
t.life--;
|
t.life--;
|
||||||
@@ -216,38 +328,116 @@ export class Game {
|
|||||||
const col = Math.floor((t.x - OX) / TILE);
|
const col = Math.floor((t.x - OX) / TILE);
|
||||||
const row = Math.floor((t.y - OY) / TILE);
|
const row = Math.floor((t.y - OY) / TILE);
|
||||||
if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
|
if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
|
||||||
|
this.explodeBomb(room, t);
|
||||||
t.life = 0;
|
t.life = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (room.tiles[row][col] === T_WALL) {
|
if (room.tiles[row][col] === T_WALL) {
|
||||||
|
this.explodeBomb(room, t);
|
||||||
t.life = 0;
|
t.life = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (t.hostile) {
|
||||||
|
// Вражеский снаряд: бьёт игрока.
|
||||||
|
const p = this.player;
|
||||||
|
if (dist(t.x, t.y, p.x, p.y) < p.w / 2 + t.r && p.invTimer <= 0) {
|
||||||
|
p.hp -= t.damage;
|
||||||
|
p.invTimer = PLAYER.invFrames;
|
||||||
|
t.life = 0;
|
||||||
|
if (p.hp <= 0) { p.hp = 0; this.gameOver = true; return; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Снаряд игрока: бьёт врагов.
|
||||||
for (const e of room.enemies) {
|
for (const e of room.enemies) {
|
||||||
if (!e.alive) continue;
|
if (!e.alive) continue;
|
||||||
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) {
|
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) {
|
||||||
e.hp -= t.damage;
|
e.hp -= t.damage;
|
||||||
e.hitTimer = ENEMY.hitFlash;
|
e.hitTimer = ENEMY.hitFlash;
|
||||||
|
if (t.type === 'fireball') {
|
||||||
|
e.burnTimer = t.burnDuration;
|
||||||
|
e.burnDamage = t.burnDamage;
|
||||||
|
e.burnInterval = t.burnInterval;
|
||||||
|
}
|
||||||
|
if (t.type !== 'laser') {
|
||||||
t.life = 0;
|
t.life = 0;
|
||||||
|
this.explodeBomb(room, t);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Попадание в сундук.
|
||||||
|
if (room.chest?.alive && dist(t.x, t.y, room.chest.x, room.chest.y) < room.chest.w / 2 + t.r) {
|
||||||
|
room.chest.hp -= t.damage;
|
||||||
|
if (t.type !== 'laser') {
|
||||||
|
t.life = 0;
|
||||||
|
this.explodeBomb(room, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
room.tears = room.tears.filter((t) => t.alive);
|
room.tears = room.tears.filter((t) => t.alive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Взрыв бомбы: AoE-урон по врагам. */
|
||||||
|
private explodeBomb(room: Room, t: Projectile): void {
|
||||||
|
if (t.type !== 'bomb') return;
|
||||||
|
const radius = t.explosionRadius || 60;
|
||||||
|
for (const e of room.enemies) {
|
||||||
|
if (!e.alive) continue;
|
||||||
|
if (dist(t.x, t.y, e.x, e.y) < radius) {
|
||||||
|
e.hp -= 3;
|
||||||
|
e.hitTimer = ENEMY.hitFlash;
|
||||||
|
// Отбрасывание от центра взрыва.
|
||||||
|
const dx = e.x - t.x;
|
||||||
|
const dy = e.y - t.y;
|
||||||
|
const d = Math.hypot(dx, dy) || 1;
|
||||||
|
e.knx = (dx / d) * 12;
|
||||||
|
e.kny = (dy / d) * 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сундук уничтожен — спавним оружие. */
|
||||||
|
private updateChest(room: Room): void {
|
||||||
|
if (!room.chest || room.pickup) return;
|
||||||
|
if (room.chest.alive) return;
|
||||||
|
const weaponId = pickChestWeapon(this.rng);
|
||||||
|
room.pickup = new WeaponPickup(room.chest.x, room.chest.y, weaponId);
|
||||||
|
room.chest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подбор оружия игроком. */
|
||||||
|
private updatePickup(room: Room, p: Player): void {
|
||||||
|
if (!room.pickup) return;
|
||||||
|
if (overlap(p.box, room.pickup.box)) {
|
||||||
|
p.addWeapon(room.pickup.weaponId);
|
||||||
|
room.pickup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updateEnemies(room: Room, p: Player): number {
|
private updateEnemies(room: Room, p: Player): number {
|
||||||
let aliveCount = 0;
|
let aliveCount = 0;
|
||||||
|
const newEnemies: Enemy[] = [];
|
||||||
|
|
||||||
for (const e of room.enemies) {
|
for (const e of room.enemies) {
|
||||||
if (!e.alive) continue;
|
if (!e.alive) continue;
|
||||||
aliveCount++;
|
|
||||||
|
|
||||||
if (e.hitTimer > 0) e.hitTimer--;
|
if (e.hitTimer > 0) e.hitTimer--;
|
||||||
|
|
||||||
// Фаза отбрасывания: летит по инерции, ИИ не работает. Коллизии
|
// Горение: урон каждые fireInterval тиков.
|
||||||
// проверяем пораздельно по осям — иначе кнокбэк (до ~4.5 тайла)
|
if (e.burnTimer > 0) {
|
||||||
// пробивал стену в 1 тайл, и враг застревал снаружи навсегда (софт-лок).
|
e.burnTimer--;
|
||||||
|
if (e.burnTimer % e.burnInterval === 0) {
|
||||||
|
e.hp -= e.burnDamage;
|
||||||
|
e.hitTimer = ENEMY.hitFlash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!e.alive) continue;
|
||||||
|
aliveCount++;
|
||||||
|
|
||||||
|
// Фаза отбрасывания: летит по инерции, ИИ не работает.
|
||||||
if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
|
if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
|
||||||
e.x += e.knx * 3;
|
e.x += e.knx * 3;
|
||||||
if (collidesWall(e.box, room)) e.x -= e.knx * 3;
|
if (collidesWall(e.box, room)) e.x -= e.knx * 3;
|
||||||
@@ -260,17 +450,14 @@ export class Game {
|
|||||||
e.knx = 0;
|
e.knx = 0;
|
||||||
e.kny = 0;
|
e.kny = 0;
|
||||||
|
|
||||||
// Преследование игрока.
|
if (e.type === 'boss' && this.milestoneBossFloor(this.floor)) {
|
||||||
const dx = p.x - e.x;
|
this.updateMilestoneBoss(e, room, p, newEnemies);
|
||||||
const dy = p.y - e.y;
|
} else if (e.type === 'shooter') {
|
||||||
const d = Math.hypot(dx, dy);
|
this.updateShooter(e, room, p);
|
||||||
if (d > 0 && d < ENEMY.aggroRange) {
|
} else if (e.type === 'charger') {
|
||||||
const mx = (dx / d) * e.speed;
|
this.updateCharger(e, room, p);
|
||||||
const my = (dy / d) * e.speed;
|
} else {
|
||||||
e.x += mx;
|
this.updateChaser(e, room, p);
|
||||||
if (collidesWall(e.box, room)) e.x -= mx;
|
|
||||||
e.y += my;
|
|
||||||
if (collidesWall(e.box, room)) e.y -= my;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Контактный урон по игроку.
|
// Контактный урон по игроку.
|
||||||
@@ -287,9 +474,151 @@ export class Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
room.enemies.push(...newEnemies);
|
||||||
return aliveCount;
|
return aliveCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Этаж кратный 5, начиная с 5. */
|
||||||
|
private milestoneBossFloor(floor: number): boolean {
|
||||||
|
return floor % 5 === 0 && floor >= 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Босс на milestone-этаже: фазы, стрельба, миньоны. */
|
||||||
|
private updateMilestoneBoss(e: Enemy, room: Room, p: Player, newEnemies: Enemy[]): void {
|
||||||
|
const maxPhase = this.floor <= 5 ? 2 : 3;
|
||||||
|
const hpRatio = e.hp / e.maxHp;
|
||||||
|
|
||||||
|
let targetPhase = 1;
|
||||||
|
if (maxPhase >= 2 && hpRatio < 0.66) targetPhase = 2;
|
||||||
|
if (maxPhase >= 3 && hpRatio < 0.33) targetPhase = 3;
|
||||||
|
|
||||||
|
if (targetPhase > e.phase) {
|
||||||
|
e.phase = targetPhase;
|
||||||
|
e.phaseChanged = true;
|
||||||
|
e.hitTimer = 15; // визуальная вспышка (для рендера)
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseSpeedMul = targetPhase >= 3 ? 1.8 : targetPhase === 2 ? 1.35 : 1.0;
|
||||||
|
const effectiveSpeed = e.speed * phaseSpeedMul;
|
||||||
|
|
||||||
|
// Движение к игроку.
|
||||||
|
const dx = p.x - e.x;
|
||||||
|
const dy = p.y - e.y;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d > 0 && d < ENEMY.aggroRange) {
|
||||||
|
const mx = (dx / d) * effectiveSpeed;
|
||||||
|
const my = (dy / d) * effectiveSpeed;
|
||||||
|
e.x += mx;
|
||||||
|
if (collidesWall(e.box, room)) e.x -= mx;
|
||||||
|
e.y += my;
|
||||||
|
if (collidesWall(e.box, room)) e.y -= my;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стрельба снарядами (фаза 2+).
|
||||||
|
if (targetPhase >= 2) {
|
||||||
|
if (e.shootTimer > 0) e.shootTimer--;
|
||||||
|
const shootCD = targetPhase >= 3 ? 25 : 40;
|
||||||
|
if (e.shootTimer <= 0 && d < 400 && d > 50) {
|
||||||
|
e.shootTimer = shootCD;
|
||||||
|
const nd = d || 1;
|
||||||
|
const t = new Projectile(e.x, e.y, dx / nd, dy / nd, 'tear');
|
||||||
|
t.hostile = true;
|
||||||
|
t.damage = 1 + Math.floor(this.floor / 5);
|
||||||
|
t.speed = 3;
|
||||||
|
t.life = 60;
|
||||||
|
room.tears.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спавн миньонов (фаза 3).
|
||||||
|
if (targetPhase >= 3) {
|
||||||
|
if (e.spawnTimer > 0) e.spawnTimer--;
|
||||||
|
if (e.spawnTimer <= 0) {
|
||||||
|
e.spawnTimer = 120;
|
||||||
|
const rng = this.rng;
|
||||||
|
const mx = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
|
||||||
|
const my = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
|
||||||
|
const er = this.floorRules.enemies;
|
||||||
|
newEnemies.push(new Enemy(mx, my, 'fast', { hpMul: er.hpMul * 1.5, speedMul: er.speedMul * 1.2 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стандартное преследование (normal, fast, tank, boss). */
|
||||||
|
private updateChaser(e: Enemy, room: Room, p: Player): void {
|
||||||
|
const dx = p.x - e.x;
|
||||||
|
const dy = p.y - e.y;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d > 0 && d < ENEMY.aggroRange) {
|
||||||
|
const mx = (dx / d) * e.speed;
|
||||||
|
const my = (dy / d) * e.speed;
|
||||||
|
e.x += mx;
|
||||||
|
if (collidesWall(e.box, room)) e.x -= mx;
|
||||||
|
e.y += my;
|
||||||
|
if (collidesWall(e.box, room)) e.y -= my;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Зарядчик: бежит прямо на игрока с удвоенной скоростью. */
|
||||||
|
private updateCharger(e: Enemy, room: Room, p: Player): void {
|
||||||
|
if (e.chargeTimer > 0) e.chargeTimer--;
|
||||||
|
const dx = p.x - e.x;
|
||||||
|
const dy = p.y - e.y;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d > 0 && d < ENEMY.aggroRange) {
|
||||||
|
const speedMul = e.chargeTimer <= 0 && d < 150 ? 2.5 : 1.0;
|
||||||
|
if (speedMul > 1) e.chargeTimer = 40; // перезарядка рывка
|
||||||
|
const mx = (dx / d) * e.speed * speedMul;
|
||||||
|
const my = (dy / d) * e.speed * speedMul;
|
||||||
|
e.x += mx;
|
||||||
|
if (collidesWall(e.box, room)) e.x -= mx;
|
||||||
|
e.y += my;
|
||||||
|
if (collidesWall(e.box, room)) e.y -= my;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стрелок: держит дистанцию, стреляет снарядами. */
|
||||||
|
private updateShooter(e: Enemy, room: Room, p: Player): void {
|
||||||
|
const dx = p.x - e.x;
|
||||||
|
const dy = p.y - e.y;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
// Держит дистанцию ~200 px.
|
||||||
|
if (d > 0 && d < ENEMY.aggroRange) {
|
||||||
|
if (d < 150) {
|
||||||
|
// Слишком близко — отступает.
|
||||||
|
const mx = -(dx / d) * e.speed;
|
||||||
|
const my = -(dy / d) * e.speed;
|
||||||
|
e.x += mx;
|
||||||
|
if (collidesWall(e.box, room)) e.x -= mx;
|
||||||
|
e.y += my;
|
||||||
|
if (collidesWall(e.box, room)) e.y -= my;
|
||||||
|
} else {
|
||||||
|
const mx = (dx / d) * e.speed * 0.5;
|
||||||
|
const my = (dy / d) * e.speed * 0.5;
|
||||||
|
e.x += mx;
|
||||||
|
if (collidesWall(e.box, room)) e.x -= mx;
|
||||||
|
e.y += my;
|
||||||
|
if (collidesWall(e.box, room)) e.y -= my;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стрельба.
|
||||||
|
if (e.shootTimer > 0) e.shootTimer--;
|
||||||
|
if (e.shootTimer <= 0 && d < 350 && d > 40) {
|
||||||
|
e.shootTimer = 45;
|
||||||
|
const nd = d || 1;
|
||||||
|
const nx = dx / nd;
|
||||||
|
const ny = dy / nd;
|
||||||
|
const t = new Projectile(e.x, e.y, nx, ny, 'tear');
|
||||||
|
t.hostile = true;
|
||||||
|
t.damage = 1;
|
||||||
|
t.speed = 3.5;
|
||||||
|
t.life = 60;
|
||||||
|
room.tears.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Переходы и победа ─────────────────────────────────────
|
// ── Переходы и победа ─────────────────────────────────────
|
||||||
|
|
||||||
private checkTransition(input: InputState): void {
|
private checkTransition(input: InputState): void {
|
||||||
@@ -315,10 +644,38 @@ export class Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Спуск на следующий этаж: новая карта, усиленные враги, HP/оружие сохраняются. */
|
||||||
|
private descend(): void {
|
||||||
|
this.floor++;
|
||||||
|
this.floorRules = scaleRulesForFloor(this.rules, this.floor);
|
||||||
|
|
||||||
|
const p = this.player;
|
||||||
|
const savedHp = p.hp;
|
||||||
|
const savedMode = p.mode;
|
||||||
|
|
||||||
|
const floorSeed = this.rules.seed !== undefined ? this.rules.seed + this.floor : undefined;
|
||||||
|
this.rng = floorSeed !== undefined ? new Rng(floorSeed) : new Rng();
|
||||||
|
|
||||||
|
this.roomMap = new RoomMap(this.rng, this.floorRules);
|
||||||
|
this.cc = 0;
|
||||||
|
this.cr = 0;
|
||||||
|
this.meleeSwing = null;
|
||||||
|
|
||||||
|
p.hp = savedHp;
|
||||||
|
p.atkCD = 0;
|
||||||
|
p.moveDir = 'up';
|
||||||
|
this.enterRoom('up');
|
||||||
|
p.mode = savedMode;
|
||||||
|
}
|
||||||
|
|
||||||
private checkWin(): void {
|
private checkWin(): void {
|
||||||
for (const room of this.roomMap.rooms.values()) {
|
for (const room of this.roomMap.rooms.values()) {
|
||||||
if (room.type === 'boss' && room.cleared) {
|
if (room.type === 'boss' && room.cleared) {
|
||||||
|
if (this.rules.endless) {
|
||||||
|
this.descend();
|
||||||
|
} else {
|
||||||
this.won = true;
|
this.won = true;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { CHEST } from '../../config';
|
||||||
|
import type { Box } from '../types';
|
||||||
|
|
||||||
|
export class Chest {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hp: number;
|
||||||
|
readonly w = CHEST.size;
|
||||||
|
readonly h = CHEST.size;
|
||||||
|
|
||||||
|
constructor(x: number, y: number) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.hp = CHEST.hp;
|
||||||
|
}
|
||||||
|
|
||||||
|
get box(): Box {
|
||||||
|
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
|
||||||
|
}
|
||||||
|
|
||||||
|
get alive(): boolean {
|
||||||
|
return this.hp > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,14 @@ export class Enemy {
|
|||||||
kny = 0; // отбрасывание по Y
|
kny = 0; // отбрасывание по Y
|
||||||
hitTimer = 0; // мигание при попадании (шаги)
|
hitTimer = 0; // мигание при попадании (шаги)
|
||||||
atkTimer = 0; // перезарядка контактного удара (шаги)
|
atkTimer = 0; // перезарядка контактного удара (шаги)
|
||||||
|
burnTimer = 0; // тиков до конца горения (0 = не горит)
|
||||||
|
burnDamage = 1; // урон за тик горения
|
||||||
|
burnInterval = 10; // как часто горение наносит урон
|
||||||
|
chargeTimer = 0; // перезарядка рывка для charger (шаги)
|
||||||
|
shootTimer = 0; // перезарядка стрельбы для shooter (шаги)
|
||||||
|
phase = 1; // фаза босса (мультифазные боссы на этажах 5/10/15)
|
||||||
|
phaseChanged = false; // флаг для рендера (сброс на след. шаге)
|
||||||
|
spawnTimer = 0; // перезарядка спавна миньонов для босса
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* mods — множители из правил уровня (см. core/rules.ts). По умолчанию 1,
|
* mods — множители из правил уровня (см. core/rules.ts). По умолчанию 1,
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import { MELEE, DIR } from '../../config';
|
import { MELEE, DIR } from '../../config';
|
||||||
import type { Dir, Box } from '../types';
|
import type { Dir, Box } from '../types';
|
||||||
|
|
||||||
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на MELEE.life шагов. */
|
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на life шагов. */
|
||||||
export class MeleeSwing {
|
export class MeleeSwing {
|
||||||
readonly dir: Dir;
|
readonly dir: Dir;
|
||||||
life = MELEE.life;
|
life = MELEE.life;
|
||||||
readonly damage = MELEE.damage;
|
readonly maxLife: number;
|
||||||
readonly kb = MELEE.knockback;
|
readonly damage: number;
|
||||||
|
readonly kb: number;
|
||||||
readonly box: Box;
|
readonly box: Box;
|
||||||
|
|
||||||
constructor(x: number, y: number, dir: Dir) {
|
constructor(x: number, y: number, dir: Dir, overrides?: { damage?: number; knockback?: number; life?: number; sizeMul?: number }) {
|
||||||
this.dir = dir;
|
this.dir = dir;
|
||||||
const { reach: d, size: s } = MELEE;
|
this.damage = overrides?.damage ?? MELEE.damage;
|
||||||
|
this.kb = overrides?.knockback ?? MELEE.knockback;
|
||||||
|
if (overrides?.life !== undefined) this.life = overrides.life;
|
||||||
|
this.maxLife = this.life;
|
||||||
|
const { reach: d } = MELEE;
|
||||||
|
const size = MELEE.size * (overrides?.sizeMul ?? 1);
|
||||||
const [dx, dy] = DIR[dir];
|
const [dx, dy] = DIR[dir];
|
||||||
this.box = {
|
this.box = {
|
||||||
x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2),
|
x: x + (dx > 0 ? d : dx < 0 ? -d - size : -size / 2),
|
||||||
y: y + (dy > 0 ? d : dy < 0 ? -d - s : -s / 2),
|
y: y + (dy > 0 ? d : dy < 0 ? -d - size : -size / 2),
|
||||||
w: s,
|
w: size,
|
||||||
h: s,
|
h: size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PLAYER, MODE_RANGED } from '../../config';
|
import { PLAYER, MODE_RANGED } from '../../config';
|
||||||
|
import { WEAPONS, type WeaponId, type WeaponDef } from '../weapons';
|
||||||
import type { CombatMode, Box, Dir } from '../types';
|
import type { CombatMode, Box, Dir } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,11 @@ export class Player {
|
|||||||
invTimer = 0; // неуязвимость (шаги)
|
invTimer = 0; // неуязвимость (шаги)
|
||||||
transCD = 0; // блок перехода между комнатами (шаги)
|
transCD = 0; // блок перехода между комнатами (шаги)
|
||||||
|
|
||||||
|
/** Ровно 2 слота под оружие. */
|
||||||
|
weapons: [WeaponDef, WeaponDef] = [WEAPONS.tears, WEAPONS.melee];
|
||||||
|
/** 0 или 1 — какой слот сейчас экипирован. */
|
||||||
|
equipped: 0 | 1 = 0;
|
||||||
|
|
||||||
/** Переопределения из правил уровня; по умолчанию — баланс из config. */
|
/** Переопределения из правил уровня; по умолчанию — баланс из config. */
|
||||||
constructor(rules: { maxHp?: number; speed?: number } = {}) {
|
constructor(rules: { maxHp?: number; speed?: number } = {}) {
|
||||||
this.maxHp = rules.maxHp ?? PLAYER.maxHp;
|
this.maxHp = rules.maxHp ?? PLAYER.maxHp;
|
||||||
@@ -35,6 +41,16 @@ export class Player {
|
|||||||
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
|
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get currentWeapon(): WeaponDef {
|
||||||
|
return this.weapons[this.equipped];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подобрать оружие — заменяет текущий экипированный слот. */
|
||||||
|
addWeapon(id: WeaponId): void {
|
||||||
|
this.weapons[this.equipped] = WEAPONS[id];
|
||||||
|
this.mode = WEAPONS[id].type === 'ranged' ? MODE_RANGED : 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */
|
/** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */
|
||||||
place(x: number, y: number): void {
|
place(x: number, y: number): void {
|
||||||
this.x = this.prevX = x;
|
this.x = this.prevX = x;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PROJECTILE } from '../../config';
|
import { PROJECTILE } from '../../config';
|
||||||
|
import type { ProjectileType } from '../types';
|
||||||
|
|
||||||
/** Снаряд игрока («слеза»). Летит по прямой, пока не врежется или не истечёт life. */
|
/** Снаряд. Летит по прямой, пока не врежется или не истечёт life. */
|
||||||
export class Projectile {
|
export class Projectile {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -8,16 +9,24 @@ export class Projectile {
|
|||||||
prevY: number;
|
prevY: number;
|
||||||
dx: number;
|
dx: number;
|
||||||
dy: number;
|
dy: number;
|
||||||
|
readonly type: ProjectileType;
|
||||||
readonly r = PROJECTILE.radius;
|
readonly r = PROJECTILE.radius;
|
||||||
readonly speed = PROJECTILE.speed;
|
speed = PROJECTILE.speed;
|
||||||
readonly damage = PROJECTILE.damage;
|
damage = PROJECTILE.damage;
|
||||||
life = PROJECTILE.life;
|
life = PROJECTILE.life;
|
||||||
|
hostile = false; // true = вражеский снаряд, бьёт игрока
|
||||||
|
burnDuration = 0;
|
||||||
|
burnDamage = 1;
|
||||||
|
burnInterval = 10;
|
||||||
|
explosionRadius = 0;
|
||||||
|
beamRadius = 0;
|
||||||
|
|
||||||
constructor(x: number, y: number, dx: number, dy: number) {
|
constructor(x: number, y: number, dx: number, dy: number, type: ProjectileType = 'tear') {
|
||||||
this.x = this.prevX = x;
|
this.x = this.prevX = x;
|
||||||
this.y = this.prevY = y;
|
this.y = this.prevY = y;
|
||||||
this.dx = dx;
|
this.dx = dx;
|
||||||
this.dy = dy;
|
this.dy = dy;
|
||||||
|
this.type = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
get alive(): boolean {
|
get alive(): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Box } from '../types';
|
||||||
|
import type { WeaponId } from '../weapons';
|
||||||
|
|
||||||
|
export class WeaponPickup {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
readonly weaponId: WeaponId;
|
||||||
|
readonly w = 30;
|
||||||
|
readonly h = 30;
|
||||||
|
|
||||||
|
constructor(x: number, y: number, weaponId: WeaponId) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.weaponId = weaponId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get box(): Box {
|
||||||
|
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
* Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в config.ts: это
|
* Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в config.ts: это
|
||||||
* не «правила уровня», а константы движка.
|
* не «правила уровня», а константы движка.
|
||||||
*/
|
*/
|
||||||
import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS } from '../config';
|
import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS, FLOOR_SCALING } from '../config';
|
||||||
|
|
||||||
export interface LevelRules {
|
export interface LevelRules {
|
||||||
/** Машинный id (для сохранений/выбора). */
|
/** Машинный id (для сохранений/выбора). */
|
||||||
@@ -21,6 +21,8 @@ export interface LevelRules {
|
|||||||
description: string;
|
description: string;
|
||||||
/** Фиксированный seed генерации. undefined → случайный каждый забег. */
|
/** Фиксированный seed генерации. undefined → случайный каждый забег. */
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
/** Бесконечный спуск: после босса — новый этаж с усилением, а не победа. */
|
||||||
|
endless?: boolean;
|
||||||
|
|
||||||
/** Параметры генерации карты. */
|
/** Параметры генерации карты. */
|
||||||
map: {
|
map: {
|
||||||
@@ -91,4 +93,38 @@ export const PRESETS: LevelRules[] = [
|
|||||||
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
|
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
|
||||||
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
|
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'endless',
|
||||||
|
name: 'Бесконечный спуск',
|
||||||
|
description: 'После босса — спуск на новый этаж. Враги сильнее, комнат больше. HP и оружие сохраняются.',
|
||||||
|
endless: true,
|
||||||
|
map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
|
||||||
|
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
|
||||||
|
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает правила для указанного этажа на основе базовых правил.
|
||||||
|
* С каждым этажом враги сильнее и комнат больше.
|
||||||
|
*/
|
||||||
|
export function scaleRulesForFloor(base: LevelRules, floor: number): LevelRules {
|
||||||
|
const f = floor - 1;
|
||||||
|
const cap = (v: number): number => Math.round(v * 100) / 100;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
map: {
|
||||||
|
...base.map,
|
||||||
|
minRooms: base.map.minRooms + f * FLOOR_SCALING.roomsPerFloor,
|
||||||
|
mapRadius: Math.min(base.map.mapRadius + Math.floor(f / 3), 6),
|
||||||
|
},
|
||||||
|
enemies: {
|
||||||
|
densityMul: cap(base.enemies.densityMul + f * FLOOR_SCALING.densityMulPerFloor),
|
||||||
|
fastChance: Math.min(base.enemies.fastChance + f * FLOOR_SCALING.fastChancePerFloor, 0.8),
|
||||||
|
hpMul: cap(base.enemies.hpMul + f * FLOOR_SCALING.hpMulPerFloor),
|
||||||
|
speedMul: cap(base.enemies.speedMul + f * FLOOR_SCALING.speedMulPerFloor),
|
||||||
|
bossHpMul: cap(base.enemies.bossHpMul + f * FLOOR_SCALING.bossHpMulPerFloor),
|
||||||
|
},
|
||||||
|
endless: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { OX, OY, TILE, COLS, ROWS, DOOR, SPAWN } from '../../config';
|
import { OX, OY, TILE, COLS, ROWS, DOOR, SPAWN } from '../../config';
|
||||||
import { Enemy } from '../entities/Enemy';
|
import { Enemy } from '../entities/Enemy';
|
||||||
|
import { Chest } from '../entities/Chest';
|
||||||
import { dist } from '../util';
|
import { dist } from '../util';
|
||||||
import type { Room } from '../world/Room';
|
import type { Room } from '../world/Room';
|
||||||
import type { Dir, EnemyType } from '../types';
|
import type { Dir, EnemyType } from '../types';
|
||||||
import type { Rng } from '../rng';
|
import type { Rng } from '../rng';
|
||||||
|
import type { WeaponId } from '../weapons';
|
||||||
import { DEFAULT_RULES, type LevelRules } from '../rules';
|
import { DEFAULT_RULES, type LevelRules } from '../rules';
|
||||||
|
|
||||||
function isSpawnSpotClear(
|
function isSpawnSpotClear(
|
||||||
@@ -29,6 +31,17 @@ function isSpawnSpotClear(
|
|||||||
* берутся из правил уровня (rules). Возвращает массив — вызывающий код кладёт
|
* берутся из правил уровня (rules). Возвращает массив — вызывающий код кладёт
|
||||||
* его в room.enemies.
|
* его в room.enemies.
|
||||||
*/
|
*/
|
||||||
|
function pickEnemyType(room: Room, rng: Rng, fastChance: number): EnemyType {
|
||||||
|
if (room.type === 'boss') return 'boss';
|
||||||
|
const roll = rng.next();
|
||||||
|
// normal: до 0.4, fast: 0.4-0.6, charger: 0.6-0.75, tank: 0.75-0.88, shooter: 0.88-1.0
|
||||||
|
if (roll < 0.4) return 'normal';
|
||||||
|
if (roll < 0.4 + fastChance * 0.7) return 'fast';
|
||||||
|
if (roll < 0.7) return 'charger';
|
||||||
|
if (roll < 0.85) return 'tank';
|
||||||
|
return 'shooter';
|
||||||
|
}
|
||||||
|
|
||||||
export function spawnEnemies(
|
export function spawnEnemies(
|
||||||
room: Room,
|
room: Room,
|
||||||
entryDir: Dir,
|
entryDir: Dir,
|
||||||
@@ -50,8 +63,7 @@ export function spawnEnemies(
|
|||||||
const doorY = OY + door.cy * TILE + TILE / 2;
|
const doorY = OY + door.cy * TILE + TILE / 2;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const type: EnemyType =
|
const type: EnemyType = pickEnemyType(room, rng, er.fastChance);
|
||||||
room.type === 'boss' ? 'boss' : rng.chance(er.fastChance) ? 'fast' : 'normal';
|
|
||||||
const mods = {
|
const mods = {
|
||||||
hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1),
|
hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1),
|
||||||
speedMul: er.speedMul,
|
speedMul: er.speedMul,
|
||||||
@@ -72,3 +84,15 @@ export function spawnEnemies(
|
|||||||
|
|
||||||
return enemies;
|
return enemies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TREASURE_WEAPONS: WeaponId[] = ['shotgun', 'axe', 'staff', 'whip', 'bomb', 'boomerang', 'laser'];
|
||||||
|
|
||||||
|
export function spawnChest(room: Room, rng: Rng): Chest {
|
||||||
|
const cx = OX + (COLS / 2) * TILE;
|
||||||
|
const cy = OY + (ROWS / 2) * TILE;
|
||||||
|
return new Chest(cx, cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickChestWeapon(rng: Rng): WeaponId {
|
||||||
|
return rng.pick(TREASURE_WEAPONS);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
|
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
|
||||||
export type Dir = 'up' | 'down' | 'left' | 'right';
|
export type Dir = 'up' | 'down' | 'left' | 'right';
|
||||||
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE
|
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE
|
||||||
export type EnemyType = 'normal' | 'fast' | 'boss';
|
export type EnemyType = 'normal' | 'fast' | 'boss' | 'charger' | 'tank' | 'shooter';
|
||||||
|
export type ProjectileType = 'tear' | 'fireball' | 'bomb' | 'boomerang' | 'laser' | 'beam';
|
||||||
|
|
||||||
/** Прямоугольник (axis-aligned bounding box) для коллизий. */
|
/** Прямоугольник (axis-aligned bounding box) для коллизий. */
|
||||||
export interface Box {
|
export interface Box {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type WeaponId = 'tears' | 'melee' | 'shotgun' | 'axe' | 'staff' | 'whip' | 'bomb' | 'boomerang' | 'laser';
|
||||||
|
|
||||||
|
export type ProjectileType = 'tear' | 'fireball' | 'bomb' | 'boomerang' | 'laser' | 'beam';
|
||||||
|
|
||||||
|
export interface WeaponDef {
|
||||||
|
id: WeaponId;
|
||||||
|
name: string;
|
||||||
|
type: 'ranged' | 'melee';
|
||||||
|
damage: number;
|
||||||
|
cooldown: number;
|
||||||
|
projectileType?: ProjectileType;
|
||||||
|
spreadCount?: number;
|
||||||
|
swingSizeMul?: number;
|
||||||
|
swingLife?: number;
|
||||||
|
knockback?: number;
|
||||||
|
fireDmg?: number;
|
||||||
|
fireInterval?: number;
|
||||||
|
fireDuration?: number;
|
||||||
|
explosionRadius?: number; // для бомбы
|
||||||
|
beamLife?: number; // длительность лазерного луча
|
||||||
|
beamRadius?: number; // радиус поражения луча
|
||||||
|
beamTickDmg?: number; // урон за тик луча
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEAPONS: Record<WeaponId, WeaponDef> = {
|
||||||
|
tears: { id: 'tears', name: 'Слёзы', type: 'ranged', damage: 1, cooldown: 10, projectileType: 'tear' },
|
||||||
|
melee: { id: 'melee', name: 'Кулак', type: 'melee', damage: 2, cooldown: 22 },
|
||||||
|
shotgun: { id: 'shotgun', name: 'Дробовик', type: 'ranged', damage: 1, cooldown: 18, projectileType: 'tear', spreadCount: 3 },
|
||||||
|
axe: { id: 'axe', name: 'Топор', type: 'melee', damage: 4, cooldown: 35, swingSizeMul: 1.5, swingLife: 15, knockback: 15 },
|
||||||
|
staff: { id: 'staff', name: 'Посох', type: 'ranged', damage: 1, cooldown: 20, projectileType: 'fireball', fireDmg: 1, fireInterval: 10, fireDuration: 50 },
|
||||||
|
whip: { id: 'whip', name: 'Хлыст', type: 'melee', damage: 3, cooldown: 18, swingSizeMul: 2.5, swingLife: 12, knockback: 16 },
|
||||||
|
bomb: { id: 'bomb', name: 'Бомба', type: 'ranged', damage: 0, cooldown: 35, projectileType: 'bomb', explosionRadius: 80 },
|
||||||
|
boomerang: { id: 'boomerang', name: 'Бумеранг', type: 'ranged', damage: 2, cooldown: 25, projectileType: 'boomerang' },
|
||||||
|
laser: { id: 'laser', name: 'Лазер', type: 'ranged', damage: 1, cooldown: 20, projectileType: 'beam', beamLife: 12, beamRadius: 44, beamTickDmg: 2 },
|
||||||
|
};
|
||||||
@@ -2,6 +2,8 @@ import type { RoomType, Doors } from '../types';
|
|||||||
import { buildTiles } from './tiles';
|
import { buildTiles } from './tiles';
|
||||||
import type { Enemy } from '../entities/Enemy';
|
import type { Enemy } from '../entities/Enemy';
|
||||||
import type { Projectile } from '../entities/Projectile';
|
import type { Projectile } from '../entities/Projectile';
|
||||||
|
import type { Chest } from '../entities/Chest';
|
||||||
|
import type { WeaponPickup } from '../entities/WeaponPickup';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/
|
* Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/
|
||||||
@@ -17,6 +19,8 @@ export class Room {
|
|||||||
cleared = false;
|
cleared = false;
|
||||||
enemies: Enemy[] = [];
|
enemies: Enemy[] = [];
|
||||||
tears: Projectile[] = [];
|
tears: Projectile[] = [];
|
||||||
|
chest: Chest | null = null;
|
||||||
|
pickup: WeaponPickup | null = null;
|
||||||
tiles: number[][];
|
tiles: number[][];
|
||||||
|
|
||||||
constructor(c: number, r: number, type: RoomType) {
|
constructor(c: number, r: number, type: RoomType) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface InputState {
|
|||||||
attackHeld: boolean; // атака «по ходу движения» (пробел)
|
attackHeld: boolean; // атака «по ходу движения» (пробел)
|
||||||
toggleWeapon: boolean; // сменить оружие (однократно)
|
toggleWeapon: boolean; // сменить оружие (однократно)
|
||||||
restart: boolean; // рестарт на экране конца игры (однократно)
|
restart: boolean; // рестарт на экране конца игры (однократно)
|
||||||
|
openInventory: boolean; // открыть инвентарь (однократно, E)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
|
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
|
||||||
@@ -33,6 +34,7 @@ export function emptyInput(): InputState {
|
|||||||
attackHeld: false,
|
attackHeld: false,
|
||||||
toggleWeapon: false,
|
toggleWeapon: false,
|
||||||
restart: false,
|
restart: false,
|
||||||
|
openInventory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class KeyboardController implements InputSource {
|
|||||||
private held = new Set<string>();
|
private held = new Set<string>();
|
||||||
private toggleWeaponEdge = false;
|
private toggleWeaponEdge = false;
|
||||||
private restartEdge = false;
|
private restartEdge = false;
|
||||||
|
private inventoryEdge = false;
|
||||||
private attached = false;
|
private attached = false;
|
||||||
|
|
||||||
private onKeyDown = (e: KeyboardEvent): void => {
|
private onKeyDown = (e: KeyboardEvent): void => {
|
||||||
@@ -26,6 +27,7 @@ export class KeyboardController implements InputSource {
|
|||||||
if (!this.held.has(c)) {
|
if (!this.held.has(c)) {
|
||||||
if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true;
|
if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true;
|
||||||
if (c === 'KeyR') this.restartEdge = true;
|
if (c === 'KeyR') this.restartEdge = true;
|
||||||
|
if (c === 'KeyE') this.inventoryEdge = true;
|
||||||
}
|
}
|
||||||
this.held.add(c);
|
this.held.add(c);
|
||||||
if (PREVENT.has(c)) e.preventDefault();
|
if (PREVENT.has(c)) e.preventDefault();
|
||||||
@@ -49,6 +51,7 @@ export class KeyboardController implements InputSource {
|
|||||||
this.held.clear();
|
this.held.clear();
|
||||||
this.toggleWeaponEdge = false;
|
this.toggleWeaponEdge = false;
|
||||||
this.restartEdge = false;
|
this.restartEdge = false;
|
||||||
|
this.inventoryEdge = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подписаться на события окна. Вызывается один раз при старте. */
|
/** Подписаться на события окна. Вызывается один раз при старте. */
|
||||||
@@ -84,15 +87,17 @@ export class KeyboardController implements InputSource {
|
|||||||
attackHeld: down('Space'),
|
attackHeld: down('Space'),
|
||||||
toggleWeapon: this.toggleWeaponEdge,
|
toggleWeapon: this.toggleWeaponEdge,
|
||||||
restart: this.restartEdge,
|
restart: this.restartEdge,
|
||||||
|
openInventory: this.inventoryEdge,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.toggleWeaponEdge = false;
|
this.toggleWeaponEdge = false;
|
||||||
this.restartEdge = false;
|
this.restartEdge = false;
|
||||||
|
this.inventoryEdge = false;
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */
|
/** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */
|
||||||
const PREVENT = new Set([
|
const PREVENT = new Set([
|
||||||
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab',
|
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab', 'KeyE',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ThreeRenderer } from './render/ThreeRenderer';
|
|||||||
import { HudOverlay } from './render/HudOverlay';
|
import { HudOverlay } from './render/HudOverlay';
|
||||||
import { GameLoop } from './engine/GameLoop';
|
import { GameLoop } from './engine/GameLoop';
|
||||||
import { StartMenu } from './ui/StartMenu';
|
import { StartMenu } from './ui/StartMenu';
|
||||||
|
import { ASSET_PACKS, type AssetPack } from './render/assetPacks';
|
||||||
|
|
||||||
function boot(): void {
|
function boot(): void {
|
||||||
const world = document.getElementById('game') as HTMLCanvasElement | null;
|
const world = document.getElementById('game') as HTMLCanvasElement | null;
|
||||||
@@ -24,21 +25,27 @@ function boot(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рендер и ввод создаём один раз — они переиспользуются между забегами.
|
// Ввод живёт всё приложение; рендер создаётся на забег, потому что зависит от пака ассетов.
|
||||||
const controller = new KeyboardController();
|
const controller = new KeyboardController();
|
||||||
const world3d = new ThreeRenderer(world);
|
|
||||||
const hud = new HudOverlay(hudCanvas);
|
|
||||||
controller.attach();
|
controller.attach();
|
||||||
|
|
||||||
let loop: GameLoop | null = null;
|
let loop: GameLoop | null = null;
|
||||||
|
let world3d: ThreeRenderer | null = null;
|
||||||
|
let hud: HudOverlay | null = null;
|
||||||
|
|
||||||
const startGame = (rules: LevelRules): void => {
|
const startGame = (rules: LevelRules, assetPack: AssetPack): void => {
|
||||||
loop?.stop();
|
loop?.stop();
|
||||||
|
world3d?.dispose();
|
||||||
|
hud?.dispose();
|
||||||
controller.reset(); // чистый ввод: не тащим зажатые клавиши/смену оружия из прошлого забега
|
controller.reset(); // чистый ввод: не тащим зажатые клавиши/смену оружия из прошлого забега
|
||||||
const game = new Game(rules);
|
const game = new Game(rules);
|
||||||
|
const currentWorld = new ThreeRenderer(world, assetPack.path);
|
||||||
|
const currentHud = new HudOverlay(hudCanvas, assetPack.path);
|
||||||
|
world3d = currentWorld;
|
||||||
|
hud = currentHud;
|
||||||
loop = new GameLoop(game, controller, (alpha) => {
|
loop = new GameLoop(game, controller, (alpha) => {
|
||||||
world3d.render(game, alpha);
|
currentWorld.render(game, alpha);
|
||||||
hud.render(game);
|
currentHud.render(game);
|
||||||
});
|
});
|
||||||
menu.hide();
|
menu.hide();
|
||||||
loop.start();
|
loop.start();
|
||||||
@@ -49,18 +56,37 @@ function boot(): void {
|
|||||||
const toMenu = (): void => {
|
const toMenu = (): void => {
|
||||||
loop?.stop();
|
loop?.stop();
|
||||||
loop = null;
|
loop = null;
|
||||||
|
world3d?.dispose();
|
||||||
|
hud?.dispose();
|
||||||
|
world3d = null;
|
||||||
|
hud = null;
|
||||||
menu.show(); // фон меню перекрывает «замёрзший» последний кадр
|
menu.show(); // фон меню перекрывает «замёрзший» последний кадр
|
||||||
};
|
};
|
||||||
|
|
||||||
const menu = new StartMenu(menuEl, PRESETS, startGame);
|
const menu = new StartMenu(menuEl, PRESETS, ASSET_PACKS, startGame);
|
||||||
menu.show();
|
menu.show();
|
||||||
|
|
||||||
// Esc во время игры — вернуться к выбору уровня (по физической клавише).
|
// Esc во время игры — вернуться к выбору уровня (по физической клавише).
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if (e.code === 'Escape' && loop) {
|
if (e.code === 'Escape' && loop) {
|
||||||
|
const g = (window as Window & { game?: Game }).game;
|
||||||
|
if (g?.inventoryOpen) {
|
||||||
|
g.closeInventory();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toMenu();
|
toMenu();
|
||||||
}
|
}
|
||||||
|
// Цифры 1-9 для экипировки оружия в инвентаре.
|
||||||
|
const digit = parseInt(e.code.replace('Digit', ''), 10);
|
||||||
|
if (digit >= 1 && digit <= 9 && loop) {
|
||||||
|
const g = (window as Window & { game?: Game }).game;
|
||||||
|
if (g?.inventoryOpen) {
|
||||||
|
g.equipSlot(digit - 1);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CW, CH, OY, RH, MODE_RANGED } from '../config';
|
import { CW, CH, OY, RH } from '../config';
|
||||||
import type { Game } from '../core/Game';
|
import type { Game } from '../core/Game';
|
||||||
import type { Renderer } from './Renderer';
|
import type { Renderer } from './Renderer';
|
||||||
|
|
||||||
@@ -10,19 +10,21 @@ import type { Renderer } from './Renderer';
|
|||||||
export class HudOverlay implements Renderer {
|
export class HudOverlay implements Renderer {
|
||||||
private readonly ctx: CanvasRenderingContext2D;
|
private readonly ctx: CanvasRenderingContext2D;
|
||||||
private readonly images = new Map<string, HTMLImageElement>();
|
private readonly images = new Map<string, HTMLImageElement>();
|
||||||
|
private readonly assetBasePath: string;
|
||||||
|
|
||||||
/** Лениво грузит PNG из assets/<name>.png; возвращает картинку, только когда она готова. */
|
/** Лениво грузит PNG из выбранного пака; возвращает картинку, только когда она готова. */
|
||||||
private img(name: string): HTMLImageElement | null {
|
private img(name: string): HTMLImageElement | null {
|
||||||
let im = this.images.get(name);
|
let im = this.images.get(name);
|
||||||
if (!im) {
|
if (!im) {
|
||||||
im = new Image();
|
im = new Image();
|
||||||
im.src = `assets/${name}.png`;
|
im.src = `${this.assetBasePath}/${name}.png`;
|
||||||
this.images.set(name, im);
|
this.images.set(name, im);
|
||||||
}
|
}
|
||||||
return im.complete && im.naturalWidth > 0 ? im : null;
|
return im.complete && im.naturalWidth > 0 ? im : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement) {
|
constructor(canvas: HTMLCanvasElement, assetBasePath = 'assets') {
|
||||||
|
this.assetBasePath = assetBasePath;
|
||||||
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
|
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
|
||||||
// а рисуем по-прежнему в логических координатах CW×CH.
|
// а рисуем по-прежнему в логических координатах CW×CH.
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
@@ -36,6 +38,11 @@ export class HudOverlay implements Renderer {
|
|||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
ctx.clearRect(0, 0, CW, CH);
|
ctx.clearRect(0, 0, CW, CH);
|
||||||
|
|
||||||
|
if (game.inventoryOpen) {
|
||||||
|
this.drawInventory(game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.drawHud(game);
|
this.drawHud(game);
|
||||||
this.drawMinimap(game);
|
this.drawMinimap(game);
|
||||||
|
|
||||||
@@ -55,22 +62,21 @@ export class HudOverlay implements Renderer {
|
|||||||
// Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу.
|
// Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу.
|
||||||
const healthBottom = this.drawHealth(p.hp, p.maxHp);
|
const healthBottom = this.drawHealth(p.hp, p.maxHp);
|
||||||
|
|
||||||
// Название текущего уровня (правил).
|
// Название пресета и номер этажа (для бесконечного спуска).
|
||||||
ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace';
|
ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace';
|
||||||
ctx.fillText(`Уровень: ${game.rules.name}`, 20, healthBottom + 14);
|
const floorLabel = game.rules.endless ? ` | Этаж ${game.floor}` : '';
|
||||||
|
ctx.fillText(`${game.rules.name}${floorLabel}`, 20, healthBottom + 14);
|
||||||
|
|
||||||
// Индикатор режима боя.
|
// Индикатор оружия.
|
||||||
const my = CH - 46;
|
const my = CH - 46;
|
||||||
const ranged = p.mode === MODE_RANGED;
|
const w = p.currentWeapon;
|
||||||
const mText = ranged ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ';
|
const wCol = w.type === 'ranged' ? '#4488cc' : '#cc6644';
|
||||||
const mCol = ranged ? '#4488cc' : '#cc6644';
|
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
|
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
|
||||||
ctx.strokeStyle = mCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
|
ctx.strokeStyle = wCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
|
||||||
ctx.fillStyle = mCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${mText} ]`, CW / 2, my + 8);
|
ctx.fillStyle = wCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${w.name} ]`, CW / 2, my + 8);
|
||||||
ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
|
ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
|
||||||
// Иконка оружия слева в рамке (если ассет есть).
|
const icon = this.img(w.type === 'ranged' ? 'icon-ranged' : 'icon-melee');
|
||||||
const icon = this.img(ranged ? 'icon-ranged' : 'icon-melee');
|
|
||||||
if (icon) ctx.drawImage(icon, CW / 2 - 90, my - 14, 26, 26);
|
if (icon) ctx.drawImage(icon, CW / 2 - 90, my - 14, 26, 26);
|
||||||
|
|
||||||
// Счётчик врагов / подсказка зачистки.
|
// Счётчик врагов / подсказка зачистки.
|
||||||
@@ -167,4 +173,48 @@ export class HudOverlay implements Renderer {
|
|||||||
ctx.fillStyle = '#666'; ctx.font = '14px monospace';
|
ctx.fillStyle = '#666'; ctx.font = '14px monospace';
|
||||||
ctx.fillText('[Esc] в меню', CW / 2, CH / 2 + 68);
|
ctx.fillText('[Esc] в меню', CW / 2, CH / 2 + 68);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private drawInventory(game: import('../core/Game').Game): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(0, 0, CW, CH);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ddd'; ctx.font = 'bold 28px monospace'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('ИНВЕНТАРЬ (2 слота)', CW / 2, 50);
|
||||||
|
|
||||||
|
const p = game.player;
|
||||||
|
const iw = 340, ih = 56, gap = 16;
|
||||||
|
const total = iw * 2 + gap;
|
||||||
|
const ox = CW / 2 - total / 2;
|
||||||
|
const oy = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const w = p.weapons[i];
|
||||||
|
const x = ox + i * (iw + gap);
|
||||||
|
const y = oy;
|
||||||
|
const selected = i === p.equipped;
|
||||||
|
|
||||||
|
ctx.fillStyle = selected ? '#1a2a1a' : '#111';
|
||||||
|
ctx.fillRect(x, y, iw, ih);
|
||||||
|
ctx.strokeStyle = selected ? '#4c4' : '#333';
|
||||||
|
ctx.lineWidth = selected ? 2 : 1;
|
||||||
|
ctx.strokeRect(x, y, iw, ih);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#888'; ctx.font = '14px monospace'; ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`[${i + 1}]`, x + 12, y + 34);
|
||||||
|
|
||||||
|
ctx.fillStyle = selected ? '#4c4' : '#ccc'; ctx.font = 'bold 16px monospace';
|
||||||
|
ctx.fillText(w.name, x + 48, y + 34);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#666'; ctx.font = '12px monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
const t = w.type === 'ranged' ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ';
|
||||||
|
ctx.fillText(`${t} DMG:${w.damage} CD:${w.cooldown}`, x + iw - 12, y + 34);
|
||||||
|
|
||||||
|
const icon = this.img(w.type === 'ranged' ? 'icon-ranged' : 'icon-melee');
|
||||||
|
if (icon) ctx.drawImage(icon, x + iw - 44, y + 16, 24, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#555'; ctx.font = '13px monospace'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('[E] закрыть | [1] [2] слот | [Tab] переключить', CW / 2, CH - 30);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Game } from '../core/Game';
|
|||||||
import type { Room } from '../core/world/Room';
|
import type { Room } from '../core/world/Room';
|
||||||
import type { Enemy } from '../core/entities/Enemy';
|
import type { Enemy } from '../core/entities/Enemy';
|
||||||
import type { Projectile } from '../core/entities/Projectile';
|
import type { Projectile } from '../core/entities/Projectile';
|
||||||
|
import type { WeaponId } from '../core/weapons';
|
||||||
import type { Renderer } from './Renderer';
|
import type { Renderer } from './Renderer';
|
||||||
import { DEFAULT_THEME, type Theme } from './theme';
|
import { DEFAULT_THEME, type Theme } from './theme';
|
||||||
import { Assets, type SpriteKey } from './assets';
|
import { Assets, type SpriteKey } from './assets';
|
||||||
@@ -39,7 +40,7 @@ export class ThreeRenderer implements Renderer {
|
|||||||
private readonly renderer: THREE.WebGLRenderer;
|
private readonly renderer: THREE.WebGLRenderer;
|
||||||
private readonly scene = new THREE.Scene();
|
private readonly scene = new THREE.Scene();
|
||||||
private readonly camera: THREE.PerspectiveCamera;
|
private readonly camera: THREE.PerspectiveCamera;
|
||||||
private readonly assets = new Assets();
|
private readonly assets: Assets;
|
||||||
private readonly theme: Theme;
|
private readonly theme: Theme;
|
||||||
|
|
||||||
// Общие геометрии.
|
// Общие геометрии.
|
||||||
@@ -53,6 +54,7 @@ export class ThreeRenderer implements Renderer {
|
|||||||
private readonly playerMat: Record<'ranged' | 'melee', THREE.MeshBasicMaterial>;
|
private readonly playerMat: Record<'ranged' | 'melee', THREE.MeshBasicMaterial>;
|
||||||
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
|
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
|
||||||
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss',
|
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss',
|
||||||
|
charger: 'enemy-charger', tank: 'enemy-tank', shooter: 'enemy-shooter',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Группа статичной геометрии комнаты (пол + стены + двери).
|
// Группа статичной геометрии комнаты (пол + стены + двери).
|
||||||
@@ -69,8 +71,14 @@ export class ThreeRenderer implements Renderer {
|
|||||||
private readonly effects: Effect[] = [];
|
private readonly effects: Effect[] = [];
|
||||||
private lastAtkCD = 0;
|
private lastAtkCD = 0;
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
|
private chestMesh: THREE.Mesh | null = null;
|
||||||
this.theme = theme;
|
private pickupMesh: THREE.Mesh | null = null;
|
||||||
|
private currentPickupWeapon: WeaponId | null = null;
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement, assetBasePathOrTheme: string | Theme = 'assets', theme: Theme = DEFAULT_THEME) {
|
||||||
|
const assetBasePath = typeof assetBasePathOrTheme === 'string' ? assetBasePathOrTheme : 'assets';
|
||||||
|
this.theme = typeof assetBasePathOrTheme === 'string' ? theme : assetBasePathOrTheme;
|
||||||
|
this.assets = new Assets(assetBasePath);
|
||||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
this.renderer.setSize(CW, CH, false);
|
this.renderer.setSize(CW, CH, false);
|
||||||
@@ -130,6 +138,8 @@ export class ThreeRenderer implements Renderer {
|
|||||||
this.syncEnemies(room, alpha);
|
this.syncEnemies(room, alpha);
|
||||||
this.syncTears(room, alpha);
|
this.syncTears(room, alpha);
|
||||||
this.syncSwing(game);
|
this.syncSwing(game);
|
||||||
|
this.syncChest(room);
|
||||||
|
this.syncPickup(room, game.elapsedSteps + alpha);
|
||||||
this.updateEffects();
|
this.updateEffects();
|
||||||
|
|
||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
@@ -239,18 +249,27 @@ export class ThreeRenderer implements Renderer {
|
|||||||
|
|
||||||
const x = lerp(e.prevX, e.x, alpha);
|
const x = lerp(e.prevX, e.x, alpha);
|
||||||
const z = lerp(e.prevY, e.y, alpha);
|
const z = lerp(e.prevY, e.y, alpha);
|
||||||
const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life)); // «дёргается» при попадании
|
const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life));
|
||||||
const w = e.w * SPRITE_SCALE * pop;
|
const w = e.w * SPRITE_SCALE * pop;
|
||||||
const h = w * SPRITE_ASPECT;
|
const h = w * SPRITE_ASPECT;
|
||||||
v.sprite.scale.set(w, h, 1);
|
v.sprite.scale.set(w, h, 1);
|
||||||
v.sprite.position.set(x, h / 2, z);
|
v.sprite.position.set(x, h / 2, z);
|
||||||
this.placeShadow(v.shadow, x, z, e.w);
|
this.placeShadow(v.shadow, x, z, e.w);
|
||||||
|
|
||||||
// Искра в момент попадания (hitTimer вырос).
|
// Искра в момент попадания (hitTimer вырос).
|
||||||
if (e.hitTimer > v.lastHit) {
|
if (e.hitTimer > v.lastHit) {
|
||||||
this.spawnEffect(this.assets.spark(), this.theme.flash, x, e.w * 0.6, z, e.w * 0.9, 8, { vy: 0.6, grow: 1.06 });
|
// Увеличенный эффект для смены фазы босса (15+ тиков)
|
||||||
|
const size = e.hitTimer >= 12 ? e.w * 1.5 : e.w * 0.9;
|
||||||
|
const life = e.hitTimer >= 12 ? 16 : 8;
|
||||||
|
this.spawnEffect(this.assets.puff(), 0xff6600, x, e.w * 0.6, z, size, life, { vy: 0.8, grow: 1.06 });
|
||||||
}
|
}
|
||||||
v.lastHit = e.hitTimer;
|
v.lastHit = e.hitTimer;
|
||||||
|
|
||||||
|
// Цвет подкраски по фазе босса.
|
||||||
|
let phaseTint = 0xffffff;
|
||||||
|
if (e.burnTimer > 0) phaseTint = 0xff6644;
|
||||||
|
else if (e.type === 'boss' && e.phase === 2) phaseTint = 0xff8844;
|
||||||
|
else if (e.type === 'boss' && e.phase >= 3) phaseTint = 0xff3300;
|
||||||
|
(v.sprite.material as THREE.MeshBasicMaterial).color.setHex(phaseTint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Уборка: исчезнувшие враги. Если враг мёртв — «пуф» на месте гибели.
|
// Уборка: исчезнувшие враги. Если враг мёртв — «пуф» на месте гибели.
|
||||||
@@ -272,15 +291,21 @@ export class ThreeRenderer implements Renderer {
|
|||||||
live.add(t);
|
live.add(t);
|
||||||
let mesh = this.tearMeshes.get(t);
|
let mesh = this.tearMeshes.get(t);
|
||||||
if (!mesh) {
|
if (!mesh) {
|
||||||
|
const tex =
|
||||||
|
t.type === 'fireball' ? this.assets.fireball() :
|
||||||
|
t.type === 'beam' ? this.assets.sprite('beam') :
|
||||||
|
this.assets.tear();
|
||||||
mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
|
mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
|
||||||
map: this.assets.tear(), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
map: tex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
}));
|
}));
|
||||||
const s = PROJECTILE.radius * 4;
|
const s = t.type === 'fireball' ? PROJECTILE.radius * 5 :
|
||||||
|
t.type === 'beam' ? 50 :
|
||||||
|
PROJECTILE.radius * 4;
|
||||||
mesh.scale.set(s, s, 1);
|
mesh.scale.set(s, s, 1);
|
||||||
this.scene.add(mesh);
|
this.scene.add(mesh);
|
||||||
this.tearMeshes.set(t, mesh);
|
this.tearMeshes.set(t, mesh);
|
||||||
}
|
}
|
||||||
mesh.position.set(lerp(t.prevX, t.x, alpha), TEAR_Y, lerp(t.prevY, t.y, alpha));
|
mesh.position.set(lerp(t.prevX, t.x, alpha), t.type === 'beam' ? 10 : TEAR_Y, lerp(t.prevY, t.y, alpha));
|
||||||
}
|
}
|
||||||
for (const [t, mesh] of this.tearMeshes) {
|
for (const [t, mesh] of this.tearMeshes) {
|
||||||
if (live.has(t)) continue;
|
if (live.has(t)) continue;
|
||||||
@@ -296,7 +321,56 @@ export class ThreeRenderer implements Renderer {
|
|||||||
this.swingMesh.visible = true;
|
this.swingMesh.visible = true;
|
||||||
this.swingMesh.position.set(s.box.x + s.box.w / 2, 2, s.box.y + s.box.h / 2);
|
this.swingMesh.position.set(s.box.x + s.box.w / 2, 2, s.box.y + s.box.h / 2);
|
||||||
this.swingMesh.scale.set(s.box.w * 1.4, s.box.h * 1.4, 1);
|
this.swingMesh.scale.set(s.box.w * 1.4, s.box.h * 1.4, 1);
|
||||||
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / MELEE.life);
|
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / s.maxLife);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncChest(room: Room): void {
|
||||||
|
if (room.chest?.alive) {
|
||||||
|
if (!this.chestMesh) {
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
map: this.assets.sprite('chest'), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
this.chestMesh = new THREE.Mesh(this.vGeo, mat);
|
||||||
|
this.scene.add(this.chestMesh);
|
||||||
|
}
|
||||||
|
const c = room.chest;
|
||||||
|
const w = c.w * 1.2;
|
||||||
|
const h = w * (64 / 48);
|
||||||
|
this.chestMesh.scale.set(w, h, 1);
|
||||||
|
this.chestMesh.position.set(c.x, h / 2, c.y);
|
||||||
|
} else if (this.chestMesh) {
|
||||||
|
this.scene.remove(this.chestMesh);
|
||||||
|
(this.chestMesh.material as THREE.Material).dispose();
|
||||||
|
this.chestMesh = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncPickup(room: Room, visualStep: number): void {
|
||||||
|
if (room.pickup) {
|
||||||
|
const wid = room.pickup.weaponId;
|
||||||
|
if (!this.pickupMesh || this.currentPickupWeapon !== wid) {
|
||||||
|
if (this.pickupMesh) {
|
||||||
|
this.scene.remove(this.pickupMesh);
|
||||||
|
(this.pickupMesh.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
map: this.assets.weaponIcon(wid), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
this.pickupMesh = new THREE.Mesh(this.vGeo, mat);
|
||||||
|
this.scene.add(this.pickupMesh);
|
||||||
|
this.currentPickupWeapon = wid;
|
||||||
|
}
|
||||||
|
const p = room.pickup;
|
||||||
|
const w = p.w * 1.6;
|
||||||
|
const h = w * (48 / 48);
|
||||||
|
this.pickupMesh.scale.set(w, h, 1);
|
||||||
|
this.pickupMesh.position.set(p.x, h / 2 + Math.sin(visualStep / 12) * 3, p.y);
|
||||||
|
} else if (this.pickupMesh) {
|
||||||
|
this.scene.remove(this.pickupMesh);
|
||||||
|
(this.pickupMesh.material as THREE.Material).dispose();
|
||||||
|
this.pickupMesh = null;
|
||||||
|
this.currentPickupWeapon = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Эффекты (частицы-биллборды) ───────────────────────────
|
// ── Эффекты (частицы-биллборды) ───────────────────────────
|
||||||
@@ -362,6 +436,8 @@ export class ThreeRenderer implements Renderer {
|
|||||||
for (const m of this.tearMeshes.values()) (m.material as THREE.Material).dispose();
|
for (const m of this.tearMeshes.values()) (m.material as THREE.Material).dispose();
|
||||||
for (const fx of this.effects) (fx.mesh.material as THREE.Material).dispose();
|
for (const fx of this.effects) (fx.mesh.material as THREE.Material).dispose();
|
||||||
(this.swingMesh.material as THREE.Material).dispose();
|
(this.swingMesh.material as THREE.Material).dispose();
|
||||||
|
if (this.chestMesh) (this.chestMesh.material as THREE.Material).dispose();
|
||||||
|
if (this.pickupMesh) (this.pickupMesh.material as THREE.Material).dispose();
|
||||||
this.floorMat.dispose();
|
this.floorMat.dispose();
|
||||||
this.wallMat.dispose();
|
this.wallMat.dispose();
|
||||||
this.shadowMat.dispose();
|
this.shadowMat.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/** Наборы ассетов. Логика игры про них не знает — это чисто настройка рендера/HUD. */
|
||||||
|
export type AssetPackId = 'classic' | 'binding-2';
|
||||||
|
|
||||||
|
export interface AssetPack {
|
||||||
|
id: AssetPackId;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ASSET_PACKS: readonly AssetPack[] = [
|
||||||
|
{ id: 'classic', name: 'Классика', path: 'assets' },
|
||||||
|
{ id: 'binding-2', name: 'Биндинг 2.0', path: 'assets-binding-2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_ASSET_PACK = ASSET_PACKS[0];
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import type { WeaponId } from '../core/weapons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* assets.ts — поставщик текстур. Каждая текстура грузится из
|
* assets.ts — поставщик текстур. Каждая текстура грузится из
|
||||||
* `src/assets/<ключ>.png` через THREE.TextureLoader; если PNG нет — рисуется
|
* `<пак>/<ключ>.png` через THREE.TextureLoader; если PNG нет — рисуется
|
||||||
* процедурный фолбэк на canvas (функции drawX ниже), чтобы игра работала без
|
* процедурный фолбэк на canvas (функции drawX ниже), чтобы игра работала без
|
||||||
* ассетов. Текстуры кэшируются и освобождаются в dispose().
|
* ассетов. Текстуры кэшируются и освобождаются в dispose().
|
||||||
*
|
*
|
||||||
* Как заменить/добавить графику: положи PNG с именем `<ключ>.png` в `src/assets/`
|
* Как заменить/добавить графику: положи PNG с именем `<ключ>.png` в папку пака
|
||||||
* (dev-сервер отдаёт их из src/assets, прод-сборка копирует в dist/assets). Код
|
* (dev-сервер отдаёт их из src/<asset-pack>, прод-сборка копирует в dist/). Код
|
||||||
* трогать не нужно. Функции drawX — это лишь плейсхолдер-фолбэк; правь их, только
|
* трогать не нужно. Функции drawX — это лишь плейсхолдер-фолбэк; правь их, только
|
||||||
* если хочешь другой запасной рисунок. Полный список ключей — в docs/ASSET_BRIEF.md.
|
* если хочешь другой запасной рисунок. Полный список ключей — в docs/ASSET_BRIEF.md.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type SpriteKey =
|
export type SpriteKey =
|
||||||
| 'player-ranged' | 'player-melee'
|
| 'player-ranged' | 'player-melee'
|
||||||
| 'enemy-normal' | 'enemy-fast' | 'enemy-boss';
|
| 'enemy-normal' | 'enemy-fast' | 'enemy-boss'
|
||||||
|
| 'enemy-charger' | 'enemy-tank' | 'enemy-shooter'
|
||||||
|
| 'chest' | 'pickup' | 'fireball' | 'beam';
|
||||||
|
|
||||||
function canvas(w: number, h: number): { cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
function canvas(w: number, h: number): { cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||||||
const cv = document.createElement('canvas');
|
const cv = document.createElement('canvas');
|
||||||
@@ -166,6 +169,183 @@ function drawDoor(open: boolean): HTMLCanvasElement {
|
|||||||
return cv;
|
return cv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сундук: тёмный ящик с золотым ободком. */
|
||||||
|
function drawChest(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
ctx.fillStyle = '#5a3a1a';
|
||||||
|
roundRect(ctx, 4, 4, 40, 40, 4);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#8a6a2a';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
roundRect(ctx, 4, 4, 40, 40, 4);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#c9a84a';
|
||||||
|
ctx.fillRect(12, 18, 24, 10);
|
||||||
|
ctx.fillStyle = '#8a6a2a';
|
||||||
|
ctx.fillRect(22, 14, 4, 18);
|
||||||
|
ctx.fillStyle = '#3a220a';
|
||||||
|
ctx.beginPath(); ctx.arc(24, 24, 4, 0, Math.PI * 2); ctx.fill();
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пикап оружия: парящий ромб со свечением. */
|
||||||
|
function drawPickup(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(32, 32);
|
||||||
|
const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 15);
|
||||||
|
g.addColorStop(0, '#ffdd88');
|
||||||
|
g.addColorStop(0.4, '#cc8822');
|
||||||
|
g.addColorStop(1, 'rgba(200,100,0,0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, 32, 32);
|
||||||
|
ctx.fillStyle = '#ffcc44';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(16, 4); ctx.lineTo(28, 16); ctx.lineTo(16, 28); ctx.lineTo(4, 16); ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#aa6600';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Зарядчик: красный, агрессивный вид, щель глаза. */
|
||||||
|
function drawCharger(): HTMLCanvasElement {
|
||||||
|
return drawCharacter({ head: '#cc4422', body: '#882211', outline: '#330a04', eye: '#ffaa00', small: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Танк: большой, тёмный, тяжёлый. */
|
||||||
|
function drawTank(): HTMLCanvasElement {
|
||||||
|
return drawCharacter({ head: '#554433', body: '#443322', outline: '#1a110a', eye: '#ff6622', horns: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стрелок: синеватый, с «шапкой». */
|
||||||
|
function drawShooter(): HTMLCanvasElement {
|
||||||
|
return drawCharacter({ head: '#4488aa', body: '#336688', outline: '#122436', eye: '#aaddff', small: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лазерный луч: яркая бело-голубая полоса. */
|
||||||
|
function drawBeam(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(64, 64);
|
||||||
|
const g = ctx.createRadialGradient(32, 32, 2, 32, 32, 30);
|
||||||
|
g.addColorStop(0, '#ffffff');
|
||||||
|
g.addColorStop(0.2, '#88ddff');
|
||||||
|
g.addColorStop(0.5, '#4488ff');
|
||||||
|
g.addColorStop(1, 'rgba(0,50,200,0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, 64, 64);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
|
ctx.fillRect(20, 28, 24, 8);
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Огненный шар: красно-оранжевый с бликом. */
|
||||||
|
function drawFireball(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(32, 32);
|
||||||
|
const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 14);
|
||||||
|
g.addColorStop(0, '#ffee88');
|
||||||
|
g.addColorStop(0.3, '#ff6622');
|
||||||
|
g.addColorStop(0.7, '#cc2200');
|
||||||
|
g.addColorStop(1, 'rgba(100,0,0,0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.beginPath(); ctx.arc(16, 16, 14, 0, Math.PI * 2); ctx.fill();
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: слеза (голубая капля). */
|
||||||
|
function drawWeaponTears(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
const g = ctx.createRadialGradient(24, 24, 2, 24, 24, 20);
|
||||||
|
g.addColorStop(0, '#dff0ff'); g.addColorStop(0.5, '#6699cc'); g.addColorStop(1, 'rgba(40,80,140,0)');
|
||||||
|
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(24, 24, 20, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#aaccee'; ctx.beginPath(); ctx.arc(20, 18, 6, 0, Math.PI * 2); ctx.fill();
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: кулак. */
|
||||||
|
function drawWeaponMelee(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
ctx.fillStyle = '#8a6a3a';
|
||||||
|
roundRect(ctx, 10, 14, 28, 24, 6); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#4a2a0a'; ctx.lineWidth = 2; roundRect(ctx, 10, 14, 28, 24, 6); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#6a4a1a'; ctx.fillRect(14, 20, 8, 8); ctx.fillRect(26, 20, 8, 8);
|
||||||
|
ctx.fillRect(18, 30, 12, 6);
|
||||||
|
ctx.fillStyle = '#5a3a0a'; ctx.fillRect(20, 6, 8, 12);
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: дробовик — три точки. */
|
||||||
|
function drawWeaponShotgun(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
for (const [x, y] of [[24, 16], [16, 30], [32, 30]]) {
|
||||||
|
const g = ctx.createRadialGradient(x, y, 1, x, y, 10);
|
||||||
|
g.addColorStop(0, '#ffcc44'); g.addColorStop(0.5, '#cc6622'); g.addColorStop(1, 'rgba(150,60,0,0)');
|
||||||
|
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, 10, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: топор. */
|
||||||
|
function drawWeaponAxe(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
ctx.fillStyle = '#777';
|
||||||
|
ctx.beginPath(); ctx.moveTo(8, 16); ctx.lineTo(38, 12); ctx.lineTo(40, 22); ctx.lineTo(30, 22); ctx.lineTo(30, 38); ctx.lineTo(16, 38); ctx.lineTo(16, 22); ctx.lineTo(6, 22); ctx.closePath(); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.stroke();
|
||||||
|
ctx.fillStyle = '#5a3a0a'; ctx.fillRect(22, 34, 4, 12);
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: посох. */
|
||||||
|
function drawWeaponStaff(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
ctx.fillStyle = '#6a4a2a'; ctx.fillRect(22, 6, 4, 36);
|
||||||
|
ctx.fillStyle = '#ff4422';
|
||||||
|
ctx.beginPath(); ctx.arc(24, 10, 10, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffcc44';
|
||||||
|
ctx.beginPath(); ctx.arc(24, 10, 5, 0, Math.PI * 2); ctx.fill();
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: хлыст. */
|
||||||
|
function drawWeaponWhip(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
ctx.strokeStyle = '#8a6a3a'; ctx.lineWidth = 4; ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath(); ctx.moveTo(10, 38); ctx.quadraticCurveTo(18, 12, 38, 14); ctx.stroke();
|
||||||
|
ctx.strokeStyle = '#5a3a0a'; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(10, 38); ctx.quadraticCurveTo(18, 12, 38, 14); ctx.stroke();
|
||||||
|
ctx.fillStyle = '#4a2a0a'; ctx.fillRect(6, 34, 8, 8);
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: бомба. */
|
||||||
|
function drawWeaponBomb(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
ctx.fillStyle = '#333'; ctx.beginPath(); ctx.arc(24, 24, 16, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#555'; ctx.beginPath(); ctx.arc(24, 24, 10, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#cc4422'; ctx.fillRect(22, 4, 4, 8);
|
||||||
|
ctx.fillStyle = '#ff8844'; ctx.beginPath(); ctx.arc(24, 4, 4, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(24, 24, 16, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: бумеранг. */
|
||||||
|
function drawWeaponBoomerang(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
ctx.fillStyle = '#8a6a3a';
|
||||||
|
ctx.beginPath(); ctx.moveTo(8, 36); ctx.lineTo(22, 16); ctx.lineTo(40, 6); ctx.lineTo(38, 18); ctx.lineTo(22, 28); ctx.lineTo(18, 36); ctx.closePath(); ctx.fill();
|
||||||
|
ctx.strokeStyle = '#4a2a0a'; ctx.lineWidth = 2; ctx.stroke();
|
||||||
|
ctx.fillStyle = '#a08850'; ctx.fillRect(8, 30, 12, 8);
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Иконка оружия: лазер. */
|
||||||
|
function drawWeaponLaser(): HTMLCanvasElement {
|
||||||
|
const { cv, ctx } = canvas(48, 48);
|
||||||
|
const g = ctx.createLinearGradient(8, 24, 40, 24);
|
||||||
|
g.addColorStop(0, 'rgba(100,180,255,0.2)'); g.addColorStop(0.3, '#88ddff'); g.addColorStop(0.5, '#ffffff'); g.addColorStop(0.7, '#88ddff'); g.addColorStop(1, 'rgba(100,180,255,0.2)');
|
||||||
|
ctx.fillStyle = g; ctx.fillRect(8, 18, 32, 12);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.fillRect(12, 22, 24, 4);
|
||||||
|
return cv;
|
||||||
|
}
|
||||||
|
|
||||||
/** Мягкая тень-«пятно» под сущностью. */
|
/** Мягкая тень-«пятно» под сущностью. */
|
||||||
function drawShadow(): HTMLCanvasElement {
|
function drawShadow(): HTMLCanvasElement {
|
||||||
const { cv, ctx } = canvas(64, 32);
|
const { cv, ctx } = canvas(64, 32);
|
||||||
@@ -184,9 +364,11 @@ export class Assets {
|
|||||||
private cache = new Map<string, THREE.Texture>();
|
private cache = new Map<string, THREE.Texture>();
|
||||||
private readonly loader = new THREE.TextureLoader();
|
private readonly loader = new THREE.TextureLoader();
|
||||||
|
|
||||||
|
constructor(private readonly basePath = 'assets') {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает текстуру по ключу. Сначала пытается загрузить PNG из
|
* Возвращает текстуру по ключу. Сначала пытается загрузить PNG из
|
||||||
* `src/assets/<key>.png` (поставляется художником, см. docs/ASSET_BRIEF.md);
|
* `<basePath>/<key>.png` (поставляется художником, см. docs/ASSET_BRIEF.md);
|
||||||
* если файла нет — рисует процедурный фолбэк, чтобы игра не ломалась.
|
* если файла нет — рисует процедурный фолбэк, чтобы игра не ломалась.
|
||||||
* 404 в консоли для ещё не добавленных ассетов — это норма (сработал фолбэк).
|
* 404 в консоли для ещё не добавленных ассетов — это норма (сработал фолбэк).
|
||||||
*/
|
*/
|
||||||
@@ -195,7 +377,7 @@ export class Assets {
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const tex = this.loader.load(
|
const tex = this.loader.load(
|
||||||
`assets/${key}.png`,
|
`${this.basePath}/${key}.png`,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
() => { tex.image = build() as unknown as HTMLImageElement; tex.needsUpdate = true; }, // PNG нет → процедурный фолбэк
|
() => { tex.image = build() as unknown as HTMLImageElement; tex.needsUpdate = true; }, // PNG нет → процедурный фолбэк
|
||||||
@@ -217,6 +399,13 @@ export class Assets {
|
|||||||
case 'enemy-normal': return drawCharacter({ head: '#c08a5a', body: '#9a5a36', outline: '#3a2210', eye: '#2a1c0c' });
|
case 'enemy-normal': return drawCharacter({ head: '#c08a5a', body: '#9a5a36', outline: '#3a2210', eye: '#2a1c0c' });
|
||||||
case 'enemy-fast': return drawCharacter({ head: '#bb3030', body: '#992222', outline: '#4a0e0e', eye: '#ffdddd', small: true });
|
case 'enemy-fast': return drawCharacter({ head: '#bb3030', body: '#992222', outline: '#4a0e0e', eye: '#ffdddd', small: true });
|
||||||
case 'enemy-boss': return drawCharacter({ head: '#7a1414', body: '#5a0a0a', outline: '#250303', eye: '#ff4444', horns: true });
|
case 'enemy-boss': return drawCharacter({ head: '#7a1414', body: '#5a0a0a', outline: '#250303', eye: '#ff4444', horns: true });
|
||||||
|
case 'enemy-charger': return drawCharger();
|
||||||
|
case 'enemy-tank': return drawTank();
|
||||||
|
case 'enemy-shooter': return drawShooter();
|
||||||
|
case 'chest': return drawChest();
|
||||||
|
case 'pickup': return drawPickup();
|
||||||
|
case 'fireball': return drawFireball();
|
||||||
|
case 'beam': return drawBeam();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -229,6 +418,25 @@ export class Assets {
|
|||||||
muzzle(): THREE.Texture { return this.get('muzzle', () => drawGlow('#fffbe0', 'rgba(255,200,60,0.7)'), false); }
|
muzzle(): THREE.Texture { return this.get('muzzle', () => drawGlow('#fffbe0', 'rgba(255,200,60,0.7)'), false); }
|
||||||
spark(): THREE.Texture { return this.get('spark', () => drawGlow('#ffffff', 'rgba(255,230,170,0.6)'), false); }
|
spark(): THREE.Texture { return this.get('spark', () => drawGlow('#ffffff', 'rgba(255,230,170,0.6)'), false); }
|
||||||
puff(): THREE.Texture { return this.get('puff', () => drawGlow('rgba(220,220,230,0.9)', 'rgba(120,120,140,0.4)'), false); }
|
puff(): THREE.Texture { return this.get('puff', () => drawGlow('rgba(220,220,230,0.9)', 'rgba(120,120,140,0.4)'), false); }
|
||||||
|
chest(): THREE.Texture { return this.get('chest', drawChest, false); }
|
||||||
|
pickup(): THREE.Texture { return this.get('pickup', drawPickup, false); }
|
||||||
|
fireball(): THREE.Texture { return this.get('fireball', drawFireball, false); }
|
||||||
|
|
||||||
|
weaponIcon(id: WeaponId): THREE.Texture {
|
||||||
|
return this.get(`weapon-icon-${id}`, () => {
|
||||||
|
switch (id) {
|
||||||
|
case 'tears': return drawWeaponTears();
|
||||||
|
case 'melee': return drawWeaponMelee();
|
||||||
|
case 'shotgun': return drawWeaponShotgun();
|
||||||
|
case 'axe': return drawWeaponAxe();
|
||||||
|
case 'staff': return drawWeaponStaff();
|
||||||
|
case 'whip': return drawWeaponWhip();
|
||||||
|
case 'bomb': return drawWeaponBomb();
|
||||||
|
case 'boomerang': return drawWeaponBoomerang();
|
||||||
|
case 'laser': return drawWeaponLaser();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const t of this.cache.values()) t.dispose();
|
for (const t of this.cache.values()) t.dispose();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LevelRules } from '../core/rules';
|
import type { LevelRules } from '../core/rules';
|
||||||
|
import { DEFAULT_ASSET_PACK, type AssetPack } from '../render/assetPacks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стартовое меню на DOM (поверх холстов). Показывает список пресетов-уровней;
|
* Стартовое меню на DOM (поверх холстов). Показывает список пресетов-уровней;
|
||||||
@@ -10,12 +11,38 @@ import type { LevelRules } from '../core/rules';
|
|||||||
*/
|
*/
|
||||||
export class StartMenu {
|
export class StartMenu {
|
||||||
private readonly root: HTMLElement;
|
private readonly root: HTMLElement;
|
||||||
|
private selectedPack: AssetPack;
|
||||||
|
|
||||||
constructor(root: HTMLElement, presets: LevelRules[], onStart: (rules: LevelRules) => void) {
|
constructor(
|
||||||
|
root: HTMLElement,
|
||||||
|
presets: LevelRules[],
|
||||||
|
assetPacks: readonly AssetPack[],
|
||||||
|
onStart: (rules: LevelRules, assetPack: AssetPack) => void,
|
||||||
|
) {
|
||||||
this.root = root;
|
this.root = root;
|
||||||
|
this.selectedPack = assetPacks[0] ?? DEFAULT_ASSET_PACK;
|
||||||
const list = root.querySelector('#menu-presets');
|
const list = root.querySelector('#menu-presets');
|
||||||
if (!list) throw new Error('StartMenu: не найден #menu-presets внутри #menu');
|
if (!list) throw new Error('StartMenu: не найден #menu-presets внутри #menu');
|
||||||
|
|
||||||
|
const packs = document.createElement('div');
|
||||||
|
packs.className = 'menu-packs';
|
||||||
|
const packButtons: HTMLButtonElement[] = [];
|
||||||
|
for (const pack of assetPacks) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'menu-pack';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = pack.name;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.selectedPack = pack;
|
||||||
|
for (const b of packButtons) b.classList.toggle('is-selected', b === btn);
|
||||||
|
this.applyPackPreview();
|
||||||
|
});
|
||||||
|
packButtons.push(btn);
|
||||||
|
packs.appendChild(btn);
|
||||||
|
}
|
||||||
|
packButtons[0]?.classList.add('is-selected');
|
||||||
|
list.before(packs);
|
||||||
|
|
||||||
for (const rules of presets) {
|
for (const rules of presets) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'menu-preset';
|
btn.className = 'menu-preset';
|
||||||
@@ -30,9 +57,11 @@ export class StartMenu {
|
|||||||
desc.textContent = rules.description;
|
desc.textContent = rules.description;
|
||||||
|
|
||||||
btn.append(name, desc);
|
btn.append(name, desc);
|
||||||
btn.addEventListener('click', () => onStart(rules));
|
btn.addEventListener('click', () => onStart(rules, this.selectedPack));
|
||||||
list.appendChild(btn);
|
list.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.applyPackPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
show(): void {
|
show(): void {
|
||||||
@@ -42,4 +71,12 @@ export class StartMenu {
|
|||||||
hide(): void {
|
hide(): void {
|
||||||
this.root.style.display = 'none';
|
this.root.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyPackPreview(): void {
|
||||||
|
const path = this.selectedPack.path;
|
||||||
|
const logo = this.root.querySelector<HTMLImageElement>('#menu-logo');
|
||||||
|
if (logo) logo.src = `${path}/logo.png`;
|
||||||
|
this.root.style.background =
|
||||||
|
`linear-gradient(rgba(10,10,15,0.7), rgba(10,10,15,0.82)), url(${path}/menu-bg.png) center / cover no-repeat, #0a0a0f`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,4 +133,51 @@ describe('Game', () => {
|
|||||||
expect(game.cc).toBe(0);
|
expect(game.cc).toBe(0);
|
||||||
expect(game.cr).toBe(0);
|
expect(game.cr).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('РЕГРЕССИЯ: бесконечный спуск применяет усиление врагов при спавне комнат', () => {
|
||||||
|
const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 100 };
|
||||||
|
const game = new Game(endless);
|
||||||
|
|
||||||
|
for (let targetFloor = 2; targetFloor <= 5; targetFloor++) {
|
||||||
|
const bossRoom = [...game.roomMap.rooms.values()].find((room) => room.type === 'boss')!;
|
||||||
|
bossRoom.cleared = true;
|
||||||
|
game.step(input());
|
||||||
|
expect(game.floor).toBe(targetFloor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bossRoom = [...game.roomMap.rooms.values()].find((room) => room.type === 'boss')!;
|
||||||
|
game.cc = bossRoom.c;
|
||||||
|
game.cr = bossRoom.r;
|
||||||
|
game.enterRoom('up');
|
||||||
|
|
||||||
|
const boss = bossRoom.enemies.find((e) => e.type === 'boss')!;
|
||||||
|
expect(boss.maxHp).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('снаряд берёт урон из выбранного оружия', () => {
|
||||||
|
const game = new Game(DEFAULT_RULES, new Rng(12));
|
||||||
|
game.player.addWeapon('boomerang');
|
||||||
|
|
||||||
|
game.step(input({ aimDir: 'right' }));
|
||||||
|
|
||||||
|
expect(game.curRoom.tears[0].type).toBe('boomerang');
|
||||||
|
expect(game.curRoom.tears[0].damage).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('РЕГРЕССИЯ: эффект снаряда не зависит от смены оружия после выстрела', () => {
|
||||||
|
const game = new Game(DEFAULT_RULES, new Rng(13));
|
||||||
|
game.player.addWeapon('staff');
|
||||||
|
|
||||||
|
const enemy = new Enemy(game.player.x + 7, game.player.y, 'tank');
|
||||||
|
game.curRoom.enemies = [enemy];
|
||||||
|
|
||||||
|
game.step(input({ aimDir: 'right' }));
|
||||||
|
expect(enemy.burnTimer).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const hpAfterHit = enemy.hp;
|
||||||
|
game.player.addWeapon('tears');
|
||||||
|
for (let i = 0; i < 10; i++) game.step(input());
|
||||||
|
|
||||||
|
expect(enemy.hp).toBeLessThan(hpAfterHit);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||