mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 13:34:38 +00:00
Merge branch 'main' into canbuildtransport-perf
This commit is contained in:
@@ -8,7 +8,6 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@8f717f0dfca33b78d3c933452e42558e4456c8e7
|
||||
@@ -21,6 +20,6 @@ jobs:
|
||||
exempt-pr-assignees: evanpelle
|
||||
exempt-pr-labels: "will not stale"
|
||||
stale-pr-label: "Stale"
|
||||
stale-pr-message: "This pull request is stale because it has been open for 14 days with no activity. If you want to keep this pull request open, add a comment or update the branch."
|
||||
close-pr-message: "This pull request has been closed because twenty-eight days have passed without activity. If someone wants to keep working on it, feel free to take the code."
|
||||
stale-pr-message: "This pull request is stale because it has been open for fourteen days with no activity. If you want to keep this pull request open, add a comment or update the branch."
|
||||
close-pr-message: "This pull request has been closed because fourteen days have passed without activity. If you want to continue work on the code, comment or create a new PR."
|
||||
close-pr-label: "Orphaned"
|
||||
|
||||
@@ -10,3 +10,4 @@ resources/.DS_Store
|
||||
.DS_Store
|
||||
.clinic/
|
||||
CLAUDE.md
|
||||
.idea/
|
||||
|
||||
@@ -151,11 +151,14 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
Translators are welcome! Please feel free to help translate into your language.
|
||||
How to help?
|
||||
|
||||
1. Request to join the dev [Discord](https://discord.gg/K9zernJB5z) (in the application form, say you want to help translate)
|
||||
1. Go to the project's Crowdin translation page: [https://crowdin.com/project/openfront-mls](https://crowdin.com/project/openfront-mls)
|
||||
1. Login if you already have an account/ Sign up if you don't have one
|
||||
1. Select the language you want to translate in/ If your language isn't on the list, click the "Request New Language" button and enter the language you want added there.
|
||||
1. Translate the strings
|
||||
1. Join the translation [Discord](https://discord.gg/3zZzacjWFr)
|
||||
2. Go to the project's Crowdin translation page: [https://crowdin.com/project/openfront-mls](https://crowdin.com/project/openfront-mls)
|
||||
3. Login if you already have an account / Sign up if you don't have one
|
||||
4. Join the project
|
||||
5. Select the language you want to translate in. If your language isn't on the list, click the "Request New Language" button and enter the language you want added there.
|
||||
6. Translate the strings
|
||||
|
||||
Feel free to ask questions in the translation Discord server!
|
||||
|
||||
### Project Governance
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 981 KiB |
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "Achiran",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [785, 985],
|
||||
"flag": "ie",
|
||||
"name": "Inishmore",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1360, 1360],
|
||||
"flag": "ie",
|
||||
"name": "Inishmann",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1630, 1515],
|
||||
"flag": "ie",
|
||||
"name": "Inisheer",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1400, 480],
|
||||
"flag": "ie",
|
||||
"name": "Achill",
|
||||
"strength": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,7 @@ var maps = []struct {
|
||||
{Name: "africa"},
|
||||
{Name: "asia"},
|
||||
{Name: "australia"},
|
||||
{Name: "achiran"},
|
||||
{Name: "baikal"},
|
||||
{Name: "betweentwoseas"},
|
||||
{Name: "blacksea"},
|
||||
|
||||
+13
-1
@@ -157,6 +157,7 @@
|
||||
"account_modal": {
|
||||
"title": "Account",
|
||||
"logged_in_as": "Logged in as {email}",
|
||||
"fetching_account": "Fetching account information...",
|
||||
"logged_in_with_discord": "Logged in with Discord",
|
||||
"recovery_email_sent": "Recovery email sent to {email}"
|
||||
},
|
||||
@@ -192,7 +193,8 @@
|
||||
"japan": "Japan",
|
||||
"yenisei": "Yenisei",
|
||||
"pluto": "Pluto",
|
||||
"montreal": "Montreal"
|
||||
"montreal": "Montreal",
|
||||
"achiran": "Achiran"
|
||||
},
|
||||
"map_categories": {
|
||||
"continental": "Continental",
|
||||
@@ -222,6 +224,12 @@
|
||||
"teams_Quads": "Quads (teams of 4)",
|
||||
"teams": "{num} teams"
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
"title": "Matchmaking",
|
||||
"connecting": "Connecting to matchmaking server...",
|
||||
"searching": "Searching for game...",
|
||||
"waiting_for_game": "Waiting for game to start..."
|
||||
},
|
||||
"username": {
|
||||
"enter_username": "Enter your username",
|
||||
"not_string": "Username must be a string.",
|
||||
@@ -346,6 +354,8 @@
|
||||
"build_atom_bomb_desc": "Build an Atom Bomb under your cursor.",
|
||||
"build_hydrogen_bomb": "Build Hydrogen Bomb",
|
||||
"build_hydrogen_bomb_desc": "Build a Hydrogen Bomb under your cursor.",
|
||||
"build_mirv": "Build MIRV",
|
||||
"build_mirv_desc": "Build a MIRV under your cursor.",
|
||||
"attack_ratio_controls": "Attack Ratio Controls",
|
||||
"attack_ratio_up": "Increase Attack Ratio",
|
||||
"attack_ratio_up_desc": "Increase attack ratio by 10%",
|
||||
@@ -596,6 +606,8 @@
|
||||
"nuke": "Nukes sent by them to you",
|
||||
"start_trade": "Start Trading",
|
||||
"stop_trade": "Stop Trading",
|
||||
"stop_trade_all": "Stop Trading with All",
|
||||
"start_trade_all": "Start Trading with All",
|
||||
"alliances": "Alliances",
|
||||
"flag": "Flag",
|
||||
"chat": "Chat",
|
||||
|
||||
+71
-7
@@ -130,9 +130,8 @@
|
||||
"disable_nations": "Deaktiviraj nacije",
|
||||
"instant_build": "Instantna gradnja",
|
||||
"infinite_gold": "Neomejeno zlata",
|
||||
"donate_gold": "Doniraj zlato",
|
||||
"infinite_troops": "Neomejene enote",
|
||||
"donate_troops": "Doniraj enote",
|
||||
"compact_map": "Mini mapa",
|
||||
"disable_nukes": "Deaktiviraj bombe",
|
||||
"enables_title": "Aktiviraj nastavitve",
|
||||
"start": "Začni igro"
|
||||
@@ -177,8 +176,10 @@
|
||||
"halkidiki": "Halkidiki",
|
||||
"straitofgibraltar": "Gibraltarska ožina",
|
||||
"italia": "Italija",
|
||||
"japan": "Japonska",
|
||||
"yenisei": "Jenisej",
|
||||
"pluto": "Pluto"
|
||||
"pluto": "Pluto",
|
||||
"montreal": "Montreal"
|
||||
},
|
||||
"map_categories": {
|
||||
"continental": "Celinska",
|
||||
@@ -196,8 +197,9 @@
|
||||
"join_lobby": "Pridruži se sobi",
|
||||
"checking": "Preverjanje sobe...",
|
||||
"not_found": "Sobe ni mogoče najti. Prosimo preverite ID in poskusite znova.",
|
||||
"error": "Prišlo je do napake. Prosimo, poskusite znova.",
|
||||
"joined_waiting": "Uspešna pridružitev! Čakanje na začetek igre..."
|
||||
"error": "Prišlo je do napake. Poskusite znova ali se obrnite na podporo.",
|
||||
"joined_waiting": "Uspešna pridružitev! Čakanje na začetek igre...",
|
||||
"version_mismatch": "Ta soba je bila ustvarjena z drugo verzijo igre. Napaka pridružitve."
|
||||
},
|
||||
"public_lobby": {
|
||||
"join": "Pridruži se naslednji igri",
|
||||
@@ -227,6 +229,7 @@
|
||||
"donate_gold": "Doniraj zlato",
|
||||
"infinite_troops": "Neomejene enote",
|
||||
"donate_troops": "Doniraj enote",
|
||||
"compact_map": "Mini mapa",
|
||||
"enables_title": "Aktiviraj nastavitve",
|
||||
"player": "Igralec",
|
||||
"players": "Igralci",
|
||||
@@ -314,6 +317,8 @@
|
||||
"territory_patterns_desc": "Izberite, ali želite v igri prikazati vzorce ozemlja",
|
||||
"performance_overlay_label": "Prekrivni sloj zmogljivosti",
|
||||
"performance_overlay_desc": "Preklopi prekrivni sloj zmogljivosti. Ko je omogočen, bo prikazan prekrivni sloj zmogljivosti. Med igro pritisnite shift-D za preklop.",
|
||||
"performance_overlay_enabled": "Prekrivanje zmogljivosti je omogočeno",
|
||||
"performance_overlay_disabled": "Prekrivanje zmogljivosti je onemogočeno",
|
||||
"easter_writing_speed_label": "Množitelj hitrosti pisanja",
|
||||
"easter_writing_speed_desc": "Prilagodite hitrost pretvarjanja, da programirate (x1–x100)",
|
||||
"easter_bug_count_label": "Število hroščev",
|
||||
@@ -494,7 +499,8 @@
|
||||
"nation": "Narod",
|
||||
"player": "Igralec",
|
||||
"team": "Ekipa",
|
||||
"d_troops": "Obrambne enote",
|
||||
"alliance_timeout": "Zavezništvo poteče čez",
|
||||
"troops": "Enote",
|
||||
"a_troops": "Napadalne enote",
|
||||
"gold": "Zlato",
|
||||
"ports": "Luka",
|
||||
@@ -585,7 +591,7 @@
|
||||
"choose_spawn": "Izberite začetno lokacijo"
|
||||
},
|
||||
"territory_patterns": {
|
||||
"title": "Izberite vzorec ozemlja",
|
||||
"title": "Izberi poslikavo območja",
|
||||
"purchase": "Nakup",
|
||||
"blocked": {
|
||||
"login": "Za dostop do tega vzorca morate biti prijavljeni.",
|
||||
@@ -644,5 +650,63 @@
|
||||
"radial_menu": {
|
||||
"delete_unit_title": "Pobriši enoto",
|
||||
"delete_unit_description": "Pritisni za odstranitev najbližjih enot"
|
||||
},
|
||||
"discord_user_header": {
|
||||
"avatar_alt": "Avatar"
|
||||
},
|
||||
"player_stats_table": {
|
||||
"building_stats": "Statistika zgradbe",
|
||||
"ship_arrivals": "Prihajajoče ladje",
|
||||
"nuke_stats": "Statistika bomb",
|
||||
"player_metrics": "Metrika igralca",
|
||||
"building": "Zgradba",
|
||||
"ship_type": "Vrsta ladje",
|
||||
"weapon": "Orožje",
|
||||
"built": "Zgrajeno",
|
||||
"destroyed": "Uničeno",
|
||||
"captured": "Ujeto",
|
||||
"lost": "Zgubljeno",
|
||||
"hits": "Zadetki",
|
||||
"launched": "Izstreljeno",
|
||||
"landed": "Pristanek",
|
||||
"sent": "Poslano",
|
||||
"arrived": "Prišlo",
|
||||
"attack": "Napad",
|
||||
"received": "Prejeto",
|
||||
"cancelled": "Prekinjeno",
|
||||
"count": "Število",
|
||||
"gold": "Zlato",
|
||||
"workers": "Delavci",
|
||||
"war": "Vojna",
|
||||
"trade": "Trgovanje",
|
||||
"steal": "Kraja",
|
||||
"unit": {
|
||||
"city": "Mesto",
|
||||
"port": "Luka",
|
||||
"defp": "Obrambni stolp",
|
||||
"saml": "SAM Izstreljevalec",
|
||||
"silo": "Izstreljevalec raket",
|
||||
"wshp": "Bojna ladja",
|
||||
"fact": "Tovarna",
|
||||
"trade": "Trgovska ladja",
|
||||
"trans": "Transportna ladja",
|
||||
"abomb": "Atomska bomba",
|
||||
"hbomb": "Vodikova bomba",
|
||||
"mirv": "MIRV",
|
||||
"mirvw": "MIRV bojna glava"
|
||||
}
|
||||
},
|
||||
"game_list": {
|
||||
"recent_games": "Nedavne igre",
|
||||
"game_id": "ID sobe",
|
||||
"mode": "Način",
|
||||
"mode_ffa": "Prosto za vse",
|
||||
"mode_team": "Ekipe",
|
||||
"view": "Poglej",
|
||||
"details": "Detajli",
|
||||
"started": "Začeto",
|
||||
"map": "Mapa",
|
||||
"difficulty": "Težavnost",
|
||||
"type": "Tip"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +130,8 @@
|
||||
"disable_nations": "禁用国家",
|
||||
"instant_build": "立即建造",
|
||||
"infinite_gold": "无限黄金",
|
||||
"donate_gold": "捐赠金币",
|
||||
"infinite_troops": "无限军队",
|
||||
"donate_troops": "捐赠军队",
|
||||
"compact_map": "小地图",
|
||||
"disable_nukes": "禁用核弹",
|
||||
"enables_title": "启用设置",
|
||||
"start": "开始游戏"
|
||||
@@ -177,8 +176,10 @@
|
||||
"halkidiki": "哈尔基季基",
|
||||
"straitofgibraltar": "直布罗陀海峡",
|
||||
"italia": "意大利",
|
||||
"japan": "日本",
|
||||
"yenisei": "叶尼塞河",
|
||||
"pluto": "冥王星"
|
||||
"pluto": "冥王星",
|
||||
"montreal": "蒙特利尔"
|
||||
},
|
||||
"map_categories": {
|
||||
"continental": "大陆",
|
||||
@@ -196,8 +197,9 @@
|
||||
"join_lobby": "加入房间",
|
||||
"checking": "正在确认房间...",
|
||||
"not_found": "找不到房间。请检查 ID 然后重试。",
|
||||
"error": "发生了错误,请再试一次。",
|
||||
"joined_waiting": "加入成功!正在等待游戏开始..."
|
||||
"error": "发生错误。请再试一次或联系支持人员。",
|
||||
"joined_waiting": "加入成功!正在等待游戏开始...",
|
||||
"version_mismatch": "这场游戏基于另一个版本,无法加入。"
|
||||
},
|
||||
"public_lobby": {
|
||||
"join": "加入下一场游戏",
|
||||
@@ -227,6 +229,7 @@
|
||||
"donate_gold": "捐赠金币",
|
||||
"infinite_troops": "无限军队",
|
||||
"donate_troops": "捐赠军队",
|
||||
"compact_map": "小地图",
|
||||
"enables_title": "启用设置",
|
||||
"player": "玩家",
|
||||
"players": "玩家",
|
||||
@@ -314,6 +317,8 @@
|
||||
"territory_patterns_desc": "选择是否在游戏中显示领土皮肤",
|
||||
"performance_overlay_label": "性能叠层",
|
||||
"performance_overlay_desc": "切换性能叠层。启用后将显示性能叠层。在游戏过程中按下 Shift+D 可进行切换。",
|
||||
"performance_overlay_enabled": "已启用性能叠层",
|
||||
"performance_overlay_disabled": "已禁用性能叠层",
|
||||
"easter_writing_speed_label": "写入速度乘数",
|
||||
"easter_writing_speed_desc": "调节你“假装写代码”的速度 (x1–x100)",
|
||||
"easter_bug_count_label": "Bug 计数",
|
||||
@@ -494,7 +499,8 @@
|
||||
"nation": "国家",
|
||||
"player": "玩家",
|
||||
"team": "队伍",
|
||||
"d_troops": "防守军队",
|
||||
"alliance_timeout": "结盟剩余时长",
|
||||
"troops": "军队",
|
||||
"a_troops": "进攻军队",
|
||||
"gold": "黄金",
|
||||
"ports": "港口",
|
||||
@@ -541,7 +547,7 @@
|
||||
},
|
||||
"relation": {
|
||||
"hostile": "敌对",
|
||||
"distrustful": "不可信",
|
||||
"distrustful": "可疑",
|
||||
"neutral": "中立",
|
||||
"friendly": "友好",
|
||||
"default": "默认"
|
||||
@@ -644,5 +650,63 @@
|
||||
"radial_menu": {
|
||||
"delete_unit_title": "删除单位",
|
||||
"delete_unit_description": "点击删除最近的单位"
|
||||
},
|
||||
"discord_user_header": {
|
||||
"avatar_alt": "头像"
|
||||
},
|
||||
"player_stats_table": {
|
||||
"building_stats": "建筑统计",
|
||||
"ship_arrivals": "船只抵达",
|
||||
"nuke_stats": "核弹统计",
|
||||
"player_metrics": "玩家指标",
|
||||
"building": "建筑",
|
||||
"ship_type": "船只类型",
|
||||
"weapon": "武器",
|
||||
"built": "已建造",
|
||||
"destroyed": "已摧毁",
|
||||
"captured": "已捕获",
|
||||
"lost": "损失",
|
||||
"hits": "击中",
|
||||
"launched": "已发射",
|
||||
"landed": "已登陆",
|
||||
"sent": "已发送",
|
||||
"arrived": "已抵达",
|
||||
"attack": "攻击",
|
||||
"received": "已收到",
|
||||
"cancelled": "已取消",
|
||||
"count": "总计",
|
||||
"gold": "黄金",
|
||||
"workers": "工人",
|
||||
"war": "战争",
|
||||
"trade": "交易",
|
||||
"steal": "偷窃",
|
||||
"unit": {
|
||||
"city": "城市",
|
||||
"port": "港口",
|
||||
"defp": "防守据点",
|
||||
"saml": "防空塔",
|
||||
"silo": "导弹发射井",
|
||||
"wshp": "军舰",
|
||||
"fact": "工厂",
|
||||
"trade": "贸易船",
|
||||
"trans": "运输船",
|
||||
"abomb": "原子弹",
|
||||
"hbomb": "氢弹",
|
||||
"mirv": "MIRV",
|
||||
"mirvw": "MIRV 弹头"
|
||||
}
|
||||
},
|
||||
"game_list": {
|
||||
"recent_games": "近期对局",
|
||||
"game_id": "游戏 ID",
|
||||
"mode": "模式",
|
||||
"mode_ffa": "混战",
|
||||
"mode_team": "团队",
|
||||
"view": "视图",
|
||||
"details": "详细信息",
|
||||
"started": "已开始",
|
||||
"map": "地图",
|
||||
"difficulty": "难度",
|
||||
"type": "类型"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1700,
|
||||
"num_land_tiles": 1149943,
|
||||
"width": 2000
|
||||
},
|
||||
"map16x": {
|
||||
"height": 425,
|
||||
"num_land_tiles": 69861,
|
||||
"width": 500
|
||||
},
|
||||
"map4x": {
|
||||
"height": 850,
|
||||
"num_land_tiles": 284530,
|
||||
"width": 1000
|
||||
},
|
||||
"name": "Achiran",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [785, 985],
|
||||
"flag": "ie",
|
||||
"name": "Inishmore",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1360, 1360],
|
||||
"flag": "ie",
|
||||
"name": "Inishmann",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1630, 1515],
|
||||
"flag": "ie",
|
||||
"name": "Inisheer",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
"coordinates": [1400, 480],
|
||||
"flag": "ie",
|
||||
"name": "Achill",
|
||||
"strength": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 884 B |
+115
-11
@@ -1,9 +1,23 @@
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import {
|
||||
PlayerGame,
|
||||
PlayerStatsTree,
|
||||
UserMeResponse,
|
||||
} from "../core/ApiSchemas";
|
||||
import "./components/baseComponents/stats/DiscordUserHeader";
|
||||
import "./components/baseComponents/stats/GameList";
|
||||
import "./components/baseComponents/stats/PlayerStatsTable";
|
||||
import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { discordLogin, getApiBase, getUserMe, logOut } from "./jwt";
|
||||
import {
|
||||
discordLogin,
|
||||
fetchPlayerById,
|
||||
getApiBase,
|
||||
getUserMe,
|
||||
logOut,
|
||||
} from "./jwt";
|
||||
import { isInIframe, translateText } from "./Utils";
|
||||
|
||||
@customElement("account-modal")
|
||||
@@ -14,12 +28,33 @@ export class AccountModal extends LitElement {
|
||||
};
|
||||
|
||||
@state() private email: string = "";
|
||||
@state() private isLoadingUser: boolean = false;
|
||||
|
||||
private loggedInEmail: string | null = null;
|
||||
private loggedInDiscord: string | null = null;
|
||||
private userMeResponse: UserMeResponse | null = null;
|
||||
private playerId: string | null = null;
|
||||
private statsTree: PlayerStatsTree | null = null;
|
||||
private recentGames: PlayerGame[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
document.addEventListener("userMeResponse", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
if (customEvent.detail) {
|
||||
this.userMeResponse = customEvent.detail as UserMeResponse;
|
||||
this.playerId = this.userMeResponse?.player?.publicId;
|
||||
if (this.playerId === undefined) {
|
||||
this.statsTree = null;
|
||||
this.recentGames = [];
|
||||
}
|
||||
} else {
|
||||
this.statsTree = null;
|
||||
this.recentGames = [];
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -38,6 +73,16 @@ export class AccountModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderInner() {
|
||||
if (this.isLoadingUser) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-2">${translateText("account_modal.fetching_account")}</p>
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (this.loggedInDiscord) {
|
||||
return this.renderLoggedInDiscord();
|
||||
} else if (this.loggedInEmail) {
|
||||
@@ -47,15 +92,39 @@ export class AccountModal extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private viewGame(gameId: string): void {
|
||||
this.close();
|
||||
const path = location.pathname;
|
||||
const { search } = location;
|
||||
const hash = `#join=${encodeURIComponent(gameId)}`;
|
||||
const newUrl = `${path}${search}${hash}`;
|
||||
|
||||
history.pushState({ join: gameId }, "", newUrl);
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
}
|
||||
|
||||
private renderLoggedInDiscord() {
|
||||
return html`
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<p class="text-white text-center mb-4">
|
||||
<div class="mb-4 text-center">
|
||||
<p class="text-white mb-4">
|
||||
Logged in with Discord as ${this.loggedInDiscord}
|
||||
</p>
|
||||
${this.logoutButton()}
|
||||
</div>
|
||||
<div class="flex flex-col items-center mt-2 mb-4">
|
||||
<discord-user-header
|
||||
.data=${this.userMeResponse?.user?.discord ?? null}
|
||||
></discord-user-header>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
<hr class="w-2/3 border-gray-600 my-2" />
|
||||
<game-list
|
||||
.games=${this.recentGames}
|
||||
.onViewGame=${(id: string) => this.viewGame(id)}
|
||||
></game-list>
|
||||
</div>
|
||||
${this.logoutButton()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -208,13 +277,30 @@ export class AccountModal extends LitElement {
|
||||
discordLogin();
|
||||
}
|
||||
|
||||
public async open() {
|
||||
const userMe = await getUserMe();
|
||||
if (userMe) {
|
||||
this.loggedInEmail = userMe.user.email ?? null;
|
||||
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
|
||||
}
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
this.isLoadingUser = true;
|
||||
|
||||
void getUserMe()
|
||||
.then((userMe) => {
|
||||
if (userMe) {
|
||||
this.loggedInEmail = userMe.user.email ?? null;
|
||||
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
|
||||
if (this.playerId) {
|
||||
this.loadFromApi(this.playerId);
|
||||
}
|
||||
} else {
|
||||
this.loggedInEmail = null;
|
||||
this.loggedInDiscord = null;
|
||||
}
|
||||
this.isLoadingUser = false;
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Failed to fetch user info in AccountModal.open():", err);
|
||||
this.isLoadingUser = false;
|
||||
this.requestUpdate();
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -228,6 +314,24 @@ export class AccountModal extends LitElement {
|
||||
// Refresh the page after logout to update the UI state
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private async loadFromApi(playerId: string): Promise<void> {
|
||||
try {
|
||||
const data = await fetchPlayerById(playerId);
|
||||
if (!data) {
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
this.recentGames = data.games;
|
||||
this.statsTree = data.stats;
|
||||
|
||||
this.requestUpdate();
|
||||
} catch (err) {
|
||||
console.warn("Failed to load player data:", err);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("account-button")
|
||||
|
||||
@@ -74,10 +74,11 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
|
||||
export function patternRelationship(
|
||||
pattern: Pattern,
|
||||
colorPalette: { name: string; isArchived?: boolean } | null,
|
||||
userMeResponse: UserMeResponse | null,
|
||||
userMeResponse: UserMeResponse | false,
|
||||
affiliateCode: string | null,
|
||||
): "owned" | "purchasable" | "blocked" {
|
||||
const flares = userMeResponse?.player.flares ?? [];
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
if (flares.includes("pattern:*")) {
|
||||
return "owned";
|
||||
}
|
||||
|
||||
+31
-1
@@ -1,5 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { isInIframe } from "./Utils";
|
||||
|
||||
const LEFT_FUSE = "gutter-ad-container-left";
|
||||
@@ -17,6 +18,31 @@ export class GutterAds extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
private readonly boundUserMeHandler = (event: Event) =>
|
||||
this.onUserMe((event as CustomEvent<UserMeResponse | false>).detail);
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
"userMeResponse",
|
||||
this.boundUserMeHandler as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
private onUserMe(userMeResponse: UserMeResponse | false): void {
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
const hasFlare = flares.some((flare) => flare.startsWith("pattern:"));
|
||||
if (hasFlare) {
|
||||
console.log("No ads because you have patterns");
|
||||
window.enableAds = false;
|
||||
} else {
|
||||
console.log("No flares, showing ads");
|
||||
this.show();
|
||||
window.enableAds = true;
|
||||
}
|
||||
}
|
||||
|
||||
private isScreenLargeEnough(): boolean {
|
||||
return window.innerWidth >= MIN_SCREEN_WIDTH;
|
||||
}
|
||||
@@ -52,6 +78,10 @@ export class GutterAds extends LitElement {
|
||||
this.isVisible = false;
|
||||
console.log("hiding GutterAds");
|
||||
this.destroyAds();
|
||||
document.removeEventListener(
|
||||
"userMeResponse",
|
||||
this.boundUserMeHandler as EventListener,
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -95,7 +125,7 @@ export class GutterAds extends LitElement {
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.destroyAds();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
+20
-111
@@ -2,7 +2,6 @@ import version from "../../resources/version.txt";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./AccountModal";
|
||||
@@ -22,6 +21,8 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
|
||||
import "./LangSelector";
|
||||
import { LangSelector } from "./LangSelector";
|
||||
import { LanguageModal } from "./LanguageModal";
|
||||
import "./Matchmaking";
|
||||
import { MatchmakingModal } from "./Matchmaking";
|
||||
import { NewsModal } from "./NewsModal";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
@@ -36,17 +37,17 @@ import {
|
||||
generateCryptoRandomUUID,
|
||||
incrementGamesPlayed,
|
||||
isInIframe,
|
||||
translateText,
|
||||
} from "./Utils";
|
||||
import "./components/NewsButton";
|
||||
import { NewsButton } from "./components/NewsButton";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { discordLogin, getUserMe, isLoggedIn } from "./jwt";
|
||||
import { getUserMe, isLoggedIn } from "./jwt";
|
||||
import "./styles.css";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
enableAds: boolean;
|
||||
PageOS: {
|
||||
session: {
|
||||
newPageView: () => void;
|
||||
@@ -57,6 +58,7 @@ declare global {
|
||||
destroyZone: (id: string) => void;
|
||||
pageInit: (options?: any) => void;
|
||||
que: Array<() => void>;
|
||||
destroySticky: () => void;
|
||||
};
|
||||
ramp: {
|
||||
que: Array<() => void>;
|
||||
@@ -100,6 +102,7 @@ class Client {
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private patternsModal: TerritoryPatternsModal;
|
||||
private tokenLoginModal: TokenLoginModal;
|
||||
private matchmakingModal: MatchmakingModal;
|
||||
|
||||
private gutterAds: GutterAds;
|
||||
|
||||
@@ -256,6 +259,16 @@ class Client {
|
||||
console.warn("Token login modal element not found");
|
||||
}
|
||||
|
||||
this.matchmakingModal = document.querySelector(
|
||||
"matchmaking-modal",
|
||||
) as MatchmakingModal;
|
||||
if (
|
||||
!this.matchmakingModal ||
|
||||
!(this.matchmakingModal instanceof MatchmakingModal)
|
||||
) {
|
||||
console.warn("Matchmaking modal element not found");
|
||||
}
|
||||
|
||||
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("userMeResponse", {
|
||||
@@ -265,98 +278,12 @@ class Client {
|
||||
}),
|
||||
);
|
||||
|
||||
const config = await getServerConfigFromClient();
|
||||
if (!hasAllowedFlare(userMeResponse, config)) {
|
||||
if (userMeResponse === false) {
|
||||
// Login is required
|
||||
document.body.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
">
|
||||
<div style="
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 2em;
|
||||
margin: 5em;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
">
|
||||
<p style="margin-bottom: 1em;">${translateText("auth.login_required")}</p>
|
||||
<p style="margin-bottom: 1.5em;">${translateText("auth.redirecting")}</p>
|
||||
<div style="width: 100%; height: 8px; background-color: #444; border-radius: 4px; overflow: hidden;">
|
||||
<div style="
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background-color: #4caf50;
|
||||
animation: fillBar 5s linear forwards;
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-image"></div>
|
||||
<style>
|
||||
@keyframes fillBar {
|
||||
from { width: 0%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
setTimeout(discordLogin, 5000);
|
||||
} else {
|
||||
// Unauthorized
|
||||
document.body.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
">
|
||||
<div style="
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 2em;
|
||||
margin: 5em;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
">
|
||||
<p style="margin-bottom: 1em;">${translateText("auth.not_authorized")}</p>
|
||||
<p>${translateText("auth.contact_admin")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-image"></div>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
} else if (userMeResponse === false) {
|
||||
// Not logged in
|
||||
this.patternsModal.onUserMe(null);
|
||||
} else {
|
||||
if (userMeResponse !== false) {
|
||||
// Authorized
|
||||
console.log(
|
||||
`Your player ID is ${userMeResponse.player.publicId}\n` +
|
||||
"Sharing this ID will allow others to view your game history and stats.",
|
||||
);
|
||||
this.patternsModal.onUserMe(userMeResponse);
|
||||
const flares = (userMeResponse.player.flares ?? []).filter((flare) =>
|
||||
flare.startsWith("pattern:"),
|
||||
);
|
||||
if (flares.length > 0) {
|
||||
console.log("Hiding gutter ads because you have patterns");
|
||||
this.gutterAds.hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -598,6 +525,7 @@ class Client {
|
||||
"flag-input-modal",
|
||||
"account-button",
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
].forEach((tag) => {
|
||||
const modal = document.querySelector(tag) as HTMLElement & {
|
||||
close?: () => void;
|
||||
@@ -605,7 +533,7 @@ class Client {
|
||||
};
|
||||
if (modal?.close) {
|
||||
modal.close();
|
||||
} else if ("isModalOpen" in modal) {
|
||||
} else if (modal && "isModalOpen" in modal) {
|
||||
modal.isModalOpen = false;
|
||||
}
|
||||
});
|
||||
@@ -628,12 +556,6 @@ class Client {
|
||||
this.publicLobby.stop();
|
||||
incrementGamesPlayed();
|
||||
|
||||
try {
|
||||
window.PageOS.session.newPageView();
|
||||
} catch (e) {
|
||||
console.error("Error calling newPageView", e);
|
||||
}
|
||||
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
@@ -675,7 +597,6 @@ class Client {
|
||||
window.fusetag.pageInit({
|
||||
blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"],
|
||||
});
|
||||
this.gutterAds.show();
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
@@ -697,7 +618,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
function getPlayToken(): string {
|
||||
export function getPlayToken(): string {
|
||||
const result = isLoggedIn();
|
||||
if (result !== false) return result.token;
|
||||
return getPersistentIDFromCookie();
|
||||
@@ -735,15 +656,3 @@ function getPersistentIDFromCookie(): string {
|
||||
|
||||
return newID;
|
||||
}
|
||||
|
||||
function hasAllowedFlare(
|
||||
userMeResponse: UserMeResponse | false,
|
||||
config: ServerConfig,
|
||||
) {
|
||||
const allowed = config.allowedFlares();
|
||||
if (allowed === undefined) return true;
|
||||
if (userMeResponse === false) return false;
|
||||
const flares = userMeResponse.player.flares;
|
||||
if (flares === undefined) return false;
|
||||
return allowed.length === 0 || allowed.some((f) => flares.includes(f));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { generateID } from "../core/Util";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { getPlayToken, JoinLobbyEvent } from "./Main";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("matchmaking-modal")
|
||||
export class MatchmakingModal extends LitElement {
|
||||
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private connected = false;
|
||||
@state() private socket: WebSocket | null = null;
|
||||
|
||||
@state() private gameID: string | null = null;
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal
|
||||
id="matchmaking-modal"
|
||||
title="${translateText("matchmaking_modal.title")}"
|
||||
>
|
||||
${this.renderInner()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderInner() {
|
||||
if (!this.connected) {
|
||||
return html`${translateText("matchmaking_modal.connecting")}`;
|
||||
}
|
||||
if (this.gameID === null) {
|
||||
return html`${translateText("matchmaking_modal.searching")}`;
|
||||
} else {
|
||||
return html`${translateText("matchmaking_modal.waiting_for_game")}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
const config = await getServerConfigFromClient();
|
||||
|
||||
this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`);
|
||||
this.socket.onopen = () => {
|
||||
console.log("Connected to matchmaking server");
|
||||
setTimeout(() => {
|
||||
// Set a delay so the user can see the "connecting" message,
|
||||
// otherwise the "searching" message will be shown immediately.
|
||||
this.connected = true;
|
||||
this.requestUpdate();
|
||||
}, 1000);
|
||||
this.socket?.send(
|
||||
JSON.stringify({
|
||||
type: "auth",
|
||||
playToken: getPlayToken(),
|
||||
}),
|
||||
);
|
||||
};
|
||||
this.socket.onmessage = (event) => {
|
||||
console.log(event.data);
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "match-assignment") {
|
||||
this.socket?.close();
|
||||
console.log(`matchmaking: got game ID: ${data.gameId}`);
|
||||
this.gameID = data.gameId;
|
||||
}
|
||||
};
|
||||
this.socket.onerror = (event: ErrorEvent) => {
|
||||
console.error("WebSocket error occurred:", event);
|
||||
};
|
||||
this.socket.onclose = (event) => {
|
||||
console.log("Matchmaking server closed connection");
|
||||
};
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.connected = false;
|
||||
this.socket?.close();
|
||||
this.modalEl?.close();
|
||||
if (this.gameCheckInterval) {
|
||||
clearInterval(this.gameCheckInterval);
|
||||
this.gameCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async open() {
|
||||
this.modalEl?.open();
|
||||
this.requestUpdate();
|
||||
this.connect();
|
||||
this.gameCheckInterval = setInterval(() => this.checkGame(), 3000);
|
||||
}
|
||||
|
||||
private async checkGame() {
|
||||
if (this.gameID === null) {
|
||||
return;
|
||||
}
|
||||
const config = await getServerConfigFromClient();
|
||||
const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const gameInfo = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.error(`Error checking game ${this.gameID}: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gameInfo.exists) {
|
||||
console.info(`Game ${this.gameID} does not exist or hasn't started yet`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.gameCheckInterval) {
|
||||
clearInterval(this.gameCheckInterval);
|
||||
this.gameCheckInterval = null;
|
||||
}
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: this.gameID,
|
||||
clientID: generateID(),
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("matchmaking-button")
|
||||
export class MatchmakingButton extends LitElement {
|
||||
@query("matchmaking-modal") private matchmakingModal: MatchmakingModal;
|
||||
@state() private matchmakingEnabled = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const config = await getServerConfigFromClient();
|
||||
this.matchmakingEnabled = config.enableMatchmaking();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.matchmakingEnabled) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="z-[9999]">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-full h-16 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
title="${translateText("matchmaking_modal.title")}"
|
||||
>
|
||||
Matchmaking
|
||||
</button>
|
||||
</div>
|
||||
<matchmaking-modal></matchmaking-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.matchmakingModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.matchmakingModal?.close();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -37,14 +37,24 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
private affiliateCode: string | null = null;
|
||||
|
||||
private userMeResponse: UserMeResponse | null = null;
|
||||
private userMeResponse: UserMeResponse | false = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | null) {
|
||||
if (userMeResponse === null) {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
"userMeResponse",
|
||||
(event: CustomEvent<UserMeResponse | false>) => {
|
||||
this.onUserMe(event.detail);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | false) {
|
||||
if (userMeResponse === false) {
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
this.selectedPattern = null;
|
||||
this.selectedColor = null;
|
||||
@@ -136,7 +146,11 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderColorSwatchGrid(): TemplateResult {
|
||||
const hexCodes = (this.userMeResponse?.player.flares ?? [])
|
||||
const hexCodes = (
|
||||
this.userMeResponse === false
|
||||
? []
|
||||
: (this.userMeResponse.player.flares ?? [])
|
||||
)
|
||||
.filter((flare) => flare.startsWith("color:"))
|
||||
.map((flare) => "#" + flare.split(":")[1]);
|
||||
return html`
|
||||
|
||||
@@ -132,6 +132,10 @@ export class SendEmbargoIntentEvent implements GameEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SendEmbargoAllIntentEvent implements GameEvent {
|
||||
constructor(public readonly action: "start" | "stop") {}
|
||||
}
|
||||
|
||||
export class SendDeleteUnitIntentEvent implements GameEvent {
|
||||
constructor(public readonly unitId: number) {}
|
||||
}
|
||||
@@ -226,6 +230,9 @@ export class Transport {
|
||||
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
|
||||
this.onSendEmbargoIntent(e),
|
||||
);
|
||||
this.eventBus.on(SendEmbargoAllIntentEvent, (e) =>
|
||||
this.onSendEmbargoAllIntent(e),
|
||||
);
|
||||
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e));
|
||||
|
||||
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
|
||||
@@ -528,6 +535,14 @@ export class Transport {
|
||||
});
|
||||
}
|
||||
|
||||
private onSendEmbargoAllIntent(event: SendEmbargoAllIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "embargo_all",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
action: event.action,
|
||||
});
|
||||
}
|
||||
|
||||
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "build_unit",
|
||||
|
||||
@@ -523,8 +523,8 @@ export class UserSettingModal extends LitElement {
|
||||
|
||||
<setting-keybind
|
||||
action="buildMIRV"
|
||||
label=${translateText("user_setting.build_MIRV")}
|
||||
description=${translateText("user_setting.build_MIRV_desc")}
|
||||
label=${translateText("user_setting.build_mirv")}
|
||||
description=${translateText("user_setting.build_mirv_desc")}
|
||||
defaultKey="Digit0"
|
||||
.value=${this.keybinds["buildMIRV"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
Yenisei: "Yenisei",
|
||||
Pluto: "Pluto",
|
||||
Montreal: "Montreal",
|
||||
Achiran: "Achiran",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -137,7 +137,6 @@ export function renderPatternPreview(
|
||||
width: number,
|
||||
height: number,
|
||||
): TemplateResult {
|
||||
console.log("renderPatternPreview", pattern);
|
||||
if (pattern === null) {
|
||||
return renderBlankPreview(width, height);
|
||||
}
|
||||
|
||||
@@ -23,12 +23,19 @@ export class SettingKeybind extends LitElement {
|
||||
<div class="setting-label-group">
|
||||
<label class="setting-label block mb-1">${this.label}</label>
|
||||
|
||||
<div class="setting-keybind-box">
|
||||
<div class="setting-keybind-description">${this.description}</div>
|
||||
<div class="setting-keybind-box flex flex-wrap items-start gap-2">
|
||||
<div
|
||||
class="setting-keybind-description flex-1 min-w-[240px] max-w-full whitespace-normal break-words text-sm text-gray-300"
|
||||
style="word-break: break-word;"
|
||||
>
|
||||
${this.description}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 gap-y-1 basis-full sm:basis-auto min-w-0"
|
||||
>
|
||||
<span
|
||||
class="setting-key"
|
||||
class="setting-key shrink-0"
|
||||
tabindex="0"
|
||||
@keydown=${this.handleKeydown}
|
||||
@click=${this.startListening}
|
||||
@@ -37,13 +44,13 @@ export class SettingKeybind extends LitElement {
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
|
||||
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition whitespace-normal break-words max-w-full"
|
||||
@click=${this.resetToDefault}
|
||||
>
|
||||
${translateText("user_setting.reset")}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition"
|
||||
class="text-xs text-gray-400 hover:text-white border border-gray-500 px-2 py-0.5 rounded transition whitespace-normal break-words max-w-full"
|
||||
@click=${this.unbindKey}
|
||||
>
|
||||
${translateText("user_setting.unbind")}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
|
||||
import buildingExplosion from "../../../resources/sprites/buildingExplosion.png";
|
||||
import conquestSword from "../../../resources/sprites/conquestSword.png";
|
||||
import dust from "../../../resources/sprites/dust.png";
|
||||
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
|
||||
@@ -89,6 +90,15 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originX: 9,
|
||||
originY: 9,
|
||||
},
|
||||
[FxType.BuildingExplosion]: {
|
||||
url: buildingExplosion,
|
||||
frameWidth: 17,
|
||||
frameCount: 10,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 8,
|
||||
originY: 8,
|
||||
},
|
||||
[FxType.SinkingShip]: {
|
||||
url: sinkingShip,
|
||||
frameWidth: 16,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GameStartingModal } from "../GameStartingModal";
|
||||
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
|
||||
import { TransformHandler } from "./TransformHandler";
|
||||
import { UIState } from "./UIState";
|
||||
import { AdTimer } from "./layers/AdTimer";
|
||||
import { AlertFrame } from "./layers/AlertFrame";
|
||||
import { BuildMenu } from "./layers/BuildMenu";
|
||||
import { ChatDisplay } from "./layers/ChatDisplay";
|
||||
@@ -27,7 +28,6 @@ import { PlayerPanel } from "./layers/PlayerPanel";
|
||||
import { RailroadLayer } from "./layers/RailroadLayer";
|
||||
import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SettingsModal } from "./layers/SettingsModal";
|
||||
import { SpawnAd } from "./layers/SpawnAd";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
|
||||
import { StructureLayer } from "./layers/StructureLayer";
|
||||
@@ -209,18 +209,19 @@ export function createRenderer(
|
||||
fpsDisplay.eventBus = eventBus;
|
||||
fpsDisplay.userSettings = userSettings;
|
||||
|
||||
const spawnAd = document.querySelector("spawn-ad") as SpawnAd;
|
||||
if (!(spawnAd instanceof SpawnAd)) {
|
||||
console.error("spawn ad not found");
|
||||
}
|
||||
spawnAd.g = game;
|
||||
|
||||
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
|
||||
if (!(alertFrame instanceof AlertFrame)) {
|
||||
console.error("alert frame not found");
|
||||
}
|
||||
alertFrame.game = game;
|
||||
|
||||
const spawnTimer = document.querySelector("spawn-timer") as SpawnTimer;
|
||||
if (!(spawnTimer instanceof SpawnTimer)) {
|
||||
console.error("spawn timer not found");
|
||||
}
|
||||
spawnTimer.game = game;
|
||||
spawnTimer.transformHandler = transformHandler;
|
||||
|
||||
// When updating these layers please be mindful of the order.
|
||||
// Try to group layers by the return value of shouldTransform.
|
||||
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
|
||||
@@ -246,7 +247,7 @@ export function createRenderer(
|
||||
uiState,
|
||||
playerPanel,
|
||||
),
|
||||
new SpawnTimer(game, transformHandler),
|
||||
spawnTimer,
|
||||
leaderboard,
|
||||
gameLeftSidebar,
|
||||
unitDisplay,
|
||||
@@ -260,7 +261,7 @@ export function createRenderer(
|
||||
playerPanel,
|
||||
headsUpMessage,
|
||||
multiTabModal,
|
||||
spawnAd,
|
||||
new AdTimer(game),
|
||||
alertFrame,
|
||||
fpsDisplay,
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum FxType {
|
||||
MiniSmokeAndFire = "MiniSmokeAndFire",
|
||||
MiniExplosion = "MiniExplosion",
|
||||
UnitExplosion = "UnitExplosion",
|
||||
BuildingExplosion = "BuildingExplosion",
|
||||
SinkingShip = "SinkingShip",
|
||||
Nuke = "Nuke",
|
||||
SAMExplosion = "SAMExplosion",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_SHOW_TICKS = 60 * 10; // 1 minute
|
||||
|
||||
export class AdTimer implements Layer {
|
||||
private isHidden: boolean = false;
|
||||
|
||||
constructor(private g: GameView) {}
|
||||
|
||||
init() {}
|
||||
|
||||
public async tick() {
|
||||
if (this.isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns();
|
||||
if (gameTicks > AD_SHOW_TICKS) {
|
||||
console.log("destroying sticky ads");
|
||||
window.fusetag?.que?.push(() => {
|
||||
window.fusetag?.destroySticky?.();
|
||||
});
|
||||
this.isHidden = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,14 @@ export class FxLayer implements Layer {
|
||||
case UnitType.Train:
|
||||
this.onTrainEvent(unit);
|
||||
break;
|
||||
case UnitType.DefensePost:
|
||||
case UnitType.City:
|
||||
case UnitType.Port:
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.SAMLauncher:
|
||||
case UnitType.Factory:
|
||||
this.onStructureEvent(unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +254,20 @@ export class FxLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
onStructureEvent(unit: UnitView) {
|
||||
if (!unit.isActive()) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const explosion = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.BuildingExplosion,
|
||||
);
|
||||
this.allFx.push(explosion);
|
||||
}
|
||||
}
|
||||
|
||||
onNukeEvent(unit: UnitView, radius: number) {
|
||||
if (!unit.isActive()) {
|
||||
if (!unit.reachedTarget()) {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface Entry {
|
||||
gold: string;
|
||||
troops: string;
|
||||
isMyPlayer: boolean;
|
||||
isOnSameTeam: boolean;
|
||||
player: PlayerView;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,7 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
gold: renderNumber(player.gold()),
|
||||
troops: renderNumber(troops),
|
||||
isMyPlayer: player === myPlayer,
|
||||
isOnSameTeam: player === myPlayer || player.isOnSameTeam(myPlayer!),
|
||||
player: player,
|
||||
};
|
||||
});
|
||||
@@ -143,6 +145,7 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
gold: renderNumber(myPlayer.gold()),
|
||||
troops: renderNumber(myPlayerTroops),
|
||||
isMyPlayer: true,
|
||||
isOnSameTeam: true,
|
||||
player: myPlayer,
|
||||
});
|
||||
}
|
||||
@@ -225,7 +228,7 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
(p) => p.player.id(),
|
||||
(player) => html`
|
||||
<div
|
||||
class="contents hover:bg-slate-600/60 ${player.isMyPlayer
|
||||
class="contents hover:bg-slate-600/60 ${player.isOnSameTeam
|
||||
? "font-bold"
|
||||
: ""} cursor-pointer"
|
||||
@click=${() => this.handleRowClickPlayer(player.player)}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CloseViewEvent, MouseUpEvent } from "../../InputHandler";
|
||||
import {
|
||||
SendAllianceRequestIntentEvent,
|
||||
SendBreakAllianceIntentEvent,
|
||||
SendEmbargoAllIntentEvent,
|
||||
SendEmbargoIntentEvent,
|
||||
SendEmojiIntentEvent,
|
||||
SendTargetPlayerIntentEvent,
|
||||
@@ -193,6 +194,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
|
||||
private closeSend = () => {
|
||||
this.sendTarget = null;
|
||||
this.sendMode = "none";
|
||||
};
|
||||
|
||||
private confirmSend = (
|
||||
@@ -222,6 +224,16 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private onStopTradingAllClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
this.eventBus.emit(new SendEmbargoAllIntentEvent("start"));
|
||||
}
|
||||
|
||||
private onStartTradingAllClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
this.eventBus.emit(new SendEmbargoAllIntentEvent("stop"));
|
||||
}
|
||||
|
||||
private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) {
|
||||
e.stopPropagation();
|
||||
this.emojiTable.showTable((emoji: string) => {
|
||||
@@ -418,10 +430,15 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
}}
|
||||
/>`
|
||||
: ""}
|
||||
<h1 class="text-2xl font-bold tracking-[-0.01em] truncate text-zinc-50">
|
||||
${other.name()}
|
||||
</h1>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2
|
||||
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
|
||||
title=${other.name()}
|
||||
>
|
||||
${other.name()}
|
||||
</h2>
|
||||
</div>
|
||||
${chip
|
||||
? html`<span
|
||||
class=${`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-semibold ${chip.classes}`}
|
||||
@@ -445,28 +462,28 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
return html`
|
||||
<div class="mb-1 flex justify-between gap-2">
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
|
||||
text-base font-semibold text-zinc-200"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-white/[0.04] px-3 py-1.5
|
||||
text-white w-[140px] min-w-[140px] flex-shrink-0"
|
||||
>
|
||||
<span class="mr-0.5">💰</span>
|
||||
<span translate="no" class="inline-block w-[45px] text-right">
|
||||
<span translate="no" class="tabular-nums w-[5ch]font-semibold">
|
||||
${renderNumber(other.gold() || 0)}
|
||||
</span>
|
||||
<span class="opacity-95 whitespace-nowrap"
|
||||
>${translateText("player_panel.gold")}</span
|
||||
<span class="text-zinc-200 whitespace-nowrap">
|
||||
${translateText("player_panel.gold")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
|
||||
text-base font-semibold text-zinc-200"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-white/[0.04] px-3 py-1.5
|
||||
text-white w-[140px] min-w-[140px] flex-shrink-0"
|
||||
>
|
||||
<span class="mr-0.5">🛡️</span>
|
||||
<span translate="no" class="inline-block w-[45px] text-right">
|
||||
<span translate="no" class="tabular-nums w-[5ch] font-semibold">
|
||||
${renderTroops(other.troops() || 0)}
|
||||
</span>
|
||||
<span class="opacity-95 whitespace-nowrap"
|
||||
>${translateText("player_panel.troops")}</span
|
||||
<span class="text-zinc-200 whitespace-nowrap">
|
||||
${translateText("player_panel.troops")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,32 +493,34 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
private renderStats(other: PlayerView, my: PlayerView) {
|
||||
return html`
|
||||
<!-- Betrayals -->
|
||||
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
|
||||
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2">
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold text-zinc-300 leading-snug"
|
||||
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
|
||||
>
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span>${translateText("player_panel.betrayals")}</span>
|
||||
</div>
|
||||
<div class="text-right font-semibold text-zinc-200">
|
||||
<div class="text-right text-[14px] font-semibold text-zinc-200">
|
||||
${other.data.betrayals ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trading / Embargo -->
|
||||
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
|
||||
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2">
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold text-zinc-300 leading-snug"
|
||||
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
|
||||
>
|
||||
<span aria-hidden="true">⚓</span>
|
||||
<span>${translateText("player_panel.trading")}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 font-semibold">
|
||||
<div
|
||||
class="flex items-center justify-end gap-2 text-[14px] font-semibold"
|
||||
>
|
||||
${other.hasEmbargoAgainst(my)
|
||||
? html`<span class="text-[#f59e0b]"
|
||||
? html`<span class="text-amber-400"
|
||||
>${translateText("player_panel.stopped")}</span
|
||||
>`
|
||||
: html`<span class="text-emerald-400"
|
||||
: html`<span class="text-blue-400"
|
||||
>${translateText("player_panel.active")}</span
|
||||
>`}
|
||||
</div>
|
||||
@@ -512,60 +531,57 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
private renderAlliances(other: PlayerView) {
|
||||
const allies = other.allies();
|
||||
|
||||
const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
|
||||
const alliesSorted = [...allies].sort((a, b) =>
|
||||
nameCollator.compare(a.name(), b.name()),
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="text-base select-none">
|
||||
<!-- Header -->
|
||||
<div class="select-none">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="font-semibold text-zinc-300 text-base">
|
||||
<div
|
||||
id="alliances-title"
|
||||
class="text-[15px] font-medium text-zinc-200"
|
||||
>
|
||||
${translateText("player_panel.alliances")}
|
||||
</div>
|
||||
<span
|
||||
aria-label="Alliance count"
|
||||
class="inline-flex items-center justify-center min-w-[20px] h-5 px-[6px] rounded-[10px]
|
||||
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
|
||||
aria-labelledby="alliances-title"
|
||||
class="inline-flex items-center justify-center min-w-[20px] h-5 px-[6px] rounded-[10px]
|
||||
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
|
||||
>
|
||||
${allies.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 rounded-lg border border-zinc-600 bg-zinc-800/80">
|
||||
<div
|
||||
class="max-h-[72px] overflow-y-auto p-2 text-zinc-200 text-[12.5px] leading-relaxed"
|
||||
<div
|
||||
class="rounded-lg bg-zinc-800/70 ring-1 ring-zinc-700/60 w-full min-w-0"
|
||||
>
|
||||
<ul
|
||||
class="max-h-[120px] overflow-y-auto p-2
|
||||
flex flex-wrap gap-1.5
|
||||
scrollbar-thin scrollbar-thumb-zinc-600 hover:scrollbar-thumb-zinc-500 scrollbar-track-zinc-800"
|
||||
role="list"
|
||||
aria-label="Alliance list"
|
||||
aria-labelledby="alliances-title"
|
||||
translate="no"
|
||||
>
|
||||
${allies.length > 0
|
||||
? allies.map((p) => {
|
||||
const color = p.territoryColor().toHex();
|
||||
return html`
|
||||
<div
|
||||
role="listitem"
|
||||
class="grid grid-cols-[16px_1fr] items-center gap-2 w-full h-[30px]
|
||||
px-2 rounded-lg border border-transparent text-left
|
||||
hover:bg-[#141821] hover:border-white/30 transition-colors"
|
||||
${alliesSorted.length === 0
|
||||
? html`<li class="text-zinc-400 text-[14px] px-1">
|
||||
${translateText("common.none")}
|
||||
</li>`
|
||||
: alliesSorted.map(
|
||||
(p) =>
|
||||
html`<li
|
||||
class="max-w-full inline-flex items-center gap-1.5
|
||||
rounded-md border border-white/10 bg-white/[0.05]
|
||||
px-2.5 py-1 text-[14px] text-zinc-100
|
||||
hover:bg-white/[0.08] active:scale-[0.99] transition"
|
||||
title=${p.name()}
|
||||
>
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-full mr-2"
|
||||
style="background-color: ${color}"
|
||||
>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="truncate select-none pointer-events-none font-medium"
|
||||
>
|
||||
${p.name()}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
: html`
|
||||
<div class="py-2 text-zinc-300">
|
||||
${translateText("common.none")}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<span class="truncate">${p.name()}</span>
|
||||
</li>`,
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -580,7 +596,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
</div>
|
||||
<div class="text-right font-semibold">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-base font-bold ${this.getExpiryColorClass(
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-[14px] font-bold ${this.getExpiryColorClass(
|
||||
this.allianceExpirySeconds,
|
||||
)}"
|
||||
>${this.allianceExpiryText}</span
|
||||
@@ -605,7 +621,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
const canEmbargo = this.actions?.interaction?.canEmbargo;
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="grid auto-cols-fr grid-flow-col gap-1">
|
||||
${actionButton({
|
||||
onClick: (e: MouseEvent) => this.handleChat(e, my, other),
|
||||
@@ -657,6 +673,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
<ui-divider></ui-divider>
|
||||
|
||||
<div class="grid auto-cols-fr grid-flow-col gap-1">
|
||||
${other !== my
|
||||
@@ -703,6 +720,37 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
${other === my
|
||||
? html`<div class="grid auto-cols-fr grid-flow-col gap-1">
|
||||
${actionButton({
|
||||
onClick: (e: MouseEvent) => this.onStopTradingAllClick(e),
|
||||
icon: stopTradingIcon,
|
||||
iconAlt: "Stop Trading With All",
|
||||
title: !this.actions?.canEmbargoAll
|
||||
? `${translateText("player_panel.stop_trade_all")} - ${translateText("cooldown")}`
|
||||
: translateText("player_panel.stop_trade_all"),
|
||||
label: !this.actions?.canEmbargoAll
|
||||
? `${translateText("player_panel.stop_trade_all")} ⏳`
|
||||
: translateText("player_panel.stop_trade_all"),
|
||||
type: "yellow",
|
||||
disabled: !this.actions?.canEmbargoAll,
|
||||
})}
|
||||
${actionButton({
|
||||
onClick: (e: MouseEvent) => this.onStartTradingAllClick(e),
|
||||
icon: startTradingIcon,
|
||||
iconAlt: "Start Trading With All",
|
||||
title: !this.actions?.canEmbargoAll
|
||||
? `${translateText("player_panel.start_trade_all")} - ${translateText("cooldown")}`
|
||||
: translateText("player_panel.start_trade_all"),
|
||||
label: !this.actions?.canEmbargoAll
|
||||
? `${translateText("player_panel.start_trade_all")} ⏳`
|
||||
: translateText("player_panel.start_trade_all"),
|
||||
type: "green",
|
||||
disabled: !this.actions?.canEmbargoAll,
|
||||
})}
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -754,80 +802,85 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[1001] flex items-center justify-center overflow-auto
|
||||
bg-black/15 backdrop-blur-sm backdrop-brightness-110 pointer-events-auto"
|
||||
bg-black/15 backdrop-brightness-110 pointer-events-auto"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
@wheel=${(e: MouseEvent) => e.stopPropagation()}
|
||||
@click=${() => this.hide()}
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto max-h-[90vh] overflow-y-auto min-w-[240px] w-auto px-4 py-2"
|
||||
class="pointer-events-auto max-h-[90vh] min-w-[300px] max-w-[400px] px-4 py-2"
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
class=${`relative mt-2 w-full bg-zinc-900/90 backdrop-blur-sm p-5 shadow-2xl rounded-xl text-zinc-200
|
||||
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-zinc-700"}`}
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click=${this.handleClose}
|
||||
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
|
||||
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
|
||||
aria-label=${translateText("common.close") || "Close"}
|
||||
title=${translateText("common.close") || "Close"}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex flex-col gap-2 font-sans antialiased text-[14px] leading-relaxed"
|
||||
class="absolute inset-2 -z-10 rounded-2xl bg-black/25 backdrop-blur-[2px]"
|
||||
></div>
|
||||
<div
|
||||
class=${`relative w-full bg-zinc-900/95 p-6 rounded-2xl text-zinc-100 overflow-visible shadow-2xl shadow-black/50
|
||||
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-white/5"}`}
|
||||
>
|
||||
<!-- Identity (flag, name, type, traitor, relation) -->
|
||||
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click=${this.handleClose}
|
||||
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
|
||||
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
|
||||
aria-label=${translateText("common.close") || "Close"}
|
||||
title=${translateText("common.close") || "Close"}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
${this.sendTarget
|
||||
? html`
|
||||
<send-resource-modal
|
||||
.open=${this.sendMode !== "none"}
|
||||
.mode=${this.sendMode}
|
||||
.total=${this.sendMode === "troops"
|
||||
? myTroopsNum
|
||||
: myGoldNum}
|
||||
.uiState=${this.uiState}
|
||||
.myPlayer=${my}
|
||||
.target=${this.sendTarget}
|
||||
.gameView=${this.g}
|
||||
.eventBus=${this.eventBus}
|
||||
.format=${this.sendMode === "troops"
|
||||
? renderTroops
|
||||
: renderNumber}
|
||||
@confirm=${this.confirmSend}
|
||||
@close=${this.closeSend}
|
||||
></send-resource-modal>
|
||||
`
|
||||
: ""}
|
||||
<div
|
||||
class="flex flex-col gap-2 font-sans antialiased text-[14.5px] leading-relaxed"
|
||||
>
|
||||
<!-- Identity (flag, name, type, traitor, relation) -->
|
||||
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
|
||||
|
||||
<ui-divider></ui-divider>
|
||||
${this.sendTarget
|
||||
? html`
|
||||
<send-resource-modal
|
||||
.open=${this.sendMode !== "none"}
|
||||
.mode=${this.sendMode}
|
||||
.total=${this.sendMode === "troops"
|
||||
? myTroopsNum
|
||||
: myGoldNum}
|
||||
.uiState=${this.uiState}
|
||||
.myPlayer=${my}
|
||||
.target=${this.sendTarget}
|
||||
.gameView=${this.g}
|
||||
.eventBus=${this.eventBus}
|
||||
.format=${this.sendMode === "troops"
|
||||
? renderTroops
|
||||
: renderNumber}
|
||||
@confirm=${this.confirmSend}
|
||||
@close=${this.closeSend}
|
||||
></send-resource-modal>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!-- Resources -->
|
||||
${this.renderResources(other)}
|
||||
<ui-divider></ui-divider>
|
||||
|
||||
<ui-divider></ui-divider>
|
||||
<!-- Resources -->
|
||||
${this.renderResources(other)}
|
||||
|
||||
<!-- Stats: betrayals / trading -->
|
||||
${this.renderStats(other, my)}
|
||||
<ui-divider></ui-divider>
|
||||
|
||||
<ui-divider></ui-divider>
|
||||
<!-- Stats: betrayals / trading -->
|
||||
${this.renderStats(other, my)}
|
||||
|
||||
<!-- Alliances list -->
|
||||
${this.renderAlliances(other)}
|
||||
<ui-divider></ui-divider>
|
||||
|
||||
<!-- Alliance time remaining -->
|
||||
${this.renderAllianceExpiry()}
|
||||
<!-- Alliances list -->
|
||||
${this.renderAlliances(other)}
|
||||
|
||||
<ui-divider></ui-divider>
|
||||
<!-- Alliance time remaining -->
|
||||
${this.renderAllianceExpiry()}
|
||||
|
||||
<!-- Actions -->
|
||||
${this.renderActions(my, other)}
|
||||
<ui-divider class="mt-1"></ui-divider>
|
||||
|
||||
<!-- Actions -->
|
||||
${this.renderActions(my, other)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,10 +149,6 @@ export class RadialMenu implements Layer {
|
||||
.style("position", "absolute")
|
||||
.style("top", "50%")
|
||||
.style("left", "50%")
|
||||
.style(
|
||||
"transition",
|
||||
`top ${this.config.menuTransitionDuration}ms ease, left ${this.config.menuTransitionDuration}ms ease`,
|
||||
)
|
||||
.style("transform", "translate(-50%, -50%)")
|
||||
.style("pointer-events", "all")
|
||||
.on("click", (event) => this.hideRadialMenu());
|
||||
@@ -554,6 +550,20 @@ export class RadialMenu implements Layer {
|
||||
.attr("x", arc.centroid(d)[0] - this.config.iconSize / 2)
|
||||
.attr("y", arc.centroid(d)[1] - this.config.iconSize / 2)
|
||||
.attr("opacity", disabled ? 0.5 : 1);
|
||||
|
||||
if (this.params && d.data.cooldown?.(this.params)) {
|
||||
const cooldown = Math.ceil(d.data.cooldown?.(this.params));
|
||||
content
|
||||
.append("text")
|
||||
.attr("class", `cooldown-text`)
|
||||
.text(cooldown + "s")
|
||||
.attr("fill", "white")
|
||||
.attr("opacity", disabled ? 0.5 : 1)
|
||||
.attr("font-size", "14px")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("x", arc.centroid(d)[0] - this.config.iconSize / 4)
|
||||
.attr("y", arc.centroid(d)[1] + this.config.iconSize / 2 + 7);
|
||||
}
|
||||
}
|
||||
|
||||
this.menuIcons.set(contentId, content as any);
|
||||
@@ -998,6 +1008,17 @@ export class RadialMenu implements Layer {
|
||||
if (!imageElement.empty()) {
|
||||
imageElement.attr("opacity", disabled ? 0.5 : 1);
|
||||
}
|
||||
|
||||
// Update cooldown text if applicable
|
||||
const cooldownElement = icon.select(".cooldown-text");
|
||||
if (this.params && !cooldownElement.empty() && item.cooldown) {
|
||||
const cooldown = Math.ceil(item.cooldown(this.params));
|
||||
if (cooldown <= 0) {
|
||||
cooldownElement.remove();
|
||||
} else {
|
||||
cooldownElement.text(cooldown + "s");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface MenuElement {
|
||||
tooltipItems?: TooltipItem[];
|
||||
tooltipKeys?: TooltipKey[];
|
||||
|
||||
cooldown?: (params: MenuElementParams) => number;
|
||||
disabled: (params: MenuElementParams) => boolean;
|
||||
action?: (params: MenuElementParams) => void; // For leaf items that perform actions
|
||||
subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus
|
||||
@@ -425,6 +426,7 @@ export const attackMenuElement: MenuElement = {
|
||||
export const deleteUnitElement: MenuElement = {
|
||||
id: Slot.Delete,
|
||||
name: "delete",
|
||||
cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(),
|
||||
disabled: (params: MenuElementParams) => {
|
||||
const tileOwner = params.game.owner(params.tile);
|
||||
const isLand = params.game.isLand(params.tile);
|
||||
@@ -441,7 +443,7 @@ export const deleteUnitElement: MenuElement = {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!params.myPlayer.canDeleteUnit()) {
|
||||
if (params.myPlayer.deleteUnitCooldown() > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -450,8 +452,10 @@ export const deleteUnitElement: MenuElement = {
|
||||
.units()
|
||||
.filter(
|
||||
(unit) =>
|
||||
unit.constructionType() === undefined &&
|
||||
unit.markedForDeletion() === false &&
|
||||
params.game.manhattanDist(unit.tile(), params.tile) <=
|
||||
DELETE_SELECTION_RADIUS,
|
||||
DELETE_SELECTION_RADIUS,
|
||||
);
|
||||
|
||||
return myUnits.length === 0;
|
||||
|
||||
@@ -244,19 +244,22 @@ export class SendResourceModal extends LitElement {
|
||||
private renderHeader() {
|
||||
const name = this.target?.name?.() ?? "";
|
||||
return html`
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="mb-3 flex items-center justify-between relative">
|
||||
<h2
|
||||
id="send-title"
|
||||
class="text-lg font-semibold tracking-tight text-zinc-100"
|
||||
>
|
||||
${this.heading ?? this.i18n.title(name)}
|
||||
</h2>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="rounded-md px-2 text-2xl leading-none text-zinc-300 hover:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
type="button"
|
||||
@click=${() => this.closeModal()}
|
||||
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors focus-visible:ring-2 focus-visible:ring-white/30 focus:outline-none"
|
||||
aria-label=${this.i18n.closeLabel()}
|
||||
title=${this.i18n.closeLabel()}
|
||||
>
|
||||
×
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -264,7 +267,6 @@ export class SendResourceModal extends LitElement {
|
||||
|
||||
private renderAvailable() {
|
||||
const total = this.getTotalNumber();
|
||||
const cap = this.getCapacityLeft();
|
||||
|
||||
return html`
|
||||
<div class="mb-4 pb-3 border-b border-zinc-800">
|
||||
@@ -277,21 +279,6 @@ export class SendResourceModal extends LitElement {
|
||||
<span class="opacity-90">${this.i18n.availableChip()}</span>
|
||||
<span class="font-mono tabular-nums">${this.format(total)}</span>
|
||||
</span>
|
||||
|
||||
${cap !== null
|
||||
? html`
|
||||
<!-- Cap -->
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 ring-1 ring-amber-400/40 text-amber-200"
|
||||
title=${this.i18n.capTooltip()}
|
||||
>
|
||||
<span class="opacity-90">${this.i18n.cap()}</span>
|
||||
<span class="font-mono tabular-nums"
|
||||
>${this.format(cap)}</span
|
||||
>
|
||||
</span>
|
||||
`
|
||||
: html``}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -554,9 +541,11 @@ export class SendResourceModal extends LitElement {
|
||||
const allowed = this.limitAmount(this.sendAmount);
|
||||
|
||||
return html`
|
||||
<div class="fixed inset-0 z-[1100] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 z-[1100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm rounded-2xl"
|
||||
class="absolute inset-0 bg-black/60 rounded-2xl"
|
||||
@click=${() => this.closeModal()}
|
||||
></div>
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { getGamesPlayed } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_TYPE = "bottom_rail";
|
||||
const AD_CONTAINER_ID = "bottom-rail-ad-container";
|
||||
|
||||
@customElement("spawn-ad")
|
||||
export class SpawnAd extends LitElement implements Layer {
|
||||
public g: GameView;
|
||||
|
||||
@state()
|
||||
private isVisible: boolean = false;
|
||||
|
||||
@state()
|
||||
private adLoaded: boolean = false;
|
||||
|
||||
private gamesPlayed: number = 0;
|
||||
|
||||
// Override createRenderRoot to disable shadow DOM
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
static styles = css``;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.gamesPlayed = getGamesPlayed();
|
||||
}
|
||||
|
||||
public show(): void {
|
||||
this.isVisible = true;
|
||||
this.loadAd();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
// Destroy the ad when hiding
|
||||
this.destroyAd();
|
||||
this.isVisible = false;
|
||||
this.adLoaded = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public async tick() {
|
||||
if (
|
||||
!this.isVisible &&
|
||||
this.g.inSpawnPhase() &&
|
||||
this.g.ticks() > 10 &&
|
||||
this.gamesPlayed > 5
|
||||
) {
|
||||
console.log("not showing spawn ad");
|
||||
// this.show();
|
||||
}
|
||||
if (this.isVisible && !this.g.inSpawnPhase()) {
|
||||
console.log("hiding bottom left ad");
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
private loadAd(): void {
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available");
|
||||
return;
|
||||
}
|
||||
if (this.adLoaded) {
|
||||
console.log("Ad already loaded, skipping");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
window.ramp.spaAddAds([
|
||||
{
|
||||
type: AD_TYPE,
|
||||
selectorId: AD_CONTAINER_ID,
|
||||
},
|
||||
]);
|
||||
this.adLoaded = true;
|
||||
console.log("Playwire ad loaded:", AD_TYPE);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load Playwire ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private destroyAd(): void {
|
||||
if (!window.ramp || !this.adLoaded) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
window.ramp.destroyUnits("all");
|
||||
console.log("Playwire spawn ad destroyed");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to destroy Playwire ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// Clean up ad when component is removed
|
||||
this.destroyAd();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed bottom-0 left-0 w-full min-h-[100px] bg-gray-900 border border-gray-600 flex items-center justify-center z-50"
|
||||
>
|
||||
<div
|
||||
id="${AD_CONTAINER_ID}"
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
${!this.adLoaded
|
||||
? html`<span class="text-white text-sm"
|
||||
>${translateText("spawn_ad.loading")}</span
|
||||
>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,34 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { GameMode, Team } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class SpawnTimer implements Layer {
|
||||
@customElement("spawn-timer")
|
||||
export class SpawnTimer extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
public transformHandler: TransformHandler;
|
||||
|
||||
private ratios = [0];
|
||||
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private transformHandler: TransformHandler,
|
||||
) {}
|
||||
private isVisible = false;
|
||||
|
||||
init() {}
|
||||
createRenderRoot() {
|
||||
this.style.position = "fixed";
|
||||
this.style.top = "0";
|
||||
this.style.left = "0";
|
||||
this.style.width = "100%";
|
||||
this.style.height = "7px";
|
||||
this.style.zIndex = "1000";
|
||||
this.style.pointerEvents = "none";
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.isVisible = true;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.game.inSpawnPhase()) {
|
||||
@@ -21,6 +37,7 @@ export class SpawnTimer implements Layer {
|
||||
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
|
||||
];
|
||||
this.colors = ["rgba(0, 128, 255, 0.7)"];
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,6 +45,7 @@ export class SpawnTimer implements Layer {
|
||||
this.colors = [];
|
||||
|
||||
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,44 +59,52 @@ export class SpawnTimer implements Layer {
|
||||
|
||||
const theme = this.game.config().theme();
|
||||
const total = sumIterator(teamTiles.values());
|
||||
if (total === 0) return;
|
||||
if (total === 0) {
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [team, count] of teamTiles) {
|
||||
const ratio = count / total;
|
||||
this.ratios.push(ratio);
|
||||
this.colors.push(theme.teamColor(team).toRgbString());
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
if (this.ratios.length === 0 || this.colors.length === 0) return;
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const barHeight = 10;
|
||||
const barWidth = this.transformHandler.width();
|
||||
if (this.ratios.length === 0 || this.colors.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.game.inSpawnPhase() &&
|
||||
this.game.config().gameConfig().gameMode !== GameMode.Team
|
||||
) {
|
||||
return;
|
||||
return html``;
|
||||
}
|
||||
|
||||
let x = 0;
|
||||
let filledRatio = 0;
|
||||
for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
|
||||
const ratio = this.ratios[i] ?? 1 - filledRatio;
|
||||
const segmentWidth = barWidth * ratio;
|
||||
|
||||
context.fillStyle = this.colors[i];
|
||||
context.fillRect(x, 0, segmentWidth, barHeight);
|
||||
|
||||
x += segmentWidth;
|
||||
filledRatio += ratio;
|
||||
}
|
||||
return html`
|
||||
<div class="w-full h-full flex z-[999]">
|
||||
${this.ratios.map((ratio, i) => {
|
||||
const color = this.colors[i] || "rgba(0, 0, 0, 0.5)";
|
||||
return html`
|
||||
<div
|
||||
class="h-full transition-all duration-100 ease-in-out"
|
||||
style="width: ${ratio * 100}%; background-color: ${color};"
|
||||
></div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,10 +99,7 @@ export class SpriteFactory {
|
||||
|
||||
private invalidateTextureCache(unitType: UnitType) {
|
||||
for (const key of Array.from(this.textureCache.keys())) {
|
||||
if (
|
||||
key.endsWith(`-${unitType}-icon`) ||
|
||||
key === `construction-${unitType}-icon`
|
||||
) {
|
||||
if (key.includes(`-${unitType}`)) {
|
||||
this.textureCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -115,7 +112,13 @@ export class SpriteFactory {
|
||||
structureType: UnitType,
|
||||
): PIXI.Container {
|
||||
const parentContainer = new PIXI.Container();
|
||||
const texture = this.createTexture(structureType, player, false, true);
|
||||
const texture = this.createTexture(
|
||||
structureType,
|
||||
player,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
const sprite = new PIXI.Sprite(texture);
|
||||
sprite.anchor.set(0.5);
|
||||
sprite.alpha = 0.5;
|
||||
@@ -139,6 +142,7 @@ export class SpriteFactory {
|
||||
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
|
||||
|
||||
const isMarkedForDeletion = unit.markedForDeletion() !== false;
|
||||
const isConstruction = unit.type() === UnitType.Construction;
|
||||
const constructionType = unit.constructionType();
|
||||
const structureType = isConstruction ? constructionType! : unit.type();
|
||||
@@ -156,6 +160,7 @@ export class SpriteFactory {
|
||||
structureType,
|
||||
unit.owner(),
|
||||
isConstruction,
|
||||
isMarkedForDeletion,
|
||||
type === "icon",
|
||||
);
|
||||
const sprite = new PIXI.Sprite(texture);
|
||||
@@ -202,19 +207,30 @@ export class SpriteFactory {
|
||||
type: UnitType,
|
||||
owner: PlayerView,
|
||||
isConstruction: boolean,
|
||||
isMarkedForDeletion: boolean,
|
||||
renderIcon: boolean,
|
||||
): PIXI.Texture {
|
||||
const cacheKey = isConstruction
|
||||
? `construction-${type}` + (renderIcon ? "-icon" : "")
|
||||
: `${this.theme.territoryColor(owner).toRgbString()}-${type}` +
|
||||
(renderIcon ? "-icon" : "");
|
||||
const cacheKeyBase = isConstruction
|
||||
? `construction-${type}`
|
||||
: `${this.theme.territoryColor(owner).toRgbString()}-${type}`;
|
||||
const cacheKey =
|
||||
cacheKeyBase +
|
||||
(renderIcon ? "-icon" : "") +
|
||||
(isMarkedForDeletion ? "-deleted" : "");
|
||||
|
||||
if (this.textureCache.has(cacheKey)) {
|
||||
return this.textureCache.get(cacheKey)!;
|
||||
}
|
||||
const shape = STRUCTURE_SHAPES[type];
|
||||
const texture = shape
|
||||
? this.createIcon(owner, type, isConstruction, shape, renderIcon)
|
||||
? this.createIcon(
|
||||
owner,
|
||||
type,
|
||||
isConstruction,
|
||||
isMarkedForDeletion,
|
||||
shape,
|
||||
renderIcon,
|
||||
)
|
||||
: PIXI.Texture.EMPTY;
|
||||
this.textureCache.set(cacheKey, texture);
|
||||
return texture;
|
||||
@@ -224,6 +240,7 @@ export class SpriteFactory {
|
||||
owner: PlayerView,
|
||||
structureType: UnitType,
|
||||
isConstruction: boolean,
|
||||
isMarkedForDeletion: boolean,
|
||||
shape: string,
|
||||
renderIcon: boolean,
|
||||
): PIXI.Texture {
|
||||
@@ -370,11 +387,8 @@ export class SpriteFactory {
|
||||
}
|
||||
|
||||
const structureInfo = this.structuresInfos.get(structureType);
|
||||
if (!structureInfo?.image) {
|
||||
return PIXI.Texture.from(structureCanvas);
|
||||
}
|
||||
|
||||
if (renderIcon) {
|
||||
if (structureInfo?.image && renderIcon) {
|
||||
const SHAPE_OFFSETS = {
|
||||
triangle: [6, 11],
|
||||
square: [5, 5],
|
||||
@@ -390,6 +404,22 @@ export class SpriteFactory {
|
||||
offsetY,
|
||||
);
|
||||
}
|
||||
|
||||
if (isMarkedForDeletion) {
|
||||
context.save();
|
||||
context.strokeStyle = "rgba(255, 64, 64, 0.95)";
|
||||
context.lineWidth = Math.max(2, Math.round(iconSize * 0.12));
|
||||
context.lineCap = "round";
|
||||
const padding = Math.max(2, iconSize * 0.12);
|
||||
context.beginPath();
|
||||
context.moveTo(padding, padding);
|
||||
context.lineTo(iconSize - padding, iconSize - padding);
|
||||
context.moveTo(iconSize - padding, padding);
|
||||
context.lineTo(padding, iconSize - padding);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
return PIXI.Texture.from(structureCanvas);
|
||||
}
|
||||
|
||||
|
||||
@@ -418,6 +418,7 @@ export class StructureIconsLayer implements Layer {
|
||||
const render = this.findRenderByUnit(unitView);
|
||||
if (render) {
|
||||
this.checkForConstructionState(render, unitView);
|
||||
this.checkForDeletionState(render, unitView);
|
||||
this.checkForOwnershipChange(render, unitView);
|
||||
this.checkForLevelChange(render, unitView);
|
||||
}
|
||||
@@ -467,6 +468,16 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) {
|
||||
if (unit.markedForDeletion() !== false) {
|
||||
render.iconContainer?.destroy();
|
||||
render.dotContainer?.destroy();
|
||||
render.iconContainer = this.createIconSprite(unit);
|
||||
render.dotContainer = this.createDotSprite(unit);
|
||||
this.modifyVisibility(render);
|
||||
}
|
||||
}
|
||||
|
||||
private checkForConstructionState(
|
||||
render: StructureRenderInfo,
|
||||
unit: UnitView,
|
||||
|
||||
@@ -221,5 +221,6 @@ export class TeamStats extends LitElement implements Layer {
|
||||
function formatPercentage(value: number): string {
|
||||
const perc = value * 100;
|
||||
if (Number.isNaN(perc)) return "0%";
|
||||
if (perc === 100) return "100%";
|
||||
return perc.toPrecision(2) + "%";
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.game.inSpawnPhase()) {
|
||||
this.spawnHighlight();
|
||||
}
|
||||
|
||||
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
|
||||
@@ -145,12 +149,6 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
this.lastFocusedPlayer = focusedPlayer;
|
||||
}
|
||||
|
||||
if (!this.game.inSpawnPhase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.spawnHighlight();
|
||||
}
|
||||
|
||||
private spawnHighlight() {
|
||||
@@ -186,13 +184,16 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
let color = this.theme.spawnHighlightColor();
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (
|
||||
myPlayer !== null &&
|
||||
myPlayer !== human &&
|
||||
myPlayer.isFriendly(human)
|
||||
) {
|
||||
color = this.theme.selfColor();
|
||||
if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) {
|
||||
// In FFA games (when team === null), use default yellow spawn highlight color
|
||||
color = this.theme.spawnHighlightColor();
|
||||
} else if (myPlayer !== null && myPlayer !== human) {
|
||||
// In Team games, the spawn highlight color becomes that player's team color
|
||||
// Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively
|
||||
const team = human.team();
|
||||
if (team !== null) color = this.theme.teamColor(team);
|
||||
}
|
||||
|
||||
for (const tile of this.game.bfs(
|
||||
centerTile,
|
||||
euclDistFN(centerTile, 9, true),
|
||||
@@ -215,20 +216,20 @@ export class TerritoryLayer implements Layer {
|
||||
return;
|
||||
}
|
||||
// Breathing border animation
|
||||
this.borderAnimTime += 3;
|
||||
const minPadding = 6;
|
||||
const maxPadding = 12;
|
||||
this.borderAnimTime += 0.5;
|
||||
const minRad = 8;
|
||||
const maxRad = 24;
|
||||
// Range: [minPadding..maxPadding]
|
||||
const breathingPadding =
|
||||
minPadding +
|
||||
(maxPadding - minPadding) *
|
||||
(0.5 + 0.5 * Math.sin(this.borderAnimTime * 0.3));
|
||||
const radius =
|
||||
minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
|
||||
|
||||
this.drawBreathingRing(
|
||||
center.x,
|
||||
center.y,
|
||||
breathingPadding,
|
||||
this.theme.spawnHighlightColor(),
|
||||
minRad,
|
||||
maxRad,
|
||||
radius,
|
||||
this.theme.spawnHighlightSelfColor(), // Always draw breathing ring with self spawn highlight color
|
||||
);
|
||||
}
|
||||
|
||||
@@ -558,18 +559,52 @@ export class TerritoryLayer implements Layer {
|
||||
const y = this.game.y(tile);
|
||||
this.highlightContext.clearRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
private drawBreathingRing(
|
||||
cx: number,
|
||||
cy: number,
|
||||
minRad: number,
|
||||
maxRad: number,
|
||||
radius: number,
|
||||
color: Colord,
|
||||
) {
|
||||
const ctx = this.highlightContext;
|
||||
if (!ctx) return;
|
||||
|
||||
// Draw a semi-transparent ring around the starting location
|
||||
ctx.beginPath();
|
||||
// Transparency matches the highlight color provided
|
||||
const transparent = color.toHex() + "00";
|
||||
const c = color.toHex();
|
||||
const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
|
||||
|
||||
// Pixels with radius < minRad are transparent
|
||||
radGrad.addColorStop(0, transparent);
|
||||
// The ring then starts with solid highlight color
|
||||
radGrad.addColorStop(0.01, c);
|
||||
radGrad.addColorStop(0.1, c);
|
||||
// The outer edge of the ring is transparent
|
||||
radGrad.addColorStop(1, transparent);
|
||||
|
||||
// Draw an arc at the max radius and fill with the created radial gradient
|
||||
ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
|
||||
ctx.fillStyle = radGrad;
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw a solid ring around the starting location with outer radius = the breathing radius
|
||||
ctx.beginPath();
|
||||
const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
|
||||
// Pixels with radius < minRad are transparent
|
||||
radGrad2.addColorStop(0, transparent);
|
||||
// The ring then starts with solid highlight color
|
||||
radGrad2.addColorStop(0.01, c);
|
||||
// The ring is solid throughout
|
||||
radGrad2.addColorStop(1, c);
|
||||
|
||||
// Draw an arc at the current breathing radius and fill with the created "gradient"
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color.toRgbString();
|
||||
ctx.lineWidth = 4;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = radGrad2;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,11 +117,18 @@ export class UILayer implements Layer {
|
||||
this.drawHealthBar(unit);
|
||||
break;
|
||||
}
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.DefensePost:
|
||||
case UnitType.Port:
|
||||
case UnitType.MissileSilo:
|
||||
this.createLoadingBar(unit);
|
||||
break;
|
||||
case UnitType.SAMLauncher:
|
||||
this.createLoadingBar(unit);
|
||||
if (
|
||||
unit.markedForDeletion() !== false ||
|
||||
unit.missileReadinesss() < 1
|
||||
) {
|
||||
this.createLoadingBar(unit);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
@@ -329,12 +336,28 @@ export class UILayer implements Layer {
|
||||
}
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.SAMLauncher:
|
||||
return unit.missileReadinesss();
|
||||
return !unit.markedForDeletion()
|
||||
? unit.missileReadinesss()
|
||||
: this.deletionProgress(this.game, unit);
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.Port:
|
||||
case UnitType.DefensePost:
|
||||
return this.deletionProgress(this.game, unit);
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private deletionProgress(game: GameView, unit: UnitView): number {
|
||||
const deleteAt = unit.markedForDeletion();
|
||||
if (deleteAt === false) return 1;
|
||||
return Math.max(
|
||||
0,
|
||||
(deleteAt - game.ticks()) / game.config().deletionMarkDuration(),
|
||||
);
|
||||
}
|
||||
|
||||
public createLoadingBar(unit: UnitView) {
|
||||
if (!this.context) {
|
||||
return;
|
||||
|
||||
@@ -132,12 +132,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
for (const pattern of Object.values(patterns?.patterns ?? {})) {
|
||||
for (const colorPalette of pattern.colorPalettes ?? []) {
|
||||
if (
|
||||
patternRelationship(
|
||||
pattern,
|
||||
colorPalette,
|
||||
me !== false ? me : null,
|
||||
null,
|
||||
) === "purchasable"
|
||||
patternRelationship(pattern, colorPalette, me, null) === "purchasable"
|
||||
) {
|
||||
const palette = patterns?.colorPalettes?.[colorPalette.name];
|
||||
if (palette) {
|
||||
|
||||
+11
-1
@@ -97,6 +97,13 @@
|
||||
src="https://cdn.fuseplatform.net/publift/tags/2/4121/fuse.js"
|
||||
></script>
|
||||
|
||||
<script>
|
||||
window.googletag = window.googletag || { cmd: [] };
|
||||
googletag.cmd.push(function () {
|
||||
googletag.pubads().set("page_url", "http://openfront.io ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Analytics -->
|
||||
<script
|
||||
async
|
||||
@@ -221,6 +228,9 @@
|
||||
<div>
|
||||
<public-lobby class="block"></public-lobby>
|
||||
</div>
|
||||
<div>
|
||||
<matchmaking-button class="w-[20%] md:w-[15%]"></matchmaking-button>
|
||||
</div>
|
||||
<div class="container__row container__row--equal">
|
||||
<o-button
|
||||
id="host-lobby-button"
|
||||
@@ -395,6 +405,7 @@
|
||||
</div>
|
||||
<settings-modal></settings-modal>
|
||||
<player-panel></player-panel>
|
||||
<spawn-timer></spawn-timer>
|
||||
<help-modal></help-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<alert-frame></alert-frame>
|
||||
@@ -403,7 +414,6 @@
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<news-modal></news-modal>
|
||||
<game-left-sidebar></game-left-sidebar>
|
||||
<spawn-ad></spawn-ad>
|
||||
<flag-input-modal></flag-input-modal>
|
||||
<fps-display></fps-display>
|
||||
<div
|
||||
|
||||
@@ -191,6 +191,7 @@ export class GameRunner {
|
||||
canAttack: tile !== null && player.canAttack(tile),
|
||||
buildableUnits: player.buildableUnits(tile, transportShipFilter),
|
||||
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
|
||||
canEmbargoAll: player.canEmbargoAll(),
|
||||
} as PlayerActions;
|
||||
|
||||
if (tile !== null && this.game.hasOwner(tile)) {
|
||||
|
||||
@@ -43,6 +43,7 @@ export type Intent =
|
||||
| QuickChatIntent
|
||||
| MoveWarshipIntent
|
||||
| MarkDisconnectedIntent
|
||||
| EmbargoAllIntent
|
||||
| UpgradeStructureIntent
|
||||
| DeleteUnitIntent
|
||||
| KickPlayerIntent;
|
||||
@@ -51,6 +52,7 @@ export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
||||
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
||||
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
|
||||
export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>;
|
||||
export type EmbargoAllIntent = z.infer<typeof EmbargoAllIntentSchema>;
|
||||
export type CancelBoatIntent = z.infer<typeof CancelBoatIntentSchema>;
|
||||
export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>;
|
||||
export type AllianceRequestReplyIntent = z.infer<
|
||||
@@ -276,6 +278,11 @@ export const EmbargoIntentSchema = BaseIntentSchema.extend({
|
||||
action: z.union([z.literal("start"), z.literal("stop")]),
|
||||
});
|
||||
|
||||
export const EmbargoAllIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("embargo_all"),
|
||||
action: z.union([z.literal("start"), z.literal("stop")]),
|
||||
});
|
||||
|
||||
export const DonateGoldIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("donate_gold"),
|
||||
recipient: ID,
|
||||
@@ -355,6 +362,7 @@ const IntentSchema = z.discriminatedUnion("type", [
|
||||
BuildUnitIntentSchema,
|
||||
UpgradeStructureIntentSchema,
|
||||
EmbargoIntentSchema,
|
||||
EmbargoAllIntentSchema,
|
||||
MoveWarshipIntentSchema,
|
||||
QuickChatIntentSchema,
|
||||
AllianceExtensionIntentSchema,
|
||||
|
||||
@@ -262,104 +262,309 @@ export const botColors: Colord[] = [
|
||||
|
||||
// Fallback colors for when the color palette is exhausted. Currently 100 colors.
|
||||
export const fallbackColors: Colord[] = [
|
||||
colord({ r: 0, g: 5, b: 0 }), // Black Mint
|
||||
colord({ r: 0, g: 15, b: 0 }), // Deep Forest
|
||||
colord({ r: 0, g: 25, b: 0 }), // Jungle
|
||||
colord({ r: 0, g: 35, b: 0 }), // Dark Emerald
|
||||
colord({ r: 0, g: 45, b: 0 }), // Green Moss
|
||||
colord({ r: 0, g: 55, b: 0 }), // Moss Shadow
|
||||
colord({ r: 0, g: 65, b: 0 }), // Dark Meadow
|
||||
colord({ r: 0, g: 75, b: 0 }), // Forest Fern
|
||||
colord({ r: 0, g: 85, b: 0 }), // Pine Leaf
|
||||
colord({ r: 0, g: 95, b: 0 }), // Shadow Grass
|
||||
colord({ r: 0, g: 105, b: 0 }), // Classic Green
|
||||
colord({ r: 0, g: 115, b: 0 }), // Deep Lime
|
||||
colord({ r: 0, g: 125, b: 0 }), // Dense Leaf
|
||||
colord({ r: 0, g: 135, b: 0 }), // Basil Green
|
||||
colord({ r: 0, g: 145, b: 0 }), // Organic Green
|
||||
colord({ r: 0, g: 155, b: 0 }), // Bitter Herb
|
||||
colord({ r: 0, g: 165, b: 0 }), // Raw Spinach
|
||||
colord({ r: 0, g: 175, b: 0 }), // Woodland
|
||||
colord({ r: 0, g: 185, b: 0 }), // Spring Weed
|
||||
colord({ r: 0, g: 195, b: 5 }), // Apple Stem
|
||||
colord({ r: 0, g: 205, b: 10 }), // Crisp Lettuce
|
||||
colord({ r: 0, g: 215, b: 15 }), // Vibrant Green
|
||||
colord({ r: 0, g: 225, b: 20 }), // Bright Herb
|
||||
colord({ r: 0, g: 235, b: 25 }), // Green Splash
|
||||
colord({ r: 0, g: 245, b: 30 }), // Mint Leaf
|
||||
colord({ r: 0, g: 255, b: 35 }), // Fresh Mint
|
||||
colord({ r: 10, g: 255, b: 45 }), // Neon Grass
|
||||
colord({ r: 20, g: 255, b: 55 }), // Lemon Balm
|
||||
colord({ r: 30, g: 255, b: 65 }), // Juicy Green
|
||||
colord({ r: 40, g: 255, b: 75 }), // Pear Tint
|
||||
colord({ r: 50, g: 255, b: 85 }), // Avocado Pastel
|
||||
colord({ r: 60, g: 255, b: 95 }), // Lime Glow
|
||||
colord({ r: 70, g: 255, b: 105 }), // Light Leaf
|
||||
colord({ r: 80, g: 255, b: 115 }), // Soft Fern
|
||||
colord({ r: 90, g: 255, b: 125 }), // Pastel Green
|
||||
colord({ r: 100, g: 255, b: 135 }), // Green Melon
|
||||
colord({ r: 110, g: 255, b: 145 }), // Herbal Mist
|
||||
colord({ r: 120, g: 255, b: 155 }), // Kiwi Foam
|
||||
colord({ r: 130, g: 255, b: 165 }), // Aloe Fresh
|
||||
colord({ r: 140, g: 255, b: 175 }), // Light Mint
|
||||
colord({ r: 35, g: 0, b: 0 }),
|
||||
colord({ r: 45, g: 0, b: 0 }),
|
||||
colord({ r: 55, g: 0, b: 0 }),
|
||||
colord({ r: 65, g: 0, b: 0 }),
|
||||
colord({ r: 75, g: 0, b: 0 }),
|
||||
colord({ r: 85, g: 0, b: 0 }),
|
||||
colord({ r: 95, g: 0, b: 0 }),
|
||||
colord({ r: 105, g: 0, b: 0 }),
|
||||
colord({ r: 115, g: 0, b: 0 }),
|
||||
colord({ r: 125, g: 0, b: 0 }),
|
||||
colord({ r: 135, g: 0, b: 0 }),
|
||||
colord({ r: 145, g: 0, b: 0 }),
|
||||
colord({ r: 155, g: 0, b: 0 }),
|
||||
colord({ r: 165, g: 0, b: 0 }),
|
||||
colord({ r: 175, g: 0, b: 0 }),
|
||||
colord({ r: 185, g: 0, b: 0 }),
|
||||
colord({ r: 195, g: 0, b: 5 }),
|
||||
colord({ r: 205, g: 0, b: 10 }),
|
||||
colord({ r: 215, g: 0, b: 15 }),
|
||||
colord({ r: 225, g: 0, b: 20 }),
|
||||
colord({ r: 235, g: 0, b: 25 }),
|
||||
colord({ r: 245, g: 0, b: 30 }),
|
||||
colord({ r: 255, g: 0, b: 35 }),
|
||||
colord({ r: 255, g: 10, b: 45 }),
|
||||
colord({ r: 255, g: 20, b: 55 }),
|
||||
colord({ r: 255, g: 30, b: 65 }),
|
||||
colord({ r: 255, g: 40, b: 75 }),
|
||||
colord({ r: 255, g: 50, b: 85 }),
|
||||
colord({ r: 255, g: 60, b: 95 }),
|
||||
colord({ r: 255, g: 70, b: 105 }),
|
||||
colord({ r: 255, g: 80, b: 115 }),
|
||||
colord({ r: 255, g: 90, b: 125 }),
|
||||
colord({ r: 255, g: 100, b: 135 }),
|
||||
colord({ r: 255, g: 110, b: 145 }),
|
||||
colord({ r: 255, g: 120, b: 155 }),
|
||||
colord({ r: 255, g: 130, b: 165 }),
|
||||
colord({ r: 255, g: 140, b: 175 }),
|
||||
colord({ r: 255, g: 150, b: 185 }),
|
||||
colord({ r: 255, g: 160, b: 195 }),
|
||||
colord({ r: 255, g: 170, b: 205 }),
|
||||
colord({ r: 255, g: 180, b: 215 }),
|
||||
colord({ r: 255, g: 190, b: 225 }),
|
||||
colord({ r: 255, g: 200, b: 235 }),
|
||||
colord({ r: 0, g: 45, b: 0 }),
|
||||
colord({ r: 0, g: 55, b: 0 }),
|
||||
colord({ r: 0, g: 65, b: 0 }),
|
||||
colord({ r: 0, g: 75, b: 0 }),
|
||||
colord({ r: 0, g: 85, b: 0 }),
|
||||
colord({ r: 0, g: 95, b: 0 }),
|
||||
colord({ r: 0, g: 105, b: 0 }),
|
||||
colord({ r: 0, g: 115, b: 0 }),
|
||||
colord({ r: 0, g: 125, b: 0 }),
|
||||
colord({ r: 0, g: 135, b: 0 }),
|
||||
colord({ r: 0, g: 145, b: 0 }),
|
||||
colord({ r: 0, g: 155, b: 0 }),
|
||||
colord({ r: 0, g: 165, b: 0 }),
|
||||
colord({ r: 0, g: 175, b: 0 }),
|
||||
colord({ r: 0, g: 185, b: 0 }),
|
||||
colord({ r: 0, g: 195, b: 5 }),
|
||||
colord({ r: 0, g: 205, b: 10 }),
|
||||
colord({ r: 0, g: 215, b: 15 }),
|
||||
colord({ r: 0, g: 225, b: 20 }),
|
||||
colord({ r: 0, g: 235, b: 25 }),
|
||||
colord({ r: 0, g: 245, b: 30 }),
|
||||
colord({ r: 0, g: 255, b: 35 }),
|
||||
colord({ r: 10, g: 255, b: 45 }),
|
||||
colord({ r: 20, g: 255, b: 55 }),
|
||||
colord({ r: 30, g: 255, b: 65 }),
|
||||
colord({ r: 40, g: 255, b: 75 }),
|
||||
colord({ r: 50, g: 255, b: 85 }),
|
||||
colord({ r: 60, g: 255, b: 95 }),
|
||||
colord({ r: 70, g: 255, b: 105 }),
|
||||
colord({ r: 80, g: 255, b: 115 }),
|
||||
colord({ r: 90, g: 255, b: 125 }),
|
||||
colord({ r: 100, g: 255, b: 135 }),
|
||||
colord({ r: 110, g: 255, b: 145 }),
|
||||
colord({ r: 120, g: 255, b: 155 }),
|
||||
colord({ r: 130, g: 255, b: 165 }),
|
||||
colord({ r: 140, g: 255, b: 175 }),
|
||||
colord({ r: 150, g: 255, b: 185 }),
|
||||
colord({ r: 160, g: 255, b: 195 }),
|
||||
colord({ r: 170, g: 255, b: 205 }),
|
||||
colord({ r: 180, g: 255, b: 215 }),
|
||||
colord({ r: 190, g: 255, b: 225 }),
|
||||
colord({ r: 200, g: 255, b: 235 }),
|
||||
colord({ r: 0, g: 0, b: 35 }),
|
||||
colord({ r: 0, g: 0, b: 45 }),
|
||||
colord({ r: 0, g: 0, b: 55 }),
|
||||
colord({ r: 0, g: 0, b: 65 }),
|
||||
colord({ r: 0, g: 0, b: 75 }),
|
||||
colord({ r: 0, g: 0, b: 85 }),
|
||||
colord({ r: 0, g: 0, b: 95 }),
|
||||
colord({ r: 0, g: 0, b: 105 }),
|
||||
colord({ r: 0, g: 0, b: 115 }),
|
||||
colord({ r: 0, g: 0, b: 125 }),
|
||||
colord({ r: 0, g: 0, b: 135 }),
|
||||
colord({ r: 0, g: 0, b: 145 }),
|
||||
colord({ r: 0, g: 0, b: 155 }),
|
||||
colord({ r: 0, g: 0, b: 165 }),
|
||||
colord({ r: 0, g: 0, b: 175 }),
|
||||
colord({ r: 0, g: 0, b: 185 }),
|
||||
colord({ r: 5, g: 0, b: 195 }),
|
||||
colord({ r: 10, g: 0, b: 205 }),
|
||||
colord({ r: 15, g: 0, b: 215 }),
|
||||
colord({ r: 20, g: 0, b: 225 }),
|
||||
colord({ r: 25, g: 0, b: 235 }),
|
||||
colord({ r: 30, g: 0, b: 245 }),
|
||||
colord({ r: 35, g: 0, b: 255 }),
|
||||
colord({ r: 45, g: 10, b: 255 }),
|
||||
colord({ r: 55, g: 20, b: 255 }),
|
||||
colord({ r: 65, g: 30, b: 255 }),
|
||||
colord({ r: 75, g: 40, b: 255 }),
|
||||
colord({ r: 85, g: 50, b: 255 }),
|
||||
colord({ r: 95, g: 60, b: 255 }),
|
||||
colord({ r: 105, g: 70, b: 255 }),
|
||||
colord({ r: 115, g: 80, b: 255 }),
|
||||
colord({ r: 125, g: 90, b: 255 }),
|
||||
colord({ r: 135, g: 100, b: 255 }),
|
||||
colord({ r: 145, g: 110, b: 255 }),
|
||||
colord({ r: 155, g: 120, b: 255 }),
|
||||
colord({ r: 165, g: 130, b: 255 }),
|
||||
colord({ r: 175, g: 140, b: 255 }),
|
||||
colord({ r: 185, g: 150, b: 255 }),
|
||||
colord({ r: 195, g: 160, b: 255 }),
|
||||
colord({ r: 205, g: 170, b: 255 }),
|
||||
colord({ r: 215, g: 180, b: 255 }),
|
||||
colord({ r: 225, g: 190, b: 255 }),
|
||||
colord({ r: 235, g: 200, b: 255 }),
|
||||
colord({ r: 35, g: 0, b: 35 }),
|
||||
colord({ r: 45, g: 0, b: 45 }),
|
||||
colord({ r: 55, g: 0, b: 55 }),
|
||||
colord({ r: 65, g: 0, b: 65 }),
|
||||
colord({ r: 75, g: 0, b: 75 }),
|
||||
colord({ r: 85, g: 0, b: 85 }),
|
||||
colord({ r: 95, g: 0, b: 95 }),
|
||||
colord({ r: 105, g: 0, b: 105 }),
|
||||
colord({ r: 115, g: 0, b: 115 }),
|
||||
colord({ r: 125, g: 0, b: 125 }),
|
||||
colord({ r: 135, g: 0, b: 135 }),
|
||||
colord({ r: 145, g: 0, b: 145 }),
|
||||
colord({ r: 155, g: 0, b: 155 }),
|
||||
colord({ r: 165, g: 0, b: 165 }),
|
||||
colord({ r: 175, g: 0, b: 175 }),
|
||||
colord({ r: 185, g: 0, b: 185 }),
|
||||
colord({ r: 195, g: 5, b: 195 }),
|
||||
colord({ r: 205, g: 10, b: 205 }),
|
||||
colord({ r: 215, g: 15, b: 215 }),
|
||||
colord({ r: 225, g: 20, b: 225 }),
|
||||
colord({ r: 235, g: 25, b: 235 }),
|
||||
colord({ r: 245, g: 30, b: 245 }),
|
||||
colord({ r: 255, g: 35, b: 255 }),
|
||||
colord({ r: 255, g: 45, b: 255 }),
|
||||
colord({ r: 255, g: 55, b: 255 }),
|
||||
colord({ r: 255, g: 65, b: 255 }),
|
||||
colord({ r: 255, g: 75, b: 255 }),
|
||||
colord({ r: 255, g: 85, b: 255 }),
|
||||
colord({ r: 255, g: 95, b: 255 }),
|
||||
colord({ r: 255, g: 105, b: 255 }),
|
||||
colord({ r: 255, g: 115, b: 255 }),
|
||||
colord({ r: 255, g: 125, b: 255 }),
|
||||
colord({ r: 255, g: 135, b: 255 }),
|
||||
colord({ r: 255, g: 145, b: 255 }),
|
||||
colord({ r: 255, g: 155, b: 255 }),
|
||||
colord({ r: 255, g: 165, b: 255 }),
|
||||
colord({ r: 255, g: 175, b: 255 }),
|
||||
colord({ r: 255, g: 185, b: 255 }),
|
||||
colord({ r: 255, g: 195, b: 255 }),
|
||||
colord({ r: 255, g: 205, b: 255 }),
|
||||
colord({ r: 255, g: 215, b: 255 }),
|
||||
colord({ r: 0, g: 35, b: 35 }),
|
||||
colord({ r: 0, g: 45, b: 45 }),
|
||||
colord({ r: 0, g: 55, b: 55 }),
|
||||
colord({ r: 0, g: 65, b: 65 }),
|
||||
colord({ r: 0, g: 75, b: 75 }),
|
||||
colord({ r: 0, g: 85, b: 85 }),
|
||||
colord({ r: 0, g: 95, b: 95 }),
|
||||
colord({ r: 0, g: 105, b: 105 }),
|
||||
colord({ r: 0, g: 115, b: 115 }),
|
||||
colord({ r: 0, g: 125, b: 125 }),
|
||||
colord({ r: 0, g: 135, b: 135 }),
|
||||
colord({ r: 0, g: 145, b: 145 }),
|
||||
colord({ r: 0, g: 155, b: 155 }),
|
||||
colord({ r: 0, g: 165, b: 165 }),
|
||||
colord({ r: 0, g: 175, b: 175 }),
|
||||
colord({ r: 0, g: 185, b: 185 }),
|
||||
colord({ r: 5, g: 195, b: 195 }),
|
||||
colord({ r: 10, g: 205, b: 205 }),
|
||||
colord({ r: 15, g: 215, b: 215 }),
|
||||
colord({ r: 20, g: 225, b: 225 }),
|
||||
colord({ r: 25, g: 235, b: 235 }),
|
||||
colord({ r: 30, g: 245, b: 245 }),
|
||||
colord({ r: 35, g: 255, b: 255 }),
|
||||
colord({ r: 45, g: 255, b: 255 }),
|
||||
colord({ r: 55, g: 255, b: 255 }),
|
||||
colord({ r: 65, g: 255, b: 255 }),
|
||||
colord({ r: 75, g: 255, b: 255 }),
|
||||
colord({ r: 85, g: 255, b: 255 }),
|
||||
colord({ r: 95, g: 255, b: 255 }),
|
||||
colord({ r: 105, g: 255, b: 255 }),
|
||||
colord({ r: 115, g: 255, b: 255 }),
|
||||
colord({ r: 125, g: 255, b: 255 }),
|
||||
colord({ r: 135, g: 255, b: 255 }),
|
||||
colord({ r: 145, g: 255, b: 255 }),
|
||||
colord({ r: 155, g: 255, b: 255 }),
|
||||
colord({ r: 165, g: 255, b: 255 }),
|
||||
colord({ r: 175, g: 255, b: 255 }),
|
||||
colord({ r: 185, g: 255, b: 255 }),
|
||||
colord({ r: 195, g: 255, b: 255 }),
|
||||
colord({ r: 205, g: 255, b: 255 }),
|
||||
colord({ r: 215, g: 255, b: 255 }),
|
||||
colord({ r: 35, g: 35, b: 0 }),
|
||||
colord({ r: 45, g: 45, b: 0 }),
|
||||
colord({ r: 55, g: 55, b: 0 }),
|
||||
colord({ r: 65, g: 65, b: 0 }),
|
||||
colord({ r: 75, g: 75, b: 0 }),
|
||||
colord({ r: 85, g: 85, b: 0 }),
|
||||
colord({ r: 95, g: 95, b: 0 }),
|
||||
colord({ r: 105, g: 105, b: 0 }),
|
||||
colord({ r: 115, g: 115, b: 0 }),
|
||||
colord({ r: 125, g: 125, b: 0 }),
|
||||
colord({ r: 135, g: 135, b: 0 }),
|
||||
colord({ r: 145, g: 145, b: 0 }),
|
||||
colord({ r: 155, g: 155, b: 0 }),
|
||||
colord({ r: 165, g: 165, b: 0 }),
|
||||
colord({ r: 175, g: 175, b: 0 }),
|
||||
colord({ r: 185, g: 185, b: 0 }),
|
||||
colord({ r: 195, g: 195, b: 5 }),
|
||||
colord({ r: 205, g: 205, b: 10 }),
|
||||
colord({ r: 215, g: 215, b: 15 }),
|
||||
colord({ r: 225, g: 225, b: 20 }),
|
||||
colord({ r: 235, g: 235, b: 25 }),
|
||||
colord({ r: 245, g: 245, b: 30 }),
|
||||
colord({ r: 255, g: 255, b: 35 }),
|
||||
colord({ r: 255, g: 255, b: 45 }),
|
||||
colord({ r: 255, g: 255, b: 55 }),
|
||||
colord({ r: 255, g: 255, b: 65 }),
|
||||
colord({ r: 255, g: 255, b: 75 }),
|
||||
colord({ r: 255, g: 255, b: 85 }),
|
||||
colord({ r: 255, g: 255, b: 95 }),
|
||||
colord({ r: 255, g: 255, b: 105 }),
|
||||
colord({ r: 255, g: 255, b: 115 }),
|
||||
colord({ r: 255, g: 255, b: 125 }),
|
||||
colord({ r: 255, g: 255, b: 135 }),
|
||||
colord({ r: 255, g: 255, b: 145 }),
|
||||
colord({ r: 255, g: 255, b: 155 }),
|
||||
colord({ r: 255, g: 255, b: 165 }),
|
||||
colord({ r: 255, g: 255, b: 175 }),
|
||||
colord({ r: 255, g: 255, b: 185 }),
|
||||
colord({ r: 255, g: 255, b: 195 }),
|
||||
colord({ r: 255, g: 255, b: 205 }),
|
||||
colord({ r: 255, g: 255, b: 215 }),
|
||||
colord({ r: 215, g: 255, b: 200 }), // Fresh Mint
|
||||
colord({ r: 225, g: 255, b: 175 }), // Soft Lime
|
||||
colord({ r: 240, g: 250, b: 160 }), // Citrus Wash
|
||||
colord({ r: 245, g: 245, b: 175 }), // Lemon Mist
|
||||
colord({ r: 150, g: 200, b: 255 }), // Cornflower Mist
|
||||
colord({ r: 150, g: 255, b: 185 }), // Green Sorbet
|
||||
colord({ r: 160, g: 215, b: 255 }), // Powder Blue
|
||||
colord({ r: 160, g: 255, b: 195 }), // Pastel Apple
|
||||
colord({ r: 170, g: 190, b: 255 }), // Periwinkle Ice
|
||||
colord({ r: 170, g: 225, b: 255 }), // Baby Sky
|
||||
colord({ r: 170, g: 255, b: 205 }), // Aloe Breeze
|
||||
colord({ r: 180, g: 180, b: 255 }), // Pale Indigo
|
||||
colord({ r: 180, g: 235, b: 250 }), // Aqua Pastel
|
||||
colord({ r: 180, g: 255, b: 215 }), // Pale Mint
|
||||
colord({ r: 190, g: 140, b: 195 }), // Fuchsia Tint
|
||||
colord({ r: 190, g: 245, b: 240 }), // Ice Mint
|
||||
colord({ r: 190, g: 255, b: 225 }), // Mint Water
|
||||
colord({ r: 210, g: 255, b: 245 }), // Sea Mist
|
||||
colord({ r: 220, g: 255, b: 255 }), // Pale Aqua
|
||||
colord({ r: 230, g: 250, b: 255 }), // Sky Haze
|
||||
colord({ r: 240, g: 240, b: 255 }), // Frosted Lilac
|
||||
colord({ r: 250, g: 230, b: 255 }), // Misty Mauve
|
||||
colord({ r: 170, g: 190, b: 255 }), // Periwinkle Ice
|
||||
colord({ r: 180, g: 180, b: 255 }), // Pale Indigo
|
||||
colord({ r: 200, g: 170, b: 255 }), // Lilac Bloom
|
||||
colord({ r: 190, g: 140, b: 195 }), // Fuchsia Tint
|
||||
colord({ r: 195, g: 145, b: 200 }), // Dusky Rose
|
||||
colord({ r: 200, g: 150, b: 205 }), // Plum Frost
|
||||
colord({ r: 200, g: 170, b: 255 }), // Lilac Bloom
|
||||
colord({ r: 200, g: 255, b: 215 }), // Cool Aloe
|
||||
colord({ r: 200, g: 255, b: 235 }), // Cool Mist
|
||||
colord({ r: 205, g: 155, b: 210 }), // Berry Foam
|
||||
colord({ r: 210, g: 160, b: 215 }), // Grape Cloud
|
||||
colord({ r: 210, g: 255, b: 245 }), // Sea Mist
|
||||
colord({ r: 215, g: 165, b: 220 }), // Light Bloom
|
||||
colord({ r: 215, g: 255, b: 200 }), // Fresh Mint
|
||||
colord({ r: 220, g: 160, b: 255 }), // Violet Mist
|
||||
colord({ r: 220, g: 170, b: 225 }), // Cherry Blossom
|
||||
colord({ r: 220, g: 255, b: 255 }), // Pale Aqua
|
||||
colord({ r: 225, g: 175, b: 230 }), // Faded Rose
|
||||
colord({ r: 225, g: 255, b: 175 }), // Soft Lime
|
||||
colord({ r: 230, g: 180, b: 235 }), // Dreamy Mauve
|
||||
colord({ r: 230, g: 250, b: 255 }), // Sky Haze
|
||||
colord({ r: 235, g: 150, b: 255 }), // Orchid Glow
|
||||
colord({ r: 235, g: 185, b: 240 }), // Powder Violet
|
||||
colord({ r: 240, g: 190, b: 245 }), // Pastel Violet
|
||||
colord({ r: 240, g: 240, b: 255 }), // Frosted Lilac
|
||||
colord({ r: 240, g: 250, b: 160 }), // Citrus Wash
|
||||
colord({ r: 245, g: 160, b: 240 }), // Rose Lilac
|
||||
colord({ r: 245, g: 195, b: 250 }), // Soft Magenta
|
||||
colord({ r: 245, g: 245, b: 175 }), // Lemon Mist
|
||||
colord({ r: 250, g: 200, b: 255 }), // Lilac Cream
|
||||
colord({ r: 250, g: 230, b: 255 }), // Misty Mauve
|
||||
colord({ r: 255, g: 205, b: 255 }), // Violet Bloom
|
||||
colord({ r: 255, g: 210, b: 255 }), // Orchid Mist
|
||||
colord({ r: 255, g: 210, b: 250 }), // Lavender Mist
|
||||
colord({ r: 255, g: 205, b: 245 }), // Pastel Orchid
|
||||
colord({ r: 255, g: 215, b: 245 }), // Rose Whisper
|
||||
colord({ r: 220, g: 160, b: 255 }), // Violet Mist
|
||||
colord({ r: 235, g: 150, b: 255 }), // Orchid Glow
|
||||
colord({ r: 245, g: 160, b: 240 }), // Rose Lilac
|
||||
colord({ r: 255, g: 170, b: 225 }), // Bubblegum Pink
|
||||
colord({ r: 255, g: 185, b: 215 }), // Blush Mist
|
||||
colord({ r: 255, g: 195, b: 235 }), // Faded Fuchsia
|
||||
colord({ r: 255, g: 200, b: 220 }), // Cotton Rose
|
||||
colord({ r: 255, g: 205, b: 245 }), // Pastel Orchid
|
||||
colord({ r: 255, g: 205, b: 255 }), // Violet Bloom
|
||||
colord({ r: 255, g: 210, b: 230 }), // Pastel Blush
|
||||
colord({ r: 255, g: 210, b: 250 }), // Lavender Mist
|
||||
colord({ r: 255, g: 210, b: 255 }), // Orchid Mist
|
||||
colord({ r: 255, g: 215, b: 195 }), // Apricot Glow
|
||||
colord({ r: 255, g: 215, b: 245 }), // Rose Whisper
|
||||
colord({ r: 255, g: 220, b: 235 }), // Pink Mist
|
||||
colord({ r: 255, g: 220, b: 250 }), // Powder Petal
|
||||
colord({ r: 255, g: 225, b: 180 }), // Butter Peach
|
||||
colord({ r: 255, g: 225, b: 255 }), // Petal Mist
|
||||
colord({ r: 255, g: 230, b: 245 }), // Light Rose
|
||||
colord({ r: 255, g: 235, b: 200 }), // Cream Peach
|
||||
colord({ r: 255, g: 235, b: 235 }), // Blushed Petal
|
||||
colord({ r: 255, g: 240, b: 220 }), // Pastel Sand
|
||||
colord({ r: 255, g: 215, b: 195 }), // Apricot Glow
|
||||
colord({ r: 255, g: 225, b: 180 }), // Butter Peach
|
||||
colord({ r: 255, g: 230, b: 190 }),
|
||||
colord({ r: 255, g: 235, b: 200 }), // Cream Peach
|
||||
colord({ r: 255, g: 245, b: 210 }), // Soft Banana
|
||||
colord({ r: 255, g: 240, b: 220 }), // Pastel Sand
|
||||
];
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface ServerConfig {
|
||||
cloudflareCredsPath(): string;
|
||||
stripePublishableKey(): string;
|
||||
allowedFlares(): string[] | undefined;
|
||||
enableMatchmaking(): boolean;
|
||||
}
|
||||
|
||||
export interface NukeMagnitude {
|
||||
@@ -130,6 +131,8 @@ export interface Config {
|
||||
emojiMessageCooldown(): Tick;
|
||||
emojiMessageDuration(): Tick;
|
||||
donateCooldown(): Tick;
|
||||
embargoAllCooldown(): Tick;
|
||||
deletionMarkDuration(): Tick;
|
||||
deleteUnitCooldown(): Tick;
|
||||
defaultDonationAmount(sender: Player): number;
|
||||
unitInfo(type: UnitType): UnitInfo;
|
||||
@@ -198,4 +201,7 @@ export interface Theme {
|
||||
neutralColor(): Colord;
|
||||
enemyColor(): Colord;
|
||||
spawnHighlightColor(): Colord;
|
||||
spawnHighlightSelfColor(): Colord;
|
||||
spawnHighlightTeamColor(): Colord;
|
||||
spawnHighlightEnemyColor(): Colord;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ const numPlayersConfig = {
|
||||
[GameMapType.Africa]: [100, 70, 50],
|
||||
[GameMapType.Asia]: [50, 40, 30],
|
||||
[GameMapType.Australia]: [70, 40, 30],
|
||||
[GameMapType.Achiran]: [40, 36, 30],
|
||||
[GameMapType.Baikal]: [100, 70, 50],
|
||||
[GameMapType.BetweenTwoSeas]: [70, 50, 40],
|
||||
[GameMapType.BlackSea]: [50, 30, 30],
|
||||
@@ -213,6 +214,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
workerPortByIndex(index: number): number {
|
||||
return 3001 + index;
|
||||
}
|
||||
enableMatchmaking(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultConfig implements Config {
|
||||
@@ -569,6 +573,12 @@ export class DefaultConfig implements Config {
|
||||
donateCooldown(): Tick {
|
||||
return 10 * 10;
|
||||
}
|
||||
embargoAllCooldown(): Tick {
|
||||
return 10 * 10;
|
||||
}
|
||||
deletionMarkDuration(): Tick {
|
||||
return 15 * 10;
|
||||
}
|
||||
deleteUnitCooldown(): Tick {
|
||||
return 5 * 10;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,23 @@ export class PastelTheme implements Theme {
|
||||
private water = colord({ r: 70, g: 132, b: 180 });
|
||||
private shorelineWater = colord({ r: 100, g: 143, b: 255 });
|
||||
|
||||
/** Alternate View colors for self, green */
|
||||
private _selfColor = colord({ r: 0, g: 255, b: 0 });
|
||||
/** Alternate View colors for allies, yellow */
|
||||
private _allyColor = colord({ r: 255, g: 255, b: 0 });
|
||||
/** Alternate View colors for neutral, gray */
|
||||
private _neutralColor = colord({ r: 128, g: 128, b: 128 });
|
||||
/** Alternate View colors for enemies, red */
|
||||
private _enemyColor = colord({ r: 255, g: 0, b: 0 });
|
||||
|
||||
/** Default spawn highlight colors for other players in FFA, yellow */
|
||||
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
|
||||
/** Added non-default spawn highlight colors for self, full white */
|
||||
private _spawnHighlightSelfColor = colord({ r: 255, g: 255, b: 255 });
|
||||
/** Added non-default spawn highlight colors for teammates, green */
|
||||
private _spawnHighlightTeamColor = colord({ r: 0, g: 255, b: 0 });
|
||||
/** Added non-default spawn highlight colors for enemies, red */
|
||||
private _spawnHighlightEnemyColor = colord({ r: 255, g: 0, b: 0 });
|
||||
|
||||
teamColor(team: Team): Colord {
|
||||
return this.teamColorAllocator.assignTeamColor(team);
|
||||
@@ -144,4 +155,16 @@ export class PastelTheme implements Theme {
|
||||
spawnHighlightColor(): Colord {
|
||||
return this._spawnHighlightColor;
|
||||
}
|
||||
/** Return spawn highlight color for self */
|
||||
spawnHighlightSelfColor(): Colord {
|
||||
return this._spawnHighlightSelfColor;
|
||||
}
|
||||
/** Return spawn highlight color for teammates */
|
||||
spawnHighlightTeamColor(): Colord {
|
||||
return this._spawnHighlightTeamColor;
|
||||
}
|
||||
/** Return spawn highlight color for enemies */
|
||||
spawnHighlightEnemyColor(): Colord {
|
||||
return this._spawnHighlightEnemyColor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Execution, Game, MessageType, Player } from "../game/Game";
|
||||
import { Execution, Game, MessageType, Player, Unit } from "../game/Game";
|
||||
|
||||
export class DeleteUnitExecution implements Execution {
|
||||
private active: boolean = true;
|
||||
private mg: Game;
|
||||
private unit: Unit | null = null;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
@@ -33,6 +34,7 @@ export class DeleteUnitExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.unit = unit;
|
||||
|
||||
const tileOwner = mg.owner(unit.tile());
|
||||
if (!tileOwner.isPlayer() || tileOwner.id() !== this.player.id()) {
|
||||
@@ -61,19 +63,29 @@ export class DeleteUnitExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
unit.delete(false);
|
||||
this.player.recordDeleteUnit();
|
||||
|
||||
this.mg.displayMessage(
|
||||
`events_display.unit_voluntarily_deleted`,
|
||||
MessageType.UNIT_DESTROYED,
|
||||
this.player.id(),
|
||||
);
|
||||
|
||||
this.active = false;
|
||||
unit.markForDeletion();
|
||||
}
|
||||
|
||||
tick(ticks: number) {}
|
||||
tick(ticks: number) {
|
||||
if (!this.active || !this.unit) {
|
||||
return;
|
||||
}
|
||||
if (!this.unit.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (this.unit.isOverdueDeletion()) {
|
||||
this.unit.delete(false);
|
||||
|
||||
this.mg.displayMessage(
|
||||
`events_display.unit_voluntarily_deleted`,
|
||||
MessageType.UNIT_DESTROYED,
|
||||
this.player.id(),
|
||||
);
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Execution, Game, Player, PlayerType } from "../game/Game";
|
||||
|
||||
export class EmbargoAllExecution implements Execution {
|
||||
constructor(
|
||||
private readonly player: Player,
|
||||
private readonly action: "start" | "stop",
|
||||
) {}
|
||||
|
||||
init(mg: Game, _: number): void {
|
||||
if (!this.player.canEmbargoAll()) {
|
||||
return;
|
||||
}
|
||||
const me = this.player;
|
||||
for (const p of mg.players()) {
|
||||
if (p.id() === me.id()) continue;
|
||||
if (p.type() === PlayerType.Bot) continue;
|
||||
if (me.isOnSameTeam(p)) continue;
|
||||
|
||||
if (this.action === "start") {
|
||||
if (!me.hasEmbargoAgainst(p)) me.addEmbargo(p, false);
|
||||
} else {
|
||||
if (me.hasEmbargoAgainst(p)) me.stopEmbargo(p);
|
||||
}
|
||||
}
|
||||
|
||||
this.player.recordEmbargoAll();
|
||||
}
|
||||
|
||||
tick(_: number): void {}
|
||||
|
||||
isActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { DeleteUnitExecution } from "./DeleteUnitExecution";
|
||||
import { DonateGoldExecution } from "./DonateGoldExecution";
|
||||
import { DonateTroopsExecution } from "./DonateTroopExecution";
|
||||
import { EmbargoAllExecution } from "./EmbargoAllExecution";
|
||||
import { EmbargoExecution } from "./EmbargoExecution";
|
||||
import { EmojiExecution } from "./EmojiExecution";
|
||||
import { FakeHumanExecution } from "./FakeHumanExecution";
|
||||
@@ -100,6 +101,8 @@ export class Executor {
|
||||
return new DonateGoldExecution(player, intent.recipient, intent.gold);
|
||||
case "embargo":
|
||||
return new EmbargoExecution(player, intent.targetID, intent.action);
|
||||
case "embargo_all":
|
||||
return new EmbargoAllExecution(player, intent.action);
|
||||
case "build_unit":
|
||||
return new ConstructionExecution(player, intent.unit, intent.tile);
|
||||
case "allianceExtension": {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TileRef, euclDistFN } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util";
|
||||
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { EmojiExecution } from "./EmojiExecution";
|
||||
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
|
||||
@@ -221,7 +222,9 @@ export class FakeHumanExecution implements Execution {
|
||||
if (this.random.chance(20)) {
|
||||
const toAlly = this.random.randElement(enemies);
|
||||
if (this.player.canSendAllianceRequest(toAlly)) {
|
||||
this.player.createAllianceRequest(toAlly);
|
||||
this.mg.addExecution(
|
||||
new AllianceRequestExecution(this.player, toAlly.id()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export class PlayerExecution implements Execution {
|
||||
if (main === undefined) throw new Error("No clusters");
|
||||
this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main);
|
||||
const surroundedBy = this.surroundedBySamePlayer(main);
|
||||
if (surroundedBy && !this.player.isFriendly(surroundedBy)) {
|
||||
if (surroundedBy && !surroundedBy.isFriendly(this.player)) {
|
||||
this.removeCluster(main);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class UpgradeStructureExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.player.canUpgradeUnit(this.structure.type())) {
|
||||
if (!this.player.canUpgradeUnit(this.structure)) {
|
||||
console.warn(
|
||||
`[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`,
|
||||
);
|
||||
|
||||
+13
-4
@@ -95,6 +95,7 @@ export enum GameMapType {
|
||||
Yenisei = "Yenisei",
|
||||
Pluto = "Pluto",
|
||||
Montreal = "Montreal",
|
||||
Achiran = "Achiran",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -135,6 +136,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.Pluto,
|
||||
GameMapType.Mars,
|
||||
GameMapType.DeglaciatedAntarctica,
|
||||
GameMapType.Achiran,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -202,6 +204,7 @@ const _structureTypes: ReadonlySet<UnitType> = new Set([
|
||||
UnitType.SAMLauncher,
|
||||
UnitType.MissileSilo,
|
||||
UnitType.Port,
|
||||
UnitType.Factory,
|
||||
]);
|
||||
|
||||
export function isStructureType(type: UnitType): boolean {
|
||||
@@ -405,11 +408,11 @@ export class PlayerInfo {
|
||||
public readonly nation?: Nation | null,
|
||||
) {
|
||||
// Compute clan from name
|
||||
if (!name.startsWith("[") || !name.includes("]")) {
|
||||
if (!name.includes("[") || !name.includes("]")) {
|
||||
this.clan = null;
|
||||
} else {
|
||||
const clanMatch = name.match(/^\[([a-zA-Z]{2,5})\]/);
|
||||
this.clan = clanMatch ? clanMatch[1] : null;
|
||||
const clanMatch = name.match(/\[([a-zA-Z0-9]{2,5})\]/);
|
||||
this.clan = clanMatch ? clanMatch[1].toUpperCase() : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,6 +435,9 @@ export interface Unit {
|
||||
type(): UnitType;
|
||||
owner(): Player;
|
||||
info(): UnitInfo;
|
||||
isMarkedForDeletion(): boolean;
|
||||
markForDeletion(): void;
|
||||
isOverdueDeletion(): boolean;
|
||||
delete(displayMessage?: boolean, destroyer?: Player): void;
|
||||
tile(): TileRef;
|
||||
lastTile(): TileRef;
|
||||
@@ -576,7 +582,7 @@ export interface Player {
|
||||
// New units of the same type can upgrade existing units.
|
||||
// e.g. if a place a new city here, can it upgrade an existing city?
|
||||
findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false;
|
||||
canUpgradeUnit(unitType: UnitType): boolean;
|
||||
canUpgradeUnit(unit: Unit): boolean;
|
||||
upgradeUnit(unit: Unit): void;
|
||||
captureUnit(unit: Unit): void;
|
||||
|
||||
@@ -621,6 +627,8 @@ export interface Player {
|
||||
donateGold(recipient: Player, gold: Gold): boolean;
|
||||
canDeleteUnit(): boolean;
|
||||
recordDeleteUnit(): void;
|
||||
canEmbargoAll(): boolean;
|
||||
recordEmbargoAll(): void;
|
||||
|
||||
// Embargo
|
||||
hasEmbargoAgainst(other: Player): boolean;
|
||||
@@ -743,6 +751,7 @@ export interface PlayerActions {
|
||||
canAttack: boolean;
|
||||
buildableUnits: BuildableUnit[];
|
||||
canSendEmojiAllPlayers: boolean;
|
||||
canEmbargoAll?: boolean;
|
||||
interaction?: PlayerInteraction;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ export interface UnitUpdate {
|
||||
reachedTarget: boolean;
|
||||
retreating: boolean;
|
||||
targetable: boolean;
|
||||
markedForDeletion: number | false;
|
||||
targetUnitId?: number; // Only for trade ships
|
||||
targetTile?: TileRef; // Only for nukes
|
||||
health?: number;
|
||||
|
||||
@@ -88,6 +88,10 @@ export class UnitView {
|
||||
return this.data.targetable;
|
||||
}
|
||||
|
||||
markedForDeletion(): number | false {
|
||||
return this.data.markedForDeletion;
|
||||
}
|
||||
|
||||
type(): UnitType {
|
||||
return this.data.unitType;
|
||||
}
|
||||
@@ -435,10 +439,13 @@ export class PlayerView {
|
||||
return this.data.lastDeleteUnitTick;
|
||||
}
|
||||
|
||||
canDeleteUnit(): boolean {
|
||||
deleteUnitCooldown(): number {
|
||||
return (
|
||||
this.game.ticks() + 1 - this.lastDeleteUnitTick() >=
|
||||
this.game.config().deleteUnitCooldown()
|
||||
Math.max(
|
||||
0,
|
||||
this.game.config().deleteUnitCooldown() -
|
||||
(this.game.ticks() + 1 - this.lastDeleteUnitTick()),
|
||||
) / 10
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -578,7 +585,7 @@ export class GameView implements GameMap {
|
||||
tile: TileRef,
|
||||
searchRange: number,
|
||||
type: UnitType,
|
||||
playerId: PlayerID,
|
||||
playerId?: PlayerID,
|
||||
) {
|
||||
return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export class PlayerImpl implements Player {
|
||||
private relations = new Map<Player, number>();
|
||||
|
||||
private lastDeleteUnitTick: Tick = -1;
|
||||
private lastEmbargoAllTick: Tick = -1;
|
||||
|
||||
public _incomingAttacks: Attack[] = [];
|
||||
public _outgoingAttacks: Attack[] = [];
|
||||
@@ -391,6 +392,13 @@ export class PlayerImpl implements Player {
|
||||
if (other === this) {
|
||||
return false;
|
||||
}
|
||||
if (this.isDisconnected() || other.isDisconnected()) {
|
||||
// Disconnected players are marked as not-friendly even if they are allies,
|
||||
// so we need to return early if either player is disconnected.
|
||||
// Otherise we could end up sending an alliance request to someone
|
||||
// we are already allied with.
|
||||
return false;
|
||||
}
|
||||
if (this.isFriendly(other) || !this.isAlive()) {
|
||||
return false;
|
||||
}
|
||||
@@ -683,6 +691,28 @@ export class PlayerImpl implements Player {
|
||||
this.lastDeleteUnitTick = this.mg.ticks();
|
||||
}
|
||||
|
||||
canEmbargoAll(): boolean {
|
||||
// Cooldown gate
|
||||
if (
|
||||
this.mg.ticks() - this.lastEmbargoAllTick <
|
||||
this.mg.config().embargoAllCooldown()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// At least one eligible player exists
|
||||
for (const p of this.mg.players()) {
|
||||
if (p.id() === this.id()) continue;
|
||||
if (p.type() === PlayerType.Bot) continue;
|
||||
if (this.isOnSameTeam(p)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
recordEmbargoAll(): void {
|
||||
this.lastEmbargoAllTick = this.mg.ticks();
|
||||
}
|
||||
|
||||
hasEmbargoAgainst(other: Player): boolean {
|
||||
return this.embargoes.has(other.id());
|
||||
}
|
||||
@@ -865,14 +895,14 @@ export class PlayerImpl implements Player {
|
||||
return existing[0].unit;
|
||||
}
|
||||
|
||||
public canUpgradeUnit(
|
||||
unitType: UnitType,
|
||||
skipBuildCheck: boolean = false,
|
||||
): boolean {
|
||||
if (!this.mg.config().unitInfo(unitType).upgradable) {
|
||||
public canUpgradeUnit(unit: Unit, skipBuildCheck: boolean = false): boolean {
|
||||
if (unit.isMarkedForDeletion()) {
|
||||
return false;
|
||||
}
|
||||
if (!skipBuildCheck && this.canBuildUnit(unitType) === false) {
|
||||
if (!this.mg.config().unitInfo(unit.type()).upgradable) {
|
||||
return false;
|
||||
}
|
||||
if (!skipBuildCheck && this.canBuildUnit(unit.type()) === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -39,6 +39,7 @@ export class UnitImpl implements Unit {
|
||||
// Nuke only
|
||||
private _trajectoryIndex: number = 0;
|
||||
private _trajectory: TrajectoryTile[];
|
||||
private _deletionAt: number | null = null;
|
||||
|
||||
constructor(
|
||||
private _type: UnitType,
|
||||
@@ -126,6 +127,7 @@ export class UnitImpl implements Unit {
|
||||
reachedTarget: this._reachedTarget,
|
||||
retreating: this._retreating,
|
||||
pos: this._tile,
|
||||
markedForDeletion: this._deletionAt ?? false,
|
||||
targetable: this._targetable,
|
||||
lastPos: this._lastTile,
|
||||
health: this.hasHealth() ? Number(this._health) : undefined,
|
||||
@@ -182,6 +184,7 @@ export class UnitImpl implements Unit {
|
||||
}
|
||||
|
||||
setOwner(newOwner: PlayerImpl): void {
|
||||
this.clearPendingDeletion();
|
||||
switch (this._type) {
|
||||
case UnitType.Warship:
|
||||
case UnitType.Port:
|
||||
@@ -221,6 +224,30 @@ export class UnitImpl implements Unit {
|
||||
}
|
||||
}
|
||||
|
||||
clearPendingDeletion(): void {
|
||||
this._deletionAt = null;
|
||||
}
|
||||
|
||||
isMarkedForDeletion(): boolean {
|
||||
return this._deletionAt !== null;
|
||||
}
|
||||
|
||||
markForDeletion(): void {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
}
|
||||
this._deletionAt =
|
||||
this.mg.ticks() + this.mg.config().deletionMarkDuration();
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
isOverdueDeletion(): boolean {
|
||||
if (!this.isActive()) {
|
||||
return false;
|
||||
}
|
||||
return this._deletionAt !== null && this.mg.ticks() - this._deletionAt > 0;
|
||||
}
|
||||
|
||||
delete(displayMessage?: boolean, destroyer?: Player): void {
|
||||
if (!this.isActive()) {
|
||||
throw new Error(`cannot delete ${this} not active`);
|
||||
|
||||
@@ -106,6 +106,7 @@ export class PathFinder {
|
||||
private curr: TileRef | null = null;
|
||||
private dst: TileRef | null = null;
|
||||
private path: TileRef[] | null = null;
|
||||
private path_idx: number = 0;
|
||||
private aStar: AStar<TileRef>;
|
||||
private computeFinished = true;
|
||||
|
||||
@@ -148,6 +149,7 @@ export class PathFinder {
|
||||
}
|
||||
|
||||
if (this.game.manhattanDist(curr, dst) < dist) {
|
||||
this.path = null;
|
||||
return { type: PathFindResultType.Completed, node: curr };
|
||||
}
|
||||
|
||||
@@ -156,11 +158,12 @@ export class PathFinder {
|
||||
this.curr = curr;
|
||||
this.dst = dst;
|
||||
this.path = null;
|
||||
this.path_idx = 0;
|
||||
this.aStar = this.newAStar(curr, dst);
|
||||
this.computeFinished = false;
|
||||
return this.nextTile(curr, dst);
|
||||
} else {
|
||||
const tile = this.path?.shift();
|
||||
const tile = this.path?.[this.path_idx++];
|
||||
if (tile === undefined) {
|
||||
throw new Error("missing tile");
|
||||
}
|
||||
@@ -172,8 +175,9 @@ export class PathFinder {
|
||||
case PathFindResultType.Completed:
|
||||
this.computeFinished = true;
|
||||
this.path = this.aStar.reconstructPath();
|
||||
// Remove the start tile
|
||||
this.path.shift();
|
||||
|
||||
// exclude first tile
|
||||
this.path_idx = 1;
|
||||
|
||||
return this.nextTile(curr, dst);
|
||||
case PathFindResultType.Pending:
|
||||
|
||||
@@ -24,6 +24,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
Africa: 7,
|
||||
Asia: 6,
|
||||
Australia: 4,
|
||||
Achiran: 14,
|
||||
Baikal: 5,
|
||||
BetweenTwoSeas: 5,
|
||||
BlackSea: 6,
|
||||
@@ -71,6 +72,8 @@ const TEAM_COUNTS = [
|
||||
export class MapPlaylist {
|
||||
private mapsPlaylist: MapWithMode[] = [];
|
||||
|
||||
constructor(private disableTeams: boolean = false) {}
|
||||
|
||||
public gameConfig(): GameConfig {
|
||||
const { map, mode } = this.getNextMap();
|
||||
|
||||
@@ -135,8 +138,10 @@ export class MapPlaylist {
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
|
||||
return false;
|
||||
if (!this.disableTeams) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -291,11 +291,6 @@ async function schedulePublicGame(playlist: MapPlaylist) {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// SPA fallback route
|
||||
app.get("*", function (req, res) {
|
||||
res.sendFile(path.join(__dirname, "../../static/index.html"));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { decodePatternData } from "../core/PatternDecoder";
|
||||
import {
|
||||
FlagSchema,
|
||||
PlayerColor,
|
||||
PlayerCosmeticRefs,
|
||||
PlayerCosmetics,
|
||||
@@ -42,10 +43,14 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
}
|
||||
}
|
||||
if (refs.flag) {
|
||||
cosmetics.flag = cosmetics.flag = refs.flag.replace(
|
||||
/[^a-z0-9-_ ()]/gi,
|
||||
"",
|
||||
);
|
||||
const result = FlagSchema.safeParse(refs.flag);
|
||||
if (!result.success) {
|
||||
return {
|
||||
type: "forbidden",
|
||||
reason: "invalid flag: " + result.error.message,
|
||||
};
|
||||
}
|
||||
cosmetics.flag = result.data;
|
||||
}
|
||||
|
||||
return { type: "allowed", cosmetics };
|
||||
|
||||
+90
-1
@@ -11,11 +11,12 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
GameID,
|
||||
ID,
|
||||
PartialGameRecordSchema,
|
||||
ServerErrorMessage,
|
||||
} from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
import { generateID, replacer } from "../core/Util";
|
||||
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
@@ -23,6 +24,7 @@ import { GameManager } from "./GameManager";
|
||||
import { getUserMe, verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
import { MapPlaylist } from "./MapPlaylist";
|
||||
import { PrivilegeRefresher } from "./PrivilegeRefresher";
|
||||
import { initWorkerMetrics } from "./WorkerMetrics";
|
||||
|
||||
@@ -30,11 +32,24 @@ const config = getServerConfigFromServer();
|
||||
|
||||
const workerId = parseInt(process.env.WORKER_ID ?? "0");
|
||||
const log = logger.child({ comp: `w_${workerId}` });
|
||||
const playlist = new MapPlaylist(true);
|
||||
|
||||
// Worker setup
|
||||
export async function startWorker() {
|
||||
log.info(`Worker starting...`);
|
||||
|
||||
if (config.enableMatchmaking()) {
|
||||
log.info("Starting matchmaking");
|
||||
setTimeout(
|
||||
() => {
|
||||
pollLobby(gm);
|
||||
},
|
||||
1000 + Math.random() * 2000,
|
||||
);
|
||||
} else {
|
||||
log.info("Matchmaking disabled");
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -450,3 +465,77 @@ export async function startWorker() {
|
||||
log.error(`unhandled rejection at:`, promise, "reason:", reason);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollLobby(gm: GameManager) {
|
||||
try {
|
||||
const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`;
|
||||
const gameId = generateGameIdForWorker();
|
||||
if (gameId === null) {
|
||||
log.warn(`Failed to generate game ID for worker ${workerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": config.apiKey(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: workerId,
|
||||
gameId: gameId,
|
||||
ccu: gm.activeClients(),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
log.warn(
|
||||
`Failed to poll lobby: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
log.info(`Lobby poll successful:`, data);
|
||||
|
||||
if (data.assignment) {
|
||||
// TODO: Only allow specified players to join the game.
|
||||
console.log(`Creating game ${gameId}`);
|
||||
const game = gm.createGame(gameId, playlist.gameConfig());
|
||||
setTimeout(() => {
|
||||
// Wait a few seconds to allow clients to connect.
|
||||
console.log(`Starting game ${gameId}`);
|
||||
game.start();
|
||||
}, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error polling lobby:`, error);
|
||||
} finally {
|
||||
setTimeout(
|
||||
() => {
|
||||
pollLobby(gm);
|
||||
},
|
||||
5000 + Math.random() * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This is a hack to generate a game ID for the worker.
|
||||
// It should be replaced with a more robust solution.
|
||||
function generateGameIdForWorker(): GameID | null {
|
||||
let attempts = 1000;
|
||||
while (attempts > 0) {
|
||||
const gameId = generateID();
|
||||
if (workerId === config.workerIndex(gameId)) {
|
||||
return gameId;
|
||||
}
|
||||
attempts--;
|
||||
}
|
||||
log.warn(`Failed to generate game ID for worker ${workerId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../src/core/game/Game";
|
||||
import { TileRef } from "../src/core/game/GameMap";
|
||||
import { setup } from "./util/Setup";
|
||||
import { executeTicks } from "./util/utils";
|
||||
|
||||
describe("DeleteUnitExecution Security Tests", () => {
|
||||
let game: Game;
|
||||
@@ -79,6 +80,7 @@ describe("DeleteUnitExecution Security Tests", () => {
|
||||
execution.init(game, 0);
|
||||
|
||||
expect(execution.isActive()).toBe(false);
|
||||
expect(enemyUnit.isMarkedForDeletion()).toBe(false);
|
||||
});
|
||||
|
||||
it("should prevent deleting units on enemy territory", () => {
|
||||
@@ -90,6 +92,7 @@ describe("DeleteUnitExecution Security Tests", () => {
|
||||
execution.init(game, 0);
|
||||
|
||||
expect(execution.isActive()).toBe(false);
|
||||
expect(unit.isMarkedForDeletion()).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -100,15 +103,7 @@ describe("DeleteUnitExecution Security Tests", () => {
|
||||
execution.init(game, 0);
|
||||
|
||||
expect(execution.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow deleting the last city (suicide)", () => {
|
||||
jest.spyOn(game, "inSpawnPhase").mockReturnValue(false);
|
||||
|
||||
const execution = new DeleteUnitExecution(player, unit.id());
|
||||
execution.init(game, 0);
|
||||
|
||||
expect(unit.isActive()).toBe(false);
|
||||
expect(unit.isMarkedForDeletion()).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow deleting units when all conditions are met", () => {
|
||||
@@ -117,7 +112,32 @@ describe("DeleteUnitExecution Security Tests", () => {
|
||||
const execution = new DeleteUnitExecution(player, unit.id());
|
||||
execution.init(game, 0);
|
||||
|
||||
expect(unit.isMarkedForDeletion()).toBe(true);
|
||||
});
|
||||
|
||||
it("should delete after deletion delay", () => {
|
||||
jest.spyOn(game, "inSpawnPhase").mockReturnValue(false);
|
||||
|
||||
const execution = new DeleteUnitExecution(player, unit.id());
|
||||
game.addExecution(execution);
|
||||
|
||||
game.executeNextTick();
|
||||
expect(unit.isMarkedForDeletion()).toBe(true);
|
||||
expect(unit.isOverdueDeletion()).toBe(false);
|
||||
executeTicks(game, game.config().deletionMarkDuration() + 1);
|
||||
expect(unit.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset deletion if captured", () => {
|
||||
jest.spyOn(game, "inSpawnPhase").mockReturnValue(false);
|
||||
|
||||
const execution = new DeleteUnitExecution(player, unit.id());
|
||||
game.addExecution(execution);
|
||||
game.executeNextTick();
|
||||
expect(unit.isMarkedForDeletion()).toBe(true);
|
||||
unit.setOwner(enemyPlayer);
|
||||
expect(unit.isMarkedForDeletion()).toBe(false);
|
||||
expect(unit.isActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+111
-11
@@ -2,7 +2,7 @@ import { PlayerInfo, PlayerType } from "../src/core/game/Game";
|
||||
|
||||
describe("PlayerInfo", () => {
|
||||
describe("clan", () => {
|
||||
test("should extract clan from name when format is [XX]Name", () => {
|
||||
test("should extract clan from name when format contains [XX]", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[CL]PlayerName",
|
||||
PlayerType.Human,
|
||||
@@ -12,7 +12,7 @@ describe("PlayerInfo", () => {
|
||||
expect(playerInfo.clan).toBe("CL");
|
||||
});
|
||||
|
||||
test("should extract clan from name when format is [XXX]Name", () => {
|
||||
test("should extract clan from name when format contains [XXX]", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[ABC]PlayerName",
|
||||
PlayerType.Human,
|
||||
@@ -22,7 +22,7 @@ describe("PlayerInfo", () => {
|
||||
expect(playerInfo.clan).toBe("ABC");
|
||||
});
|
||||
|
||||
test("should extract clan from name when format is [XXXX]Name", () => {
|
||||
test("should extract clan from name when format contains [XXXX]", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[ABCD]PlayerName",
|
||||
PlayerType.Human,
|
||||
@@ -32,7 +32,7 @@ describe("PlayerInfo", () => {
|
||||
expect(playerInfo.clan).toBe("ABCD");
|
||||
});
|
||||
|
||||
test("should extract clan from name when format is [XXXXX]Name", () => {
|
||||
test("should extract clan from name when format contains [XXXXX]", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[ABCDE]PlayerName",
|
||||
PlayerType.Human,
|
||||
@@ -42,27 +42,37 @@ describe("PlayerInfo", () => {
|
||||
expect(playerInfo.clan).toBe("ABCDE");
|
||||
});
|
||||
|
||||
test("should extract clan from name when format is [xxxxx]Name", () => {
|
||||
test("should extract uppercase clan from name when format contains [xxxxx]", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[abcde]PlayerName",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("abcde");
|
||||
expect(playerInfo.clan).toBe("ABCDE");
|
||||
});
|
||||
|
||||
test("should extract clan from name when format is [XxXxX]Name", () => {
|
||||
test("should extract uppercase clan from name when format contains [XxXxX]", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[AbCdE]PlayerName",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("AbCdE");
|
||||
expect(playerInfo.clan).toBe("ABCDE");
|
||||
});
|
||||
|
||||
test("should return null when name doesn't start with [", () => {
|
||||
test("should extract uppercase clan from name when format contains [Xx#xX]", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[Ab1cD]PlayerName",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("AB1CD");
|
||||
});
|
||||
|
||||
test("should return null when name doesn't contain [", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"PlayerName",
|
||||
PlayerType.Human,
|
||||
@@ -82,7 +92,7 @@ describe("PlayerInfo", () => {
|
||||
expect(playerInfo.clan).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null when clan tag is not 2-5 uppercase letters", () => {
|
||||
test("should return null when clan tag is not 2-5 alphanumeric letters", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[A]PlayerName",
|
||||
PlayerType.Human,
|
||||
@@ -94,7 +104,7 @@ describe("PlayerInfo", () => {
|
||||
|
||||
test("should return null when clan tag contains non alphanumeric characters", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[A1c]PlayerName",
|
||||
"[A?c]PlayerName",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
@@ -111,5 +121,95 @@ describe("PlayerInfo", () => {
|
||||
);
|
||||
expect(playerInfo.clan).toBeNull();
|
||||
});
|
||||
|
||||
test("should extract uppercase clan name from any location in the player name", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"Player[aa]Name",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("AA");
|
||||
});
|
||||
|
||||
test("should extract only the first occurrence of a clan name match", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[Ab1cD]Player[aa]Name",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("AB1CD");
|
||||
});
|
||||
|
||||
test("should extract only the first occurrence of a valid clan name match and extract as uppercase", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[Ab1cDEF]Player[aa]Name",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("AA");
|
||||
});
|
||||
|
||||
test("should extract numeric-only clan names", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[012]PlayerName",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("012");
|
||||
});
|
||||
|
||||
test("should extract numeric-only clan names and only the first valid clan name", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"[012]Player[aa]Name",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("012");
|
||||
});
|
||||
|
||||
test("should extract numeric-only clan names from anywhere within the name", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"Player[012]Name",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("012");
|
||||
});
|
||||
|
||||
test("should extract numeric-only clan names from the end of the name", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"PlayerName[012]",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("012");
|
||||
});
|
||||
|
||||
test("should extract uppercase alphanumeric clan names from anywhere within the name", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"Player[0a1B2]Name",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("0A1B2");
|
||||
});
|
||||
|
||||
test("should extract uppercase alphanumeric clan names from the end of the name", () => {
|
||||
const playerInfo = new PlayerInfo(
|
||||
"PlayerName[0a1B2]",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_id",
|
||||
);
|
||||
expect(playerInfo.clan).toBe("0A1B2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,10 @@ export class TestConfig extends DefaultConfig {
|
||||
return 20;
|
||||
}
|
||||
|
||||
deletionMarkDuration(): number {
|
||||
return 5;
|
||||
}
|
||||
|
||||
defaultSamRange(): number {
|
||||
return 20;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game";
|
||||
import { GameID } from "../../src/core/Schemas";
|
||||
|
||||
export class TestServerConfig implements ServerConfig {
|
||||
enableMatchmaking(): boolean {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
apiKey(): string {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user