This commit is contained in:
Ryan Barlow
2026-05-28 21:38:42 +01:00
parent 8142bc1070
commit a5e504c3e9
21 changed files with 638 additions and 20 deletions
+2
View File
@@ -333,6 +333,8 @@
<emoji-table></emoji-table>
<build-menu></build-menu>
<win-modal></win-modal>
<preview-finish-button></preview-finish-button>
<preview-complete-modal></preview-complete-modal>
<game-starting-modal></game-starting-modal>
<div
class="flex flex-col items-end fixed top-0 right-0 min-[1200px]:top-4 min-[1200px]:right-4 z-1000 gap-2"
+9 -1
View File
@@ -1229,7 +1229,15 @@
"login_required": "You must be logged in to purchase with currency.",
"not_enough_currency": "Not enough currency for this purchase.",
"purchase_failed": "Purchase failed. Please try again.",
"purchase_success": "Purchase succeeded: {name}"
"purchase_success": "Purchase succeeded: {name}",
"preview_skin": "Preview Skin"
},
"preview": {
"finish": "Finish preview",
"complete_title": "Preview Complete",
"complete_subtitle": "Like what you see? Make it yours.",
"exit": "Back to menu",
"keep_watching": "Keep watching"
},
"territory_patterns": {
"title": "Skins",
+11 -1
View File
@@ -572,6 +572,10 @@ export class ClientGameRunner {
* (when the player is not alive or doesn't exist)
*/
public shouldPreventWindowClose(): boolean {
// Skin previews are throwaway sandboxes — leaving should never prompt.
if (this.lobby.gameStartInfo?.config.isPreview) {
return false;
}
// Show confirmation dialog if player is alive in the game
return !!this.myPlayer?.isAlive();
}
@@ -580,6 +584,10 @@ export class ClientGameRunner {
if (!this.clientID) {
return;
}
// Skin previews are throwaway sandboxes — never record them.
if (this.lobby.gameStartInfo?.config.isPreview) {
return;
}
const players: PlayerRecord[] = [
{
persistentID: getPersistentID(),
@@ -771,7 +779,9 @@ export class ClientGameRunner {
!this.gameView.inSpawnPhase() &&
!hasGoneToPlayer &&
this.gameView.myPlayer() &&
this.userSettings.goToPlayer()
this.userSettings.goToPlayer() &&
// Skin preview keeps its own centred camera (see GameRenderer).
!this.gameView.config().isPreview()
) {
hasGoneToPlayer = true;
this.eventBus.emit(new GoToPlayerEvent(this.gameView.myPlayer()!, 8));
+13 -2
View File
@@ -60,6 +60,11 @@ export type PaymentMethod = "dollar" | "hard" | "soft";
export async function purchaseCosmetic(
resolved: ResolvedCosmetic,
method: PaymentMethod,
// When invoked from inside a live game (e.g. the skin-preview modal), skip the
// store-menu navigations that assume you're already in the menu: don't bounce
// to the packs tab on insufficient funds, and on success return home rather
// than reloading the (now-invalid) game URL.
opts: { fromGame?: boolean } = {},
): Promise<void> {
if (!resolved.cosmetic) return;
const c = resolved.cosmetic;
@@ -145,7 +150,7 @@ export async function purchaseCosmetic(
: (userMe.player.currency?.soft ?? 0);
if (balance < price) {
alert(translateText("store.not_enough_currency"));
if (method === "hard") {
if (method === "hard" && !opts.fromGame) {
// Send the user to the packs tab so they can top up plutonium.
window.location.hash = "#modal=store&tab=packs";
}
@@ -165,7 +170,13 @@ export async function purchaseCosmetic(
}
alert(translateText("store.purchase_success", { name: c.name }));
invalidateUserMe();
window.location.reload();
if (opts.fromGame) {
// Reloading the game URL would try to rejoin a game that no longer exists,
// so go home; the newly-owned cosmetic applies on the fresh load.
window.location.href = "/";
} else {
window.location.reload();
}
}
function simpleHash(str: string): string {
+4
View File
@@ -268,6 +268,10 @@ export class LocalServer {
if (this.isReplay) {
return;
}
// Skin previews are throwaway sandboxes — never archive them.
if (this.lobbyConfig.gameStartInfo?.config.isPreview) {
return;
}
const players: PlayerRecord[] = [
{
persistentID: getPersistentID(),
+7
View File
@@ -823,6 +823,7 @@ class Client {
console.log("joining lobby, stopping existing game");
this.lobbyHandle.stop(true);
document.body.classList.remove("in-game");
document.body.classList.remove("preview-mode");
}
if (lobby.source === "public") {
this.joinModal?.open({
@@ -928,6 +929,11 @@ class Client {
crazyGamesSDK.loadingStop();
crazyGamesSDK.gameplayStart();
document.body.classList.add("in-game");
// Cinematic skin-preview mode hides the normal HUD (see styles.css).
document.body.classList.toggle(
"preview-mode",
lobby.gameStartInfo?.config.isPreview === true,
);
// Ensure there's a homepage entry in history before adding the lobby entry
if (window.location.hash === "" || window.location.hash === "#") {
@@ -975,6 +981,7 @@ class Client {
}
document.body.classList.remove("in-game");
document.body.classList.remove("preview-mode");
if (this.joinModal.isOpen()) {
this.joinModal.close();
+111
View File
@@ -0,0 +1,111 @@
import { Pattern, Skin } from "../core/CosmeticSchemas";
import {
Difficulty,
GameMapSize,
GameMapType,
GameMode,
GameType,
} from "../core/game/Game";
import { GameConfig, PlayerCosmetics } from "../core/Schemas";
import { generateID } from "../core/Util";
import { ResolvedCosmetic } from "./Cosmetics";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
// The cosmetic currently being previewed, stashed so the "Preview Complete"
// modal can show it (and its buy button) when the user finishes. Set the moment
// a preview is launched and read by <preview-complete-modal>.
let previewCosmetic: ResolvedCosmetic | null = null;
export function getPreviewCosmetic(): ResolvedCosmetic | null {
return previewCosmetic;
}
/** Build a PlayerCosmetics that forces the previewed pattern/skin onto the
* player, regardless of ownership. Returns null for non-previewable types. */
function playerCosmeticsFor(
resolved: ResolvedCosmetic,
): PlayerCosmetics | null {
const c = resolved.cosmetic;
if (c === null) return null;
if (resolved.type === "pattern") {
return {
pattern: {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: resolved.colorPalette ?? undefined,
},
};
}
if (resolved.type === "skin") {
return {
skin: {
name: c.name,
url: (c as Skin).url,
},
};
}
return null;
}
/**
* Launches a singleplayer skin-preview sandbox for the given cosmetic: the
* player auto-spawns in the centre of Australia with a 100M-strong army and
* floods their (skinned) territory across the empty map. Nothing is saved.
*
* Patterns and image skins are previewable; other cosmetic types are ignored.
*/
export function launchSkinPreview(resolved: ResolvedCosmetic): void {
const cosmetics = playerCosmeticsFor(resolved);
if (cosmetics === null) return;
previewCosmetic = resolved;
const clientID = generateID();
const gameID = generateID();
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput | null;
const config: GameConfig = {
gameMap: GameMapType.Australia,
gameMapSize: GameMapSize.Normal,
gameType: GameType.Singleplayer,
gameMode: GameMode.FFA,
difficulty: Difficulty.Easy,
nations: "disabled",
bots: 0,
donateGold: false,
donateTroops: false,
infiniteGold: true,
infiniteTroops: true,
instantBuild: true,
randomSpawn: false,
disableAlliances: true,
isPreview: true,
};
document.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID,
gameStartInfo: {
gameID,
players: [
{
clientID,
username: usernameInput?.getUsername() ?? "Preview",
clanTag: usernameInput?.getClanTag() ?? null,
cosmetics,
},
],
config,
lobbyCreatedAt: Date.now(),
},
source: "singleplayer",
} satisfies JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
+3
View File
@@ -13,6 +13,7 @@ import {
purchaseCosmetic,
resolveCosmetics,
} from "./Cosmetics";
import { launchSkinPreview } from "./Preview";
import { translateText } from "./Utils";
type StoreTab = "patterns" | "flags" | "packs" | "subscriptions";
@@ -93,6 +94,7 @@ export class StoreModal extends BaseModal {
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
.onPreview=${launchSkinPreview}
></cosmetic-button>
`,
)}
@@ -261,6 +263,7 @@ export class StoreModal extends BaseModal {
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
.onPreview=${launchSkinPreview}
></cosmetic-button>
`,
)}
+16
View File
@@ -35,6 +35,11 @@ export class CosmeticButton extends LitElement {
@property({ type: Function })
onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void;
/** When set (patterns/skins only), shows a "Preview" button that launches a
* singleplayer sandbox to try the cosmetic on the map before buying. */
@property({ type: Function })
onPreview?: (resolved: ResolvedCosmetic) => void;
/** True if the user already has a subscription (any tier). */
@property({ type: Boolean })
userHasSubscription: boolean = false;
@@ -238,6 +243,17 @@ export class CosmeticButton extends LitElement {
${this.renderPreview()}
</div>
</button>
${(isPattern || isSkin) && this.onPreview
? html`<button
class="w-full px-3 py-2 bg-white/10 hover:bg-white/20 text-white border border-white/20 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors duration-200 cursor-pointer"
@click=${(e: Event) => {
e.stopPropagation();
this.onPreview?.(this.resolved);
}}
>
${translateText("store.preview_skin")}
</button>`
: nothing}
${isOwnedSubscription
? html`<div
class="w-full mt-2 px-4 py-2 bg-amber-500/20 text-amber-300 border border-amber-500/40 rounded-lg text-xs font-bold uppercase tracking-wider text-center"
+32 -2
View File
@@ -33,6 +33,8 @@ import { MultiTabModal } from "./layers/MultiTabModal";
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { PreviewCompleteModal } from "./layers/PreviewCompleteModal";
import { PreviewFinishButton } from "./layers/PreviewFinishButton";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SettingsModal } from "./layers/SettingsModal";
import { SpawnTimer } from "./layers/SpawnTimer";
@@ -40,6 +42,10 @@ import { TeamStats } from "./layers/TeamStats";
import { UnitDisplay } from "./layers/UnitDisplay";
import { WinModal } from "./layers/WinModal";
// Startup zoom for skin previews, as a multiple of the whole-map fit (1 = whole
// map). Higher zooms in closer on the centre spawn. Tune to taste.
const PREVIEW_CAMERA_ZOOM = 1;
export function createRenderer(
inputEl: HTMLElement,
game: GameView,
@@ -165,6 +171,24 @@ export function createRenderer(
winModal.eventBus = eventBus;
winModal.game = game;
const previewCompleteModal = document.querySelector(
"preview-complete-modal",
) as PreviewCompleteModal;
if (!(previewCompleteModal instanceof PreviewCompleteModal)) {
console.error("preview complete modal not found");
}
previewCompleteModal.eventBus = eventBus;
previewCompleteModal.game = game;
const previewFinishButton = document.querySelector(
"preview-finish-button",
) as PreviewFinishButton;
if (!(previewFinishButton instanceof PreviewFinishButton)) {
console.error("preview finish button not found");
}
previewFinishButton.eventBus = eventBus;
previewFinishButton.game = game;
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
if (!(replayPanel instanceof ReplayPanel)) {
console.error("replay panel not found");
@@ -307,6 +331,8 @@ export function createRenderer(
controlPanel,
playerInfo,
winModal,
previewCompleteModal,
previewFinishButton,
replayPanel,
settingsModal,
teamStats,
@@ -323,6 +349,8 @@ export function createRenderer(
uiState,
layers,
performanceOverlay,
// Skin preview: zoom in on the centre spawn; otherwise show the whole map.
game.config().isPreview() ? PREVIEW_CAMERA_ZOOM : 0.9,
);
}
@@ -334,6 +362,7 @@ export class GameRenderer {
public uiState: UIState,
private layers: Controller[],
private performanceOverlay: PerformanceOverlay,
private startupZoom: number = 0.9,
) {}
initialize() {
@@ -343,8 +372,9 @@ export class GameRenderer {
this.transformHandler.updateCanvasBoundingRect(),
);
//show whole map on startup
this.transformHandler.centerAll(0.9);
// Skin preview zooms in on the centre spawn; normal games show the whole
// map (see startupZoom in createRenderer).
this.transformHandler.centerAll(this.startupZoom);
}
tick() {
@@ -0,0 +1,104 @@
import { html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView } from "../../../core/game/GameView";
import "../../components/baseComponents/Button";
import "../../components/CosmeticButton";
import { Controller } from "../../Controller";
import {
PaymentMethod,
purchaseCosmetic,
ResolvedCosmetic,
} from "../../Cosmetics";
import { getPreviewCosmetic } from "../../Preview";
import { translateText } from "../../Utils";
/**
* Standalone end-of-preview popup. Mirrors the win/loss modal's look but shows
* only the single cosmetic that was just previewed, alongside its buy button.
* Shown when the user clicks "Finish preview" (see <preview-finish-button>).
*/
@customElement("preview-complete-modal")
export class PreviewCompleteModal extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
@state()
isVisible = false;
createRenderRoot() {
return this;
}
init() {}
tick() {}
show() {
this.isVisible = true;
this.requestUpdate();
}
hide() {
this.isVisible = false;
this.requestUpdate();
}
private handleExit() {
this.hide();
// Navigate home; the reload clears the preview-mode body class.
window.location.href = "/";
}
// Buy the previewed skin right here. `fromGame` keeps the purchase clean
// inside the live preview: a successful buy navigates home (full teardown),
// a Stripe purchase redirects out, and insufficient funds just shows the
// "not enough currency" message and leaves the preview running.
private handlePurchase = (
resolved: ResolvedCosmetic,
method: PaymentMethod,
) => {
purchaseCosmetic(resolved, method, { fromGame: true });
};
render() {
if (!this.isVisible) return html``;
const resolved = getPreviewCosmetic();
return html`
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-800/70 p-6 shrink-0 rounded-lg z-[10010] shadow-2xl backdrop-blur-xs text-white w-87.5 max-w-[90%]"
>
<h2 class="m-0 mb-2 text-[26px] text-center text-white">
${translateText("preview.complete_title")}
</h2>
<p class="m-0 mb-4 text-center text-white/70">
${translateText("preview.complete_subtitle")}
</p>
${resolved
? html`<div class="flex justify-center mb-4">
<cosmetic-button
.resolved=${resolved}
.onPurchase=${this.handlePurchase}
></cosmetic-button>
</div>`
: nothing}
<div class="flex justify-between gap-2.5">
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="preview.exit"
@click=${this.handleExit}
></o-button>
<o-button
variant="secondary"
width="block"
class="flex-1"
translationKey="preview.keep_watching"
@click=${this.hide}
></o-button>
</div>
</div>
`;
}
}
@@ -0,0 +1,51 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView } from "../../../core/game/GameView";
import "../../components/baseComponents/Button";
import { Controller } from "../../Controller";
/**
* Always-on overlay button shown only during a skin preview. Clicking it ends
* the preview by opening <preview-complete-modal>. The preview itself never
* auto-ends, so this is the player's way out.
*/
@customElement("preview-finish-button")
export class PreviewFinishButton extends LitElement implements Controller {
public game: GameView;
public eventBus: EventBus;
@state()
private isPreview = false;
createRenderRoot() {
return this;
}
init() {
this.isPreview = this.game?.config().isPreview() ?? false;
this.requestUpdate();
}
tick() {}
private onFinish() {
const modal = document.querySelector("preview-complete-modal") as
| (HTMLElement & { show?: () => void })
| null;
modal?.show?.();
}
render() {
if (!this.isPreview) return html``;
return html`
<div class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10005]">
<o-button
variant="primary"
translationKey="preview.finish"
@click=${this.onFinish}
></o-button>
</div>
`;
}
}
+5
View File
@@ -257,6 +257,11 @@ export class WinModal extends LitElement implements Controller {
init() {}
tick() {
// Skin previews use their own <preview-complete-modal>; never show the
// win/death modal (and there's no win check in preview games anyway).
if (this.game.config().isPreview()) {
return;
}
const myPlayer = this.game.myPlayer();
if (
!this.hasShownDeathModal &&
+29
View File
@@ -664,3 +664,32 @@ news-button .active button::after {
.remove-player-btn:hover {
background: #ff6666;
}
/* Cinematic skin-preview mode: hide the normal in-game HUD so the player can
just watch their previewed cosmetic spread across the map. The game canvas,
<preview-finish-button>, and <preview-complete-modal> stay visible. */
body.preview-mode build-menu,
body.preview-mode control-panel,
body.preview-mode unit-display,
body.preview-mode attacks-display,
body.preview-mode chat-display,
body.preview-mode events-display,
body.preview-mode actionable-events,
body.preview-mode game-right-sidebar,
body.preview-mode replay-panel,
body.preview-mode player-panel,
body.preview-mode spawn-timer,
body.preview-mode immunity-timer,
body.preview-mode in-game-promo,
body.preview-mode alert-frame,
body.preview-mode chat-modal,
body.preview-mode multi-tab-modal,
body.preview-mode game-left-sidebar,
body.preview-mode performance-overlay,
body.preview-mode player-info-overlay,
body.preview-mode leader-board,
body.preview-mode team-stats,
body.preview-mode heads-up-message,
body.preview-mode emoji-table {
display: none !important;
}
+21 -13
View File
@@ -1,6 +1,7 @@
import { placeName, placeSpawnName } from "../client/hud/NameBoxCalculator";
import { Config } from "./configuration/Config";
import { Executor } from "./execution/ExecutionManager";
import { PreviewAutoExpandExecution } from "./execution/PreviewAutoExpandExecution";
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
import { SpawnTimerExecution } from "./execution/SpawnTimerExecution";
import { WinCheckExecution } from "./execution/WinCheckExecution";
@@ -97,22 +98,29 @@ export class GameRunner {
) {}
init() {
if (this.game.config().gameConfig().gameType !== GameType.Singleplayer) {
const config = this.game.config();
if (config.gameConfig().gameType !== GameType.Singleplayer) {
this.game.addExecution(new SpawnTimerExecution());
}
if (this.game.config().isRandomSpawn()) {
this.game.addExecution(...this.execManager.spawnPlayers());
if (config.isPreview()) {
// Skin-preview sandbox: auto-spawn the human in the map centre and let
// PreviewAutoExpandExecution expand them across the wilderness. No bots,
// nations, or win check — the preview ends only when the user finishes.
this.game.addExecution(...this.execManager.spawnPreviewPlayers());
this.game.addExecution(new PreviewAutoExpandExecution());
} else {
if (config.isRandomSpawn()) {
this.game.addExecution(...this.execManager.spawnPlayers());
}
if (config.bots() > 0) {
this.game.addExecution(...this.execManager.spawnTribes(config.bots()));
}
if (config.spawnNations()) {
this.game.addExecution(...this.execManager.nationExecutions());
}
this.game.addExecution(new WinCheckExecution());
}
if (this.game.config().bots() > 0) {
this.game.addExecution(
...this.execManager.spawnTribes(this.game.config().bots()),
);
}
if (this.game.config().spawnNations()) {
this.game.addExecution(...this.execManager.nationExecutions());
}
this.game.addExecution(new WinCheckExecution());
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
if (!config.isUnitDisabled(UnitType.Factory)) {
this.game.addExecution(
new RecomputeRailClusterExecution(this.game.railNetwork()),
);
+4
View File
@@ -257,6 +257,10 @@ export const GameConfigSchema = z.object({
disableClanTags: z.boolean().optional(),
waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
// Singleplayer-only "skin preview" sandbox: the human auto-spawns in the
// map centre with a huge army and auto-expands into the wilderness so the
// player can watch a cosmetic spread across their territory. Never saved.
isPreview: z.boolean().optional(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks
+4
View File
@@ -97,6 +97,10 @@ export class Config {
return this._isReplay;
}
isPreview(): boolean {
return this._gameConfig.isPreview ?? false;
}
traitorDefenseDebuff(): number {
return 0.5;
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { Execution, Game } from "../game/Game";
import { Execution, Game, PlayerType } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, StampedIntent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
@@ -27,6 +27,7 @@ import { TargetPlayerExecution } from "./TargetPlayerExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { TribeSpawner } from "./TribeSpawner";
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
import { findCenterSpawnTile } from "./Util";
import { PlayerSpawner } from "./utils/PlayerSpawner";
export class Executor {
@@ -134,6 +135,18 @@ export class Executor {
return new PlayerSpawner(this.mg, this.gameID).spawnPlayers();
}
// Spawn every human player in the centre of the map. Used by the singleplayer
// skin-preview sandbox so the player starts "in the middle" without clicking.
spawnPreviewPlayers(): SpawnExecution[] {
const center = findCenterSpawnTile(this.mg) ?? undefined;
const execs: SpawnExecution[] = [];
for (const player of this.mg.allPlayers()) {
if (player.type() !== PlayerType.Human) continue;
execs.push(new SpawnExecution(this.gameID, player.info(), center));
}
return execs;
}
nationExecutions(): Execution[] {
const execs: Execution[] = [];
for (const nation of this.mg.nations()) {
@@ -0,0 +1,72 @@
import { Execution, Game, Player, PlayerType } from "../game/Game";
import { TileRef } from "../game/GameMap";
// The previewing player is given (and kept topped up to) a huge army, matching
// the "100 million troops poured into the wilderness" framing of the feature.
const PREVIEW_TROOPS = 100_000_000;
// How many rings of wilderness to swallow per tick. Higher = faster spread.
const RINGS_PER_TICK = 10;
/**
* Drives the singleplayer skin-preview sandbox: every tick it tops the human
* player up to {@link PREVIEW_TROOPS} and floods their territory outward by one
* ring, conquering every unclaimed land tile bordering them.
*
* The normal attack mechanic throttles expansion into terra nullius to a slow
* crawl no matter how many troops are involved, which is the opposite of what a
* "watch your skin spread across the map" preview wants. Since this only ever
* runs in a throwaway singleplayer sandbox (no opponents, never saved), we
* expand by conquering directly — a smooth radial flood-fill that visibly
* covers the continent with the previewed cosmetic.
*
* Runs until the user clicks "Finish preview"; it naturally goes quiet once
* there's no unclaimed land left to take.
*/
export class PreviewAutoExpandExecution implements Execution {
private active = true;
private mg: Game;
private player: Player | null = null;
init(mg: Game, ticks: number) {
this.mg = mg;
}
tick(ticks: number) {
if (this.player === null) {
this.player =
this.mg.players().find((p) => p.type() === PlayerType.Human) ?? null;
if (this.player === null) return;
}
const player = this.player;
if (!player.isAlive()) return;
// Keep the army huge so the HUD (if shown) reflects the giant force.
player.setTroops(PREVIEW_TROOPS);
// Flood outward by several rings per tick: each pass conquers every
// unclaimed land tile touching the player's current border.
for (let ring = 0; ring < RINGS_PER_TICK; ring++) {
const frontier = new Set<TileRef>();
for (const border of player.borderTiles()) {
this.mg.forEachNeighbor(border, (n) => {
if (this.mg.isLand(n) && !this.mg.hasOwner(n)) {
frontier.add(n);
}
});
}
if (frontier.size === 0) break; // fully expanded — nothing left to take
for (const tile of frontier) {
player.conquer(tile);
}
}
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+43
View File
@@ -156,6 +156,49 @@ export function getSpawnTiles(
return spawnTiles;
}
/**
* Finds the unowned land tile nearest the geometric centre of the map.
*
* Used by the singleplayer skin-preview sandbox to spawn the player "in the
* middle of the map". Scans outward in square rings (Chebyshev distance) from
* the centre, so the result is deterministic. Returns null only if the map has
* no unowned land at all.
*/
export function findCenterSpawnTile(game: Game): TileRef | null {
const width = game.width();
const height = game.height();
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
const valid = (x: number, y: number): TileRef | null => {
if (x < 0 || y < 0 || x >= width || y >= height) return null;
const t = game.ref(x, y);
return game.isLand(t) && !game.hasOwner(t) ? t : null;
};
const center = valid(cx, cy);
if (center !== null) return center;
const maxR = Math.max(width, height);
for (let r = 1; r <= maxR; r++) {
// Top and bottom edges of the ring.
for (let dx = -r; dx <= r; dx++) {
const top = valid(cx + dx, cy - r);
if (top !== null) return top;
const bottom = valid(cx + dx, cy + r);
if (bottom !== null) return bottom;
}
// Left and right edges (excluding the corners already checked above).
for (let dy = -r + 1; dy <= r - 1; dy++) {
const left = valid(cx - r, cy + dy);
if (left !== null) return left;
const right = valid(cx + r, cy + dy);
if (right !== null) return right;
}
}
return null;
}
export function closestTile(
gm: GameMap,
refs: Iterable<TileRef>,
+83
View File
@@ -0,0 +1,83 @@
import { PreviewAutoExpandExecution } from "../src/core/execution/PreviewAutoExpandExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { findCenterSpawnTile } from "../src/core/execution/Util";
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
const gameID: GameID = "game_id";
describe("findCenterSpawnTile", () => {
test("returns an unowned land tile at/near the geometric centre", async () => {
const game = await setup("plains", { isPreview: true });
const tile = findCenterSpawnTile(game);
expect(tile).not.toBeNull();
if (tile === null) return;
// The chosen tile must be spawnable land.
expect(game.isLand(tile)).toBe(true);
expect(game.hasOwner(tile)).toBe(false);
// ...and it should be the centre tile (or very close to it) on an
// all-land map.
const cx = Math.floor(game.width() / 2);
const cy = Math.floor(game.height() / 2);
expect(Math.abs(game.x(tile) - cx)).toBeLessThanOrEqual(2);
expect(Math.abs(game.y(tile) - cy)).toBeLessThanOrEqual(2);
});
});
describe("PreviewAutoExpandExecution", () => {
let game: Game;
let player: Player;
beforeEach(async () => {
game = await setup("plains", { isPreview: true, infiniteTroops: true });
const info = new PlayerInfo(
"previewer",
PlayerType.Human,
null,
"preview_id",
);
game.addPlayer(info);
const center = findCenterSpawnTile(game);
expect(center).not.toBeNull();
game.addExecution(new SpawnExecution(gameID, info, center!));
game.executeNextTick();
game.executeNextTick();
player = game.player(info.id);
});
test("floods the player across the wilderness and keeps the army huge", async () => {
const tilesAfterSpawn = player.numTilesOwned();
expect(player.isAlive()).toBe(true);
game.addExecution(new PreviewAutoExpandExecution());
// Several rings per tick, so just a few ticks balloons the territory.
for (let i = 0; i < 3; i++) {
game.executeNextTick();
}
expect(player.numTilesOwned()).toBeGreaterThan(tilesAfterSpawn * 10);
// The army is kept topped up rather than left at the ~100k natural start.
expect(player.troops()).toBe(100_000_000);
});
test("stops growing once the whole map is owned", async () => {
game.addExecution(new PreviewAutoExpandExecution());
// ~10 rings/tick fills a 100x100 all-land map from the centre quickly.
for (let i = 0; i < 20; i++) {
game.executeNextTick();
}
const filled = player.numTilesOwned();
game.executeNextTick();
game.executeNextTick();
// No unclaimed land left, so the count is stable.
expect(player.numTilesOwned()).toBe(filled);
expect(filled).toBeGreaterThan(9000);
});
});