экран настроек. черные рамки

This commit is contained in:
azykov@mail.ru
2026-09-09 19:32:25 +03:00
parent 43f6004fb0
commit 7e6b50d1bc
7 changed files with 210 additions and 16 deletions
+7
View File
@@ -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,
+16 -2
View File
@@ -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)
+57
View File
@@ -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;
}
+3 -1
View File
@@ -20,7 +20,9 @@ export async function mountDebugCharacterPreview(
appOptions?: Partial<ApplicationOptions>,
): 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);
+3 -1
View File
@@ -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();
+67 -12
View File
@@ -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<ApplicationOptions>): Promise<void> {
async mount(
container: HTMLElement,
worldWidth: number,
worldHeight: number,
options?: PixelPerfectOptions,
appOptions?: Partial<ApplicationOptions>,
): Promise<void> {
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
}
}
+57
View File
@@ -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<RenderSettings> {
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>): RenderSettings {
current = { ...current, ...patch };
persist(current);
return current;
}