Add Binding 2.0 asset pack
|
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 |
@@ -13,6 +13,7 @@ import { ThreeRenderer } from './render/ThreeRenderer';
|
||||
import { HudOverlay } from './render/HudOverlay';
|
||||
import { GameLoop } from './engine/GameLoop';
|
||||
import { StartMenu } from './ui/StartMenu';
|
||||
import { ASSET_PACKS, type AssetPack } from './render/assetPacks';
|
||||
|
||||
function boot(): void {
|
||||
const world = document.getElementById('game') as HTMLCanvasElement | null;
|
||||
@@ -24,21 +25,27 @@ function boot(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Рендер и ввод создаём один раз — они переиспользуются между забегами.
|
||||
// Ввод живёт всё приложение; рендер создаётся на забег, потому что зависит от пака ассетов.
|
||||
const controller = new KeyboardController();
|
||||
const world3d = new ThreeRenderer(world);
|
||||
const hud = new HudOverlay(hudCanvas);
|
||||
controller.attach();
|
||||
|
||||
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();
|
||||
world3d?.dispose();
|
||||
hud?.dispose();
|
||||
controller.reset(); // чистый ввод: не тащим зажатые клавиши/смену оружия из прошлого забега
|
||||
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) => {
|
||||
world3d.render(game, alpha);
|
||||
hud.render(game);
|
||||
currentWorld.render(game, alpha);
|
||||
currentHud.render(game);
|
||||
});
|
||||
menu.hide();
|
||||
loop.start();
|
||||
@@ -49,10 +56,14 @@ function boot(): void {
|
||||
const toMenu = (): void => {
|
||||
loop?.stop();
|
||||
loop = null;
|
||||
world3d?.dispose();
|
||||
hud?.dispose();
|
||||
world3d = null;
|
||||
hud = null;
|
||||
menu.show(); // фон меню перекрывает «замёрзший» последний кадр
|
||||
};
|
||||
|
||||
const menu = new StartMenu(menuEl, PRESETS, startGame);
|
||||
const menu = new StartMenu(menuEl, PRESETS, ASSET_PACKS, startGame);
|
||||
menu.show();
|
||||
|
||||
// Esc во время игры — вернуться к выбору уровня (по физической клавише).
|
||||
|
||||
@@ -10,19 +10,21 @@ import type { Renderer } from './Renderer';
|
||||
export class HudOverlay implements Renderer {
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
private readonly images = new Map<string, HTMLImageElement>();
|
||||
private readonly assetBasePath: string;
|
||||
|
||||
/** Лениво грузит PNG из assets/<name>.png; возвращает картинку, только когда она готова. */
|
||||
/** Лениво грузит PNG из выбранного пака; возвращает картинку, только когда она готова. */
|
||||
private img(name: string): HTMLImageElement | null {
|
||||
let im = this.images.get(name);
|
||||
if (!im) {
|
||||
im = new Image();
|
||||
im.src = `assets/${name}.png`;
|
||||
im.src = `${this.assetBasePath}/${name}.png`;
|
||||
this.images.set(name, im);
|
||||
}
|
||||
return im.complete && im.naturalWidth > 0 ? im : null;
|
||||
}
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
constructor(canvas: HTMLCanvasElement, assetBasePath = 'assets') {
|
||||
this.assetBasePath = assetBasePath;
|
||||
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
|
||||
// а рисуем по-прежнему в логических координатах CW×CH.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
@@ -40,7 +40,7 @@ export class ThreeRenderer implements Renderer {
|
||||
private readonly renderer: THREE.WebGLRenderer;
|
||||
private readonly scene = new THREE.Scene();
|
||||
private readonly camera: THREE.PerspectiveCamera;
|
||||
private readonly assets = new Assets();
|
||||
private readonly assets: Assets;
|
||||
private readonly theme: Theme;
|
||||
|
||||
// Общие геометрии.
|
||||
@@ -75,8 +75,10 @@ export class ThreeRenderer implements Renderer {
|
||||
private pickupMesh: THREE.Mesh | null = null;
|
||||
private currentPickupWeapon: WeaponId | null = null;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
|
||||
this.theme = theme;
|
||||
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.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
this.renderer.setSize(CW, CH, false);
|
||||
|
||||
@@ -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];
|
||||
@@ -3,12 +3,12 @@ import type { WeaponId } from '../core/weapons';
|
||||
|
||||
/**
|
||||
* assets.ts — поставщик текстур. Каждая текстура грузится из
|
||||
* `src/assets/<ключ>.png` через THREE.TextureLoader; если PNG нет — рисуется
|
||||
* `<пак>/<ключ>.png` через THREE.TextureLoader; если PNG нет — рисуется
|
||||
* процедурный фолбэк на canvas (функции drawX ниже), чтобы игра работала без
|
||||
* ассетов. Текстуры кэшируются и освобождаются в dispose().
|
||||
*
|
||||
* Как заменить/добавить графику: положи PNG с именем `<ключ>.png` в `src/assets/`
|
||||
* (dev-сервер отдаёт их из src/assets, прод-сборка копирует в dist/assets). Код
|
||||
* Как заменить/добавить графику: положи PNG с именем `<ключ>.png` в папку пака
|
||||
* (dev-сервер отдаёт их из src/<asset-pack>, прод-сборка копирует в dist/). Код
|
||||
* трогать не нужно. Функции drawX — это лишь плейсхолдер-фолбэк; правь их, только
|
||||
* если хочешь другой запасной рисунок. Полный список ключей — в docs/ASSET_BRIEF.md.
|
||||
*/
|
||||
@@ -364,9 +364,11 @@ export class Assets {
|
||||
private cache = new Map<string, THREE.Texture>();
|
||||
private readonly loader = new THREE.TextureLoader();
|
||||
|
||||
constructor(private readonly basePath = 'assets') {}
|
||||
|
||||
/**
|
||||
* Возвращает текстуру по ключу. Сначала пытается загрузить PNG из
|
||||
* `src/assets/<key>.png` (поставляется художником, см. docs/ASSET_BRIEF.md);
|
||||
* `<basePath>/<key>.png` (поставляется художником, см. docs/ASSET_BRIEF.md);
|
||||
* если файла нет — рисует процедурный фолбэк, чтобы игра не ломалась.
|
||||
* 404 в консоли для ещё не добавленных ассетов — это норма (сработал фолбэк).
|
||||
*/
|
||||
@@ -375,7 +377,7 @@ export class Assets {
|
||||
if (cached) return cached;
|
||||
|
||||
const tex = this.loader.load(
|
||||
`assets/${key}.png`,
|
||||
`${this.basePath}/${key}.png`,
|
||||
undefined,
|
||||
undefined,
|
||||
() => { tex.image = build() as unknown as HTMLImageElement; tex.needsUpdate = true; }, // PNG нет → процедурный фолбэк
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LevelRules } from '../core/rules';
|
||||
import { DEFAULT_ASSET_PACK, type AssetPack } from '../render/assetPacks';
|
||||
|
||||
/**
|
||||
* Стартовое меню на DOM (поверх холстов). Показывает список пресетов-уровней;
|
||||
@@ -10,12 +11,38 @@ import type { LevelRules } from '../core/rules';
|
||||
*/
|
||||
export class StartMenu {
|
||||
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.selectedPack = assetPacks[0] ?? DEFAULT_ASSET_PACK;
|
||||
const list = root.querySelector('#menu-presets');
|
||||
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) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'menu-preset';
|
||||
@@ -30,9 +57,11 @@ export class StartMenu {
|
||||
desc.textContent = rules.description;
|
||||
|
||||
btn.append(name, desc);
|
||||
btn.addEventListener('click', () => onStart(rules));
|
||||
btn.addEventListener('click', () => onStart(rules, this.selectedPack));
|
||||
list.appendChild(btn);
|
||||
}
|
||||
|
||||
this.applyPackPreview();
|
||||
}
|
||||
|
||||
show(): void {
|
||||
@@ -42,4 +71,12 @@ export class StartMenu {
|
||||
hide(): void {
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||