экран настроек. черные рамки
This commit is contained in:
@@ -7,6 +7,7 @@ import { GameStore } from '../game/GameStore';
|
|||||||
import { clearSave, hasSave } from '../game/persistence';
|
import { clearSave, hasSave } from '../game/persistence';
|
||||||
import { GameScreen } from './GameScreen';
|
import { GameScreen } from './GameScreen';
|
||||||
import { createMainMenu } from './MainMenu';
|
import { createMainMenu } from './MainMenu';
|
||||||
|
import { createSettingsScreen } from './SettingsScreen';
|
||||||
|
|
||||||
/** Top-level screen controller (menu <-> game) — the plain-TS replacement for App.tsx. Owns the
|
/** 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
|
* 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.analytics,
|
||||||
() => this.handleNewGame(),
|
() => this.handleNewGame(),
|
||||||
() => this.handleContinue(),
|
() => this.handleContinue(),
|
||||||
|
() => this.showSettings(),
|
||||||
);
|
);
|
||||||
this.root.replaceChildren(menu);
|
this.root.replaceChildren(menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showSettings(): void {
|
||||||
|
const settings = createSettingsScreen(() => this.showMenu());
|
||||||
|
this.root.replaceChildren(settings);
|
||||||
|
}
|
||||||
|
|
||||||
private showGame(): void {
|
private showGame(): void {
|
||||||
this.gameScreen = new GameScreen(
|
this.gameScreen = new GameScreen(
|
||||||
this.store,
|
this.store,
|
||||||
|
|||||||
+16
-2
@@ -5,7 +5,12 @@ import { mountDebugCharacterPreview } from '../pixi/DebugCharacterPreview';
|
|||||||
/** Builds the main menu screen — the DOM-side replacement for MainMenu.tsx. Fires analytics.menu()
|
/** 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
|
* 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). */
|
* 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();
|
analytics.menu();
|
||||||
|
|
||||||
const root = document.createElement('div');
|
const root = document.createElement('div');
|
||||||
@@ -37,7 +42,16 @@ export function createMainMenu(analytics: Analytics, onNewGame: () => void, onCo
|
|||||||
onContinue();
|
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
|
// 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)
|
// world — see DebugCharacterPreview.ts's doc comment. Remove this block (and the container div)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ export async function mountDebugCharacterPreview(
|
|||||||
appOptions?: Partial<ApplicationOptions>,
|
appOptions?: Partial<ApplicationOptions>,
|
||||||
): Promise<{ destroy: () => void; world: PixelPerfectWorld }> {
|
): Promise<{ destroy: () => void; world: PixelPerfectWorld }> {
|
||||||
const world = new 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 info = getSpriteTexture(CHARACTER_SPRITE);
|
||||||
const sprite = new Sprite(info.texture);
|
const sprite = new Sprite(info.texture);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { GameStore } from '../game/GameStore';
|
import type { GameStore } from '../game/GameStore';
|
||||||
import { GameWorld } from './GameWorld';
|
import { GameWorld } from './GameWorld';
|
||||||
import { PixelPerfectWorld } from './PixelPerfectWorld';
|
import { PixelPerfectWorld } from './PixelPerfectWorld';
|
||||||
|
import { getRenderSettings } from './renderSettings';
|
||||||
import { NODE_CLICK_RADIUS } from './entities/NodesView';
|
import { NODE_CLICK_RADIUS } from './entities/NodesView';
|
||||||
import { rabbitClickRadius } from './entities/RabbitsView';
|
import { rabbitClickRadius } from './entities/RabbitsView';
|
||||||
|
|
||||||
@@ -43,7 +44,8 @@ export class GameCanvas {
|
|||||||
if (width <= 0 || height <= 0)
|
if (width <= 0 || height <= 0)
|
||||||
return;
|
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.world.app.canvas.style.touchAction = 'none';
|
||||||
|
|
||||||
this.gameWorld = new GameWorld();
|
this.gameWorld = new GameWorld();
|
||||||
|
|||||||
@@ -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
|
/** 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)
|
* 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.
|
* 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
|
* 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. */
|
* 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 {
|
export class PixelPerfectWorld {
|
||||||
readonly app = new Application();
|
readonly app = new Application();
|
||||||
/** Draw whatever should appear in the world into this container — it is never added to
|
/** 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 texture: RenderTexture | null = null;
|
||||||
private sprite: Sprite | 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 worldWidth = 0;
|
||||||
private worldHeight = 0;
|
private worldHeight = 0;
|
||||||
|
private noScale = false;
|
||||||
|
|
||||||
/** Creates the Application, sized/resized to fill `container`, and the fixed `worldWidth x
|
/** Creates the Application, sized/resized to fill `container`, and the fixed `worldWidth x
|
||||||
* worldHeight` texture `content` renders into. */
|
* 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.worldWidth = worldWidth;
|
||||||
this.worldHeight = worldHeight;
|
this.worldHeight = worldHeight;
|
||||||
|
this.noScale = options?.noScale ?? false;
|
||||||
|
const antialiasing = options?.antialiasing ?? false;
|
||||||
|
|
||||||
await this.app.init({
|
await this.app.init({
|
||||||
resizeTo: container,
|
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,
|
backgroundAlpha: 0,
|
||||||
antialias: false,
|
antialias: antialiasing,
|
||||||
roundPixels: true,
|
roundPixels: true,
|
||||||
// Deliberately 1, not devicePixelRatio — see the class doc comment.
|
// Deliberately 1, not devicePixelRatio — see the class doc comment.
|
||||||
resolution: 1,
|
resolution: 1,
|
||||||
@@ -54,12 +85,22 @@ export class PixelPerfectWorld {
|
|||||||
...appOptions,
|
...appOptions,
|
||||||
});
|
});
|
||||||
this.app.canvas.style.display = 'block';
|
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);
|
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.sprite = new Sprite(this.texture);
|
||||||
this.app.stage.addChild(this.sprite);
|
this.app.stage.addChild(this.letterbox, this.sprite);
|
||||||
this.layout();
|
this.layout();
|
||||||
this.app.renderer.on('resize', () => 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
|
* 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
|
* 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
|
* 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 {
|
private layout(): void {
|
||||||
if (!this.sprite || this.worldWidth <= 0 || this.worldHeight <= 0)
|
if (!this.sprite || this.worldWidth <= 0 || this.worldHeight <= 0)
|
||||||
return;
|
return;
|
||||||
const screen = this.app.screen;
|
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.scale.set(k);
|
||||||
this.sprite.position.set(
|
const x = Math.round((screen.width - this.worldWidth * k) / 2);
|
||||||
Math.round((screen.width - this.worldWidth * k) / 2),
|
const y = Math.round((screen.height - this.worldHeight * k) / 2);
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user