From 7e6b50d1bc55322147cf8fc2bf942757c9599d8f Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Wed, 9 Sep 2026 19:32:25 +0300 Subject: [PATCH] =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA.=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=80=D0=B0=D0=BC=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.ts | 7 +++ src/app/MainMenu.ts | 18 ++++++- src/app/SettingsScreen.ts | 57 ++++++++++++++++++++++ src/pixi/DebugCharacterPreview.ts | 4 +- src/pixi/GameCanvas.ts | 4 +- src/pixi/PixelPerfectWorld.ts | 79 ++++++++++++++++++++++++++----- src/pixi/renderSettings.ts | 57 ++++++++++++++++++++++ 7 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 src/app/SettingsScreen.ts create mode 100644 src/pixi/renderSettings.ts diff --git a/src/app/App.ts b/src/app/App.ts index bcd2031..1a021cb 100644 --- a/src/app/App.ts +++ b/src/app/App.ts @@ -7,6 +7,7 @@ import { GameStore } from '../game/GameStore'; import { clearSave, hasSave } from '../game/persistence'; import { GameScreen } from './GameScreen'; import { createMainMenu } from './MainMenu'; +import { createSettingsScreen } from './SettingsScreen'; /** Top-level screen controller (menu <-> game) — the plain-TS replacement for App.tsx. Owns the * GameStore/Analytics singletons for the page's whole lifetime, same as the original module-level @@ -46,10 +47,16 @@ export class App { this.analytics, () => this.handleNewGame(), () => this.handleContinue(), + () => this.showSettings(), ); this.root.replaceChildren(menu); } + private showSettings(): void { + const settings = createSettingsScreen(() => this.showMenu()); + this.root.replaceChildren(settings); + } + private showGame(): void { this.gameScreen = new GameScreen( this.store, diff --git a/src/app/MainMenu.ts b/src/app/MainMenu.ts index 313de3e..f001669 100644 --- a/src/app/MainMenu.ts +++ b/src/app/MainMenu.ts @@ -5,7 +5,12 @@ import { mountDebugCharacterPreview } from '../pixi/DebugCharacterPreview'; /** Builds the main menu screen — the DOM-side replacement for MainMenu.tsx. Fires analytics.menu() * immediately (once per call), matching the original "once per mount" behavior since a fresh * element is built every time the player returns to the menu (see App.ts). */ -export function createMainMenu(analytics: Analytics, onNewGame: () => void, onContinue: () => void): HTMLElement { +export function createMainMenu( + analytics: Analytics, + onNewGame: () => void, + onContinue: () => void, + onSettings: () => void, +): HTMLElement { analytics.menu(); const root = document.createElement('div'); @@ -37,7 +42,16 @@ export function createMainMenu(analytics: Analytics, onNewGame: () => void, onCo onContinue(); }); - buttons.append(newGameBtn, continueBtn); + const settingsBtn = document.createElement('button'); + settingsBtn.type = 'button'; + settingsBtn.className = 'menu-btn'; + settingsBtn.textContent = 'Настройки'; + settingsBtn.addEventListener('click', () => { + analytics.button('settings'); + onSettings(); + }); + + buttons.append(newGameBtn, continueBtn, settingsBtn); // Temporary: a pixel-grid sanity check, rendered through the exact same pipeline as the game // world — see DebugCharacterPreview.ts's doc comment. Remove this block (and the container div) diff --git a/src/app/SettingsScreen.ts b/src/app/SettingsScreen.ts new file mode 100644 index 0000000..b53ad90 --- /dev/null +++ b/src/app/SettingsScreen.ts @@ -0,0 +1,57 @@ +import { getRenderSettings, setRenderSettings } from '../pixi/renderSettings'; + +/** Builds a toggle button that shows its current on/off state in its own label and flips it on + * click — the DOM-side equivalent of a checkbox, styled like the rest of the menu's buttons. */ +function createToggle(label: string, initial: boolean, onChange: (value: boolean) => void): HTMLButtonElement { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'menu-btn'; + let value = initial; + + function render(): void { + btn.textContent = `${label}: ${value ? 'вкл' : 'выкл'}`; + } + render(); + + btn.addEventListener('click', () => { + value = !value; + render(); + onChange(value); + }); + + return btn; +} + +/** Builds the settings screen — rendering-diagnostics toggles that take effect the next time a + * game is (re)started (GameCanvas reads them once, on mount), reachable from the main menu. */ +export function createSettingsScreen(onBack: () => void): HTMLElement { + const root = document.createElement('div'); + root.className = 'main-menu'; + + const title = document.createElement('h1'); + title.className = 'main-menu__title'; + title.textContent = 'Настройки'; + + const buttons = document.createElement('div'); + buttons.className = 'main-menu__buttons'; + + const settings = getRenderSettings(); + + const antialiasingBtn = createToggle('Антиалиасинг экрана', settings.antialiasing, (value) => { + setRenderSettings({ antialiasing: value }); + }); + + const noScaleBtn = createToggle('Рендер без масштабирования (k=1)', settings.noScale, (value) => { + setRenderSettings({ noScale: value }); + }); + + const backBtn = document.createElement('button'); + backBtn.type = 'button'; + backBtn.className = 'menu-btn'; + backBtn.textContent = 'Назад'; + backBtn.addEventListener('click', onBack); + + buttons.append(antialiasingBtn, noScaleBtn, backBtn); + root.append(title, buttons); + return root; +} diff --git a/src/pixi/DebugCharacterPreview.ts b/src/pixi/DebugCharacterPreview.ts index 88adeb3..c8b8ce0 100644 --- a/src/pixi/DebugCharacterPreview.ts +++ b/src/pixi/DebugCharacterPreview.ts @@ -20,7 +20,9 @@ export async function mountDebugCharacterPreview( appOptions?: Partial, ): Promise<{ destroy: () => void; world: PixelPerfectWorld }> { const world = new PixelPerfectWorld(); - await world.mount(container, WORLD_SIZE, WORLD_SIZE, appOptions); + // No PixelPerfectOptions passed — this preview always stays pixel-perfect (the default), as a + // fixed reference regardless of what the player has toggled in Settings. + await world.mount(container, WORLD_SIZE, WORLD_SIZE, undefined, appOptions); const info = getSpriteTexture(CHARACTER_SPRITE); const sprite = new Sprite(info.texture); diff --git a/src/pixi/GameCanvas.ts b/src/pixi/GameCanvas.ts index 2a8b92e..8b83cd8 100644 --- a/src/pixi/GameCanvas.ts +++ b/src/pixi/GameCanvas.ts @@ -1,6 +1,7 @@ import type { GameStore } from '../game/GameStore'; import { GameWorld } from './GameWorld'; import { PixelPerfectWorld } from './PixelPerfectWorld'; +import { getRenderSettings } from './renderSettings'; import { NODE_CLICK_RADIUS } from './entities/NodesView'; import { rabbitClickRadius } from './entities/RabbitsView'; @@ -43,7 +44,8 @@ export class GameCanvas { if (width <= 0 || height <= 0) return; - await this.world.mount(container, width, height); + const settings = getRenderSettings(); + await this.world.mount(container, width, height, { antialiasing: settings.antialiasing, noScale: settings.noScale }); this.world.app.canvas.style.touchAction = 'none'; this.gameWorld = new GameWorld(); diff --git a/src/pixi/PixelPerfectWorld.ts b/src/pixi/PixelPerfectWorld.ts index adf0322..76463fb 100644 --- a/src/pixi/PixelPerfectWorld.ts +++ b/src/pixi/PixelPerfectWorld.ts @@ -1,4 +1,4 @@ -import { Application, Container, RenderTexture, Sprite, type ApplicationOptions } from 'pixi.js'; +import { Application, Container, Graphics, RenderTexture, Sprite, type ApplicationOptions } from 'pixi.js'; /** Renders `content` (any Pixi Container) into a canvas with zero fractional-pixel scaling * anywhere Pixi/WebGL is involved — the shared mechanics behind GameCanvas (the full game world) @@ -26,6 +26,16 @@ import { Application, Container, RenderTexture, Sprite, type ApplicationOptions * compositor picks) — no web technology can avoid that for a genuinely fractional display scale. * The goal here is only to make sure *that* unavoidable, display-driven unevenness is the sole * source of any pixel irregularity, rather than compounding it with WebGL sampling of its own. */ +export interface PixelPerfectOptions { + /** Smooths the world texture's upscale to the physical screen instead of the usual crisp, + * nearest-neighbor blocky look — see RenderSettings' own doc comment. Off by default. */ + antialiasing?: boolean; + /** Forces the world-to-screen upscale factor `k` (see layout()) to 1, instead of the usual + * "biggest whole number that fits the container" — a rendering-diagnostics toggle. Off by + * default. */ + noScale?: boolean; +} + export class PixelPerfectWorld { readonly app = new Application(); /** Draw whatever should appear in the world into this container — it is never added to @@ -34,19 +44,40 @@ export class PixelPerfectWorld { private texture: RenderTexture | null = null; private sprite: Sprite | null = null; + /** Fills whatever the world-sprite's own (integer-scaled) bounds *don't* cover — an aspect- + * ratio mismatch between the world and the container, or the `noScale` diagnostic forcing k=1, + * both leave a margin around it. This has to be a separate, explicitly-bounded shape rather than + * just the app's own background color: the world-sprite's texture is transparent everywhere + * that isn't an entity (the game's grass ground is a plain CSS background *behind* this + * transparent canvas, not part of what Pixi draws — see App.css's `.game-map`), so painting the + * *whole* canvas opaque would black out the actual in-world ground too, not just the margin + * around it. Drawn as up to 4 rects around the sprite's own rectangle so the two never overlap. */ + private readonly letterbox = new Graphics(); private worldWidth = 0; private worldHeight = 0; + private noScale = false; /** Creates the Application, sized/resized to fill `container`, and the fixed `worldWidth x * worldHeight` texture `content` renders into. */ - async mount(container: HTMLElement, worldWidth: number, worldHeight: number, appOptions?: Partial): Promise { + async mount( + container: HTMLElement, + worldWidth: number, + worldHeight: number, + options?: PixelPerfectOptions, + appOptions?: Partial, + ): Promise { this.worldWidth = worldWidth; this.worldHeight = worldHeight; + this.noScale = options?.noScale ?? false; + const antialiasing = options?.antialiasing ?? false; await this.app.init({ resizeTo: container, + // Transparent, not opaque black — see the `letterbox` field's own doc comment for why the + // black bars are a separate, explicitly-bounded Graphics rather than just the app's own + // background color. backgroundAlpha: 0, - antialias: false, + antialias: antialiasing, roundPixels: true, // Deliberately 1, not devicePixelRatio — see the class doc comment. resolution: 1, @@ -54,12 +85,22 @@ export class PixelPerfectWorld { ...appOptions, }); this.app.canvas.style.display = 'block'; - this.app.canvas.style.imageRendering = 'pixelated'; + this.app.canvas.style.imageRendering = antialiasing ? 'auto' : 'pixelated'; container.appendChild(this.app.canvas); - this.texture = RenderTexture.create({ width: worldWidth, height: worldHeight, scaleMode: 'nearest', antialias: false, resolution: 1 }); + // Only the *outer* (world-texture-to-screen) sample mode responds to `antialiasing` — the + // world's own bake stays crisp/unfiltered regardless (see textures.ts and GameWorld), since + // that's the actual pixel-art data, not something to blur; this only smooths its final, + // screen-sized upscale. + this.texture = RenderTexture.create({ + width: worldWidth, + height: worldHeight, + scaleMode: antialiasing ? 'linear' : 'nearest', + antialias: false, + resolution: 1, + }); this.sprite = new Sprite(this.texture); - this.app.stage.addChild(this.sprite); + this.app.stage.addChild(this.letterbox, this.sprite); this.layout(); this.app.renderer.on('resize', () => this.layout()); } @@ -91,17 +132,31 @@ export class PixelPerfectWorld { * whenever the canvas's own on-screen size changes — the fixed-size world texture itself is * never touched. `k` is floored and never smaller than 1, so a canvas larger than the world * upscales it in clean whole-pixel steps, and a canvas smaller than the world just lets it - * overflow (expected to be clipped by an `overflow: hidden` ancestor). */ + * overflow (expected to be clipped by an `overflow: hidden` ancestor) — unless `noScale` forces + * it to exactly 1 regardless of how much bigger the container is. */ private layout(): void { if (!this.sprite || this.worldWidth <= 0 || this.worldHeight <= 0) return; const screen = this.app.screen; - const k = Math.max(1, Math.floor(Math.min(screen.width / this.worldWidth, screen.height / this.worldHeight))); + const k = this.noScale + ? 1 + : Math.max(1, Math.floor(Math.min(screen.width / this.worldWidth, screen.height / this.worldHeight))); this.sprite.scale.set(k); - this.sprite.position.set( - Math.round((screen.width - this.worldWidth * k) / 2), - Math.round((screen.height - this.worldHeight * k) / 2), - ); + const x = Math.round((screen.width - this.worldWidth * k) / 2); + const y = Math.round((screen.height - this.worldHeight * k) / 2); + this.sprite.position.set(x, y); + + const right = x + this.worldWidth * k; + const bottom = y + this.worldHeight * k; + this.letterbox.clear(); + if (y > 0) + this.letterbox.rect(0, 0, screen.width, y).fill(0x000000); // top + if (screen.height - bottom > 0) + this.letterbox.rect(0, bottom, screen.width, screen.height - bottom).fill(0x000000); // bottom + if (x > 0) + this.letterbox.rect(0, y, x, this.worldHeight * k).fill(0x000000); // left + if (screen.width - right > 0) + this.letterbox.rect(right, y, screen.width - right, this.worldHeight * k).fill(0x000000); // right } } diff --git a/src/pixi/renderSettings.ts b/src/pixi/renderSettings.ts new file mode 100644 index 0000000..ea6e4a9 --- /dev/null +++ b/src/pixi/renderSettings.ts @@ -0,0 +1,57 @@ +const STORAGE_KEY = 'forest-render-settings-v1'; + +export interface RenderSettings { + /** Smooths the world texture's upscale to the physical screen (linear sampling + real + * antialiasing + the browser's own smoothed compositing) instead of the game's normal crisp, + * blocky nearest-neighbor look. Purely a display toggle — the world is still baked at its own + * fixed resolution with flat, unfiltered pixel-art textures either way (see + * PixelPerfectWorld/textures.ts); this only affects the final screen-sized draw. */ + antialiasing: boolean; + /** Forces the world-to-screen upscale factor `k` to 1 instead of the usual "biggest whole + * number that fits the container" — a rendering-diagnostics toggle, not a gameplay one (the + * world/character/upgrade sizes are unaffected either way). */ + noScale: boolean; +} + +const DEFAULTS: RenderSettings = { antialiasing: false, noScale: false }; + +function isValid(value: unknown): value is Partial { + return typeof value === 'object' && value !== null; +} + +function load(): RenderSettings { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) + return { ...DEFAULTS }; + const parsed: unknown = JSON.parse(raw); + if (!isValid(parsed)) + return { ...DEFAULTS }; + return { + antialiasing: typeof parsed.antialiasing === 'boolean' ? parsed.antialiasing : DEFAULTS.antialiasing, + noScale: typeof parsed.noScale === 'boolean' ? parsed.noScale : DEFAULTS.noScale, + }; + } catch { + return { ...DEFAULTS }; + } +} + +function persist(settings: RenderSettings): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // Ignore — e.g. private browsing mode or quota exceeded. + } +} + +let current = load(); + +export function getRenderSettings(): RenderSettings { + return current; +} + +export function setRenderSettings(patch: Partial): RenderSettings { + current = { ...current, ...patch }; + persist(current); + return current; +}