mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:50:45 +00:00
Merge remote-tracking branch 'origin/main' into feat/impassable-terrain
This commit is contained in:
@@ -96,11 +96,10 @@ Pure black pixels (`#000000` / `rgb(0, 0, 0)` with alpha ≥ 20) are encoded as
|
||||
|
||||
Use impassable terrain to carve out non-rectangular map shapes or to create barriers that divide regions without water.
|
||||
|
||||
In-Game, terrain is rendered using themes. The color of a tile is determined dynamically based on
|
||||
its **Terrain Type** and **Magnitude**. Theme Files:
|
||||
In-Game, the color of a tile is determined dynamically based on its **Terrain Type** and **Magnitude**.
|
||||
|
||||
- `../src/core/configuration/PastelTheme.ts` (Light)
|
||||
- `../src/core/configuration/PastelThemeDark.ts` (Dark).
|
||||
- Ocean default color definition: `../src/client/render/gl/render-settings.json` (user changeable via settings)
|
||||
- Terrain color calculations: `../src/client/render/gl/utils/ColorUtils.ts#L50`
|
||||
|
||||
## Create info.json
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
deepAssign,
|
||||
MapRenderer,
|
||||
preloadAtlasData,
|
||||
renderDpr,
|
||||
type RenderSettings,
|
||||
} from "./render/gl";
|
||||
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
|
||||
@@ -352,7 +353,7 @@ function mountWebGLFrameLoop(
|
||||
|
||||
const syncCamera = (): void => {
|
||||
const scale = transformHandler.scale;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpr = renderDpr();
|
||||
const centerX =
|
||||
transformHandler.offsetX +
|
||||
mapWidth / 2 +
|
||||
|
||||
@@ -27,6 +27,9 @@ export class UsernameInput extends LitElement {
|
||||
@state() private baseUsername: string = "";
|
||||
@state() private clanTag: string = "";
|
||||
|
||||
// Clans aren't supported on CrazyGames — hide the tag input and never submit one.
|
||||
private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames();
|
||||
|
||||
@property({ type: String }) validationError: string = "";
|
||||
// Ownership-check feedback (i18n key) shown inline beneath the tag input. Only
|
||||
// "not a member" gates the buttons (see emitValidity); the rest is advisory.
|
||||
@@ -124,7 +127,9 @@ export class UsernameInput extends LitElement {
|
||||
private loadStoredUsername() {
|
||||
const storedUsername = localStorage.getItem(usernameKey);
|
||||
if (storedUsername) {
|
||||
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
|
||||
if (!this.onCrazyGames) {
|
||||
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
|
||||
}
|
||||
this.baseUsername = storedUsername;
|
||||
this.validateAndStore();
|
||||
this.startClanCheck();
|
||||
@@ -137,7 +142,7 @@ export class UsernameInput extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex items-center w-full h-full gap-2">
|
||||
<div class="relative flex items-center shrink-0">
|
||||
<div class="no-crazygames relative flex items-center shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.clanTag}
|
||||
@@ -276,7 +281,9 @@ export class UsernameInput extends LitElement {
|
||||
this._isValid = result.isValid;
|
||||
if (result.isValid) {
|
||||
localStorage.setItem(usernameKey, trimmedBase);
|
||||
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
|
||||
if (!this.onCrazyGames) {
|
||||
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
|
||||
}
|
||||
this.validationError = "";
|
||||
} else {
|
||||
this.validationError = result.error ?? "";
|
||||
|
||||
@@ -43,15 +43,21 @@ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean {
|
||||
|
||||
/**
|
||||
* Whether a SAM belongs in the nuke trajectory preview's threat set.
|
||||
* Allied SAMs are excluded unless the strike would betray that ally —
|
||||
* the alliance breaks at launch, so their SAMs will engage the nuke.
|
||||
* Mirrors SAMLauncherExecution: a SAM ignores a nuke whose owner it's
|
||||
* friendly with (same team OR allied).
|
||||
* Teammates are excluded unconditionally — a strike can break an alliance
|
||||
* but never a team relationship, so a teammate's SAM never engages.
|
||||
* Allied SAMs are excluded unless the strike would betray that ally — the
|
||||
* alliance breaks at launch, so their SAMs will engage the nuke.
|
||||
* (Own SAMs never threaten; the caller filters those out first.)
|
||||
*/
|
||||
export function samThreatensNukePreview(
|
||||
samOwnerSmallId: number,
|
||||
teammateSmallIds: ReadonlySet<number>,
|
||||
allySmallIds: ReadonlySet<number>,
|
||||
betrayedSmallIds: ReadonlySet<number>,
|
||||
): boolean {
|
||||
if (teammateSmallIds.has(samOwnerSmallId)) return false;
|
||||
return (
|
||||
!allySmallIds.has(samOwnerSmallId) || betrayedSmallIds.has(samOwnerSmallId)
|
||||
);
|
||||
@@ -339,10 +345,15 @@ export class BuildPreviewController implements Controller {
|
||||
const srcX = this.game.x(bestSilo.tile());
|
||||
const srcY = this.game.y(bestSilo.tile());
|
||||
|
||||
// Non-allied SAMs threaten the trajectory; own + allied SAMs don't —
|
||||
// except allies this strike would betray: the alliance breaks at launch
|
||||
// (NukeExecution.maybeBreakAlliances), so their SAMs will intercept.
|
||||
// Non-friendly SAMs threaten the trajectory; own + teammate + allied SAMs
|
||||
// don't — except allies this strike would betray: the alliance breaks at
|
||||
// launch (NukeExecution.maybeBreakAlliances), so their SAMs will intercept.
|
||||
// Teammates have no such exception (a strike never breaks a team).
|
||||
// listNukeBreakAlliance is the same function the sim uses there.
|
||||
const teammateIds = new Set<number>();
|
||||
for (const p of this.game.players()) {
|
||||
if (myPlayer.isOnSameTeam(p)) teammateIds.add(p.smallID());
|
||||
}
|
||||
const allyIds = new Set<number>();
|
||||
for (const a of myPlayer.allies()) allyIds.add(a.smallID());
|
||||
const betrayedIds: ReadonlySet<number> =
|
||||
@@ -359,7 +370,14 @@ export class BuildPreviewController implements Controller {
|
||||
if (!s.isActive()) continue;
|
||||
const owner = s.owner();
|
||||
if (owner === myPlayer) continue;
|
||||
if (!samThreatensNukePreview(owner.smallID(), allyIds, betrayedIds)) {
|
||||
if (
|
||||
!samThreatensNukePreview(
|
||||
owner.smallID(),
|
||||
teammateIds,
|
||||
allyIds,
|
||||
betrayedIds,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const r = this.game.config().samRange(s.level());
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* ty = -offsetY * sy
|
||||
*/
|
||||
|
||||
import { renderDpr } from "./utils/Dpr";
|
||||
|
||||
const MIN_ZOOM = 0.2;
|
||||
const MAX_ZOOM = 20;
|
||||
const DBLCLICK_MIN_ZOOM = 0.7;
|
||||
@@ -44,7 +46,7 @@ export class Camera {
|
||||
|
||||
/** Update canvas pixel dimensions. Triggers initial fitMap on first call. */
|
||||
resize(cssWidth: number, cssHeight: number): void {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpr = renderDpr();
|
||||
this.canvasW = Math.round(cssWidth * dpr);
|
||||
this.canvasH = Math.round(cssHeight * dpr);
|
||||
if (this.needsInitialFit) {
|
||||
@@ -163,7 +165,7 @@ export class Camera {
|
||||
|
||||
/** Convert screen pixel position to world coordinates. */
|
||||
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpr = renderDpr();
|
||||
const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1;
|
||||
const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1);
|
||||
const sx = (this.zoom * 2) / this.canvasW;
|
||||
@@ -176,7 +178,7 @@ export class Camera {
|
||||
|
||||
/** Convert world coordinates to screen pixel position (CSS pixels). */
|
||||
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpr = renderDpr();
|
||||
return {
|
||||
x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr),
|
||||
y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr),
|
||||
|
||||
@@ -60,6 +60,7 @@ import { WorldTextPass } from "./passes/WorldTextPass";
|
||||
import type { RenderSettings } from "./RenderSettings";
|
||||
import { AffiliationPalette } from "./utils/Affiliation";
|
||||
import { getPaletteSize, hexToRgb } from "./utils/ColorUtils";
|
||||
import { renderDpr } from "./utils/Dpr";
|
||||
import {
|
||||
createTexture2D,
|
||||
toScreen,
|
||||
@@ -569,7 +570,7 @@ export class GPURenderer {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
resize(cssWidth: number, cssHeight: number): void {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpr = renderDpr();
|
||||
this.canvas.width = Math.round(cssWidth * dpr);
|
||||
this.canvas.height = Math.round(cssHeight * dpr);
|
||||
this.camera.resize(cssWidth, cssHeight);
|
||||
|
||||
@@ -11,6 +11,7 @@ export { createRenderSettings, dumpSettings } from "./RenderSettings";
|
||||
export type { RenderSettings } from "./RenderSettings";
|
||||
export { deepAssign, deepDiff } from "./SettingsUtils";
|
||||
export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils";
|
||||
export { renderDpr } from "./utils/Dpr";
|
||||
export { buildNukeTrajectory, samRange } from "./utils/NukeTrajectory";
|
||||
export type { SAMInfo } from "./utils/NukeTrajectory";
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ export class TerrainPass {
|
||||
* nuke). `bytes[i]` is the new terrain byte for `refs[i]` (parallel arrays).
|
||||
* One 1×1 texSubImage2D per ref — fine for the small bursts a single nuke
|
||||
* produces.
|
||||
*
|
||||
* Also writes back into `terrainBytes` so a later full re-upload (e.g.
|
||||
* setOceanColor) reflects these conversions instead of reverting them.
|
||||
*/
|
||||
applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void {
|
||||
if (refs.length === 0) return;
|
||||
@@ -101,6 +104,7 @@ export class TerrainPass {
|
||||
const ref = refs[i];
|
||||
const x = ref % this.mapW;
|
||||
const y = (ref - x) / this.mapW;
|
||||
this.terrainBytes[ref] = bytes[i];
|
||||
encodeTerrainTile(bytes[i], this.pixelScratch, 0, this.oceanColor);
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { Config } from "../../../../core/configuration/Config";
|
||||
import type { BonusEvent, ConquestFx } from "../../types";
|
||||
import type { RenderSettings } from "../RenderSettings";
|
||||
import { renderDpr } from "../utils/Dpr";
|
||||
import { createProgram } from "../utils/GlUtils";
|
||||
import type { GlyphTables } from "./name-pass/AtlasData";
|
||||
import { buildGlyphTables, parseAtlasData } from "./name-pass/AtlasData";
|
||||
@@ -399,7 +400,7 @@ export class WorldTextPass {
|
||||
let count = 0;
|
||||
// canvasW in Camera is cssWidth*dpr, so `zoom` is device-px-per-world-unit.
|
||||
// Multiply screen-relative scales by dpr to keep a constant CSS-pixel size.
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpr = renderDpr();
|
||||
|
||||
for (const popup of this.active) {
|
||||
const elapsed = now - popup.startMs;
|
||||
@@ -541,7 +542,7 @@ export class WorldTextPass {
|
||||
gl.useProgram(this.program);
|
||||
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
|
||||
gl.uniform1f(this.uZoom, zoom);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dpr = renderDpr();
|
||||
gl.uniform1f(
|
||||
this.uMinScreenScale,
|
||||
this.settings.bonusPopup.minScreenScale * dpr,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Device-pixel-ratio used by the WebGL renderer for its backing store and all
|
||||
* screen↔world math. Capped at 2 to avoid rendering at 3x on very high-DPI
|
||||
* (mobile) displays, which costs ~9x the fragment work of 1x for a marginal
|
||||
* visual gain over 2x.
|
||||
*
|
||||
* Every renderer call site that previously read `window.devicePixelRatio`
|
||||
* must go through this so the canvas size, camera math, and text scaling stay
|
||||
* on the same coordinate system.
|
||||
*/
|
||||
export function renderDpr(): number {
|
||||
return Math.min(window.devicePixelRatio || 2, 2);
|
||||
}
|
||||
@@ -35,21 +35,40 @@ describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm
|
||||
});
|
||||
|
||||
describe("samThreatensNukePreview (nuke trajectory threat set, #4226)", () => {
|
||||
const teammates = new Set([7, 8]);
|
||||
const allies = new Set([2, 3]);
|
||||
|
||||
test("non-allied SAM threatens the trajectory", () => {
|
||||
expect(samThreatensNukePreview(5, allies, new Set())).toBe(true);
|
||||
test("non-friendly SAM threatens the trajectory", () => {
|
||||
expect(samThreatensNukePreview(5, teammates, allies, new Set())).toBe(true);
|
||||
});
|
||||
|
||||
test("allied SAM does not threaten when the strike breaks no alliance", () => {
|
||||
expect(samThreatensNukePreview(2, allies, new Set())).toBe(false);
|
||||
expect(samThreatensNukePreview(2, teammates, allies, new Set())).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("would-be-betrayed ally's SAM threatens (alliance breaks at launch)", () => {
|
||||
expect(samThreatensNukePreview(2, allies, new Set([2]))).toBe(true);
|
||||
expect(samThreatensNukePreview(2, teammates, allies, new Set([2]))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("other allies' SAMs still excluded when a different ally is betrayed", () => {
|
||||
expect(samThreatensNukePreview(3, allies, new Set([2]))).toBe(false);
|
||||
expect(samThreatensNukePreview(3, teammates, allies, new Set([2]))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("teammate SAM does not threaten the trajectory", () => {
|
||||
expect(samThreatensNukePreview(7, teammates, new Set(), new Set())).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("teammate SAM stays excluded even if listed as betrayed (a strike never breaks a team)", () => {
|
||||
expect(
|
||||
samThreatensNukePreview(7, teammates, new Set([7]), new Set([7])),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user