mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 09:15:03 +00:00
init
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,6 +97,10 @@ export class Config {
|
||||
return this._isReplay;
|
||||
}
|
||||
|
||||
isPreview(): boolean {
|
||||
return this._gameConfig.isPreview ?? false;
|
||||
}
|
||||
|
||||
traitorDefenseDebuff(): number {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user