Added User Settings Modal (#379)

## Description:

This PR adds a **User Settings** modal accessible from the main UI.

Currently available settings include:
- Toggle for Dark Mode
- Writing Speed Multiplier (slider)
- Bug Count (number input)
-  Troop and Worker Ratio (slider)
-  Left Click to Open Menu
-  Emoji toggle

Settings are saved via `localStorage` and persist across sessions.
There's also a hidden Easter Egg...

https://discord.com/channels/1284581928254701718/1286741605310533653/1355900228712009908
<img width="787" alt="スクリーンショット 2025-03-31 8 40 08"
src="https://github.com/user-attachments/assets/a9943834-cf40-4fa6-b828-06a8476172da"
/>

Fixes #482 

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

<DISCORD USERNAME>
aotumuri
This commit is contained in:
Aotumuri
2025-04-15 03:47:50 +09:00
committed by GitHub
parent c4c4920b5b
commit 3eccbaa982
11 changed files with 752 additions and 1 deletions
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="svg4" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" viewBox="0 0 800 800">
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
<defs>
<style>
.st0 {
stroke-width: 125px;
}
.st0, .st1 {
fill: none;
stroke: #fff;
stroke-miterlimit: 10;
}
.st1 {
stroke-dasharray: 98 98;
stroke-width: 100px;
}
</style>
</defs>
<sodipodi:namedview id="namedview6" bordercolor="#000000" borderopacity="0.25" inkscape:current-layer="svg4" inkscape:cx="401.69492" inkscape:cy="400" inkscape:deskcolor="#d1d1d1" inkscape:pagecheckerboard="0" inkscape:pageopacity="0.0" inkscape:showpageshadow="2" inkscape:window-height="987" inkscape:window-maximized="1" inkscape:window-width="1536" inkscape:window-x="0" inkscape:window-y="0" inkscape:zoom="0.295" pagecolor="#ffffff" showgrid="false"/>
<circle class="st1" cx="400" cy="400" r="249.7"/>
<ellipse class="st0" cx="400" cy="400" rx="159.6" ry="164.9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -215,6 +215,7 @@ export class ClientGameRunner {
public start() {
consolex.log("starting client game");
this.isActive = true;
this.lastMessageTime = Date.now();
setTimeout(() => {
+36
View File
@@ -22,6 +22,7 @@ import { LanguageModal } from "./LanguageModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
import { generateCryptoRandomUUID } from "./Utils";
@@ -118,6 +119,14 @@ class Client {
hlpModal.open();
});
const settingsModal = document.querySelector(
"user-setting",
) as UserSettingModal;
settingsModal instanceof UserSettingModal;
document.getElementById("settings-button").addEventListener("click", () => {
settingsModal.open();
});
const hostModal = document.querySelector(
"host-lobby-modal",
) as HostPrivateLobbyModal;
@@ -200,6 +209,33 @@ class Client {
gameRecord: lobby.gameRecord,
},
() => {
console.log("Closing modals");
document.getElementById("settings-button").classList.add("hidden");
[
"single-player-modal",
"host-lobby-modal",
"join-private-lobby-modal",
"game-starting-modal",
"top-bar",
"help-modal",
"user-setting",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
isModalOpen?: boolean;
};
if (modal?.close) {
modal.close();
} else if ("isModalOpen" in modal) {
modal.isModalOpen = false;
}
});
this.publicLobby.stop();
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
// show when the game loads
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
+226
View File
@@ -0,0 +1,226 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingNumber";
import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
@customElement("user-setting")
export class UserSettingModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
@state() private darkMode: boolean = this.userSettings.darkMode();
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
isModalOpen: boolean;
};
createRenderRoot() {
return this;
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
document.body.style.overflow = "auto";
}
private handleKeyDown = (e: KeyboardEvent) => {
if (!this.modalEl?.isModalOpen || this.showEasterEggSettings) return;
const key = e.key.toLowerCase();
const nextSequence = [...this.keySequence, key].slice(-4);
this.keySequence = nextSequence;
if (nextSequence.join("") === "evan") {
this.triggerEasterEgg();
this.keySequence = [];
}
};
private triggerEasterEgg() {
console.log("🪺 Setting~ unlocked by EVAN combo!");
this.showEasterEggSettings = true;
const popup = document.createElement("div");
popup.className = "easter-egg-popup";
popup.textContent = "🎉 You found a secret setting!";
document.body.appendChild(popup);
setTimeout(() => {
popup.remove();
}, 5000);
}
toggleDarkMode(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") {
console.warn("Unexpected toggle event payload", e);
return;
}
this.userSettings.set("settings.darkMode", enabled);
if (enabled) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
console.log("🌙 Dark Mode:", enabled ? "ON" : "OFF");
}
private toggleEmojis(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.emojis", enabled);
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
}
private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.leftClickOpensMenu", enabled);
console.log("🖱️ Left Click Opens Menu:", enabled ? "ON" : "OFF");
this.requestUpdate();
}
private sliderAttackRatio(e: CustomEvent<{ value: number }>) {
const value = e.detail?.value;
if (typeof value === "number") {
const ratio = value / 100;
localStorage.setItem("settings.attackRatio", ratio.toString());
} else {
console.warn("Slider event missing detail.value", e);
}
}
private sliderTroopRatio(e: CustomEvent<{ value: number }>) {
const value = e.detail?.value;
if (typeof value === "number") {
const ratio = value / 100;
localStorage.setItem("settings.troopRatio", ratio.toString());
} else {
console.warn("Slider event missing detail.value", e);
}
}
render() {
return html`
<o-modal title="User Settings">
<div class="modal-overlay">
<div class="modal-content user-setting-modal">
<div class="settings-list">
<setting-toggle
label="🌙 Dark Mode"
description="Toggle the sites appearance between light and dark themes"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleDarkMode(e)}
></setting-toggle>
<setting-toggle
label="😊 Emojis"
description="Toggle whether emojis are shown in game"
id="emoji-toggle"
.checked=${this.userSettings.emojis()}
@change=${this.toggleEmojis}
></setting-toggle>
<setting-toggle
label="🖱️ Left Click to Open Menu"
description="When ON, left-click opens menu and sword button attacks. When OFF, right-click attacks directly."
id="left-click-toggle"
.checked=${this.userSettings.leftClickOpensMenu()}
@change=${this.toggleLeftClickOpensMenu}
></setting-toggle>
<setting-slider
label="⚔️ Attack Ratio"
description="What percentage of your troops to send in an attack (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
) * 100}
@change=${this.sliderAttackRatio}
></setting-slider>
<setting-slider
label="🪖🛠️ Troops and Workers Ratio"
description="Adjust the balance between troops (for combat) and workers (for gold production) (1100%)"
min="1"
max="100"
.value=${Number(
localStorage.getItem("settings.troopRatio") ?? "0.95",
) * 100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
label="Writing Speed Multiplier"
description="Adjust how fast you pretend to code (x1x100)"
min="0"
max="100"
value="40"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-slider>
<setting-number
label="Bug Count"
description="How many bugs you're okay with (01000, emotionally)"
value="100"
min="0"
max="1000"
easter="true"
@change=${(e: CustomEvent) => {
const value = e.detail?.value;
if (typeof value !== "undefined") {
console.log("Changed:", value);
} else {
console.warn("Slider event missing detail.value", e);
}
}}
></setting-number>
`
: null}
</div>
</div>
</div>
</o-modal>
`;
}
public open() {
this.modalEl?.open();
}
public close() {
this.modalEl?.close();
}
}
@@ -0,0 +1,52 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("setting-number")
export class SettingNumber extends LitElement {
@property() label = "Setting";
@property() description = "";
@property({ type: Number }) value = 0;
@property({ type: Number }) min = 0;
@property({ type: Number }) max = 100;
@property({ type: Boolean }) easter = false;
createRenderRoot() {
return this;
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
const newValue = Number(input.value);
this.value = newValue;
this.dispatchEvent(
new CustomEvent("change", {
detail: { value: newValue },
bubbles: true,
composed: true,
}),
);
}
render() {
return html`
<div class="setting-item${this.easter ? " easter-egg" : ""}">
<div class="setting-label-group">
<label class="setting-label" for="setting-number-input"
>${this.label}</label
>
<div class="setting-description">${this.description}</div>
</div>
<input
type="number"
id="setting-number-input"
class="setting-input number"
.value=${String(this.value ?? 0)}
min=${this.min}
max=${this.max}
@input=${this.handleInput}
/>
</div>
`;
}
}
@@ -0,0 +1,76 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("setting-slider")
export class SettingSlider extends LitElement {
@property() label = "Setting";
@property() description = "";
@property({ type: Number }) value = 0;
@property({ type: Number }) min = 0;
@property({ type: Number }) max = 100;
@property({ type: Boolean }) easter = false;
createRenderRoot() {
return this;
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.value = Number(input.value);
this.updateSliderStyle(input);
this.dispatchEvent(
new CustomEvent("change", {
detail: { value: this.value },
bubbles: true,
composed: true,
}),
);
}
private handleSliderChange(e: Event) {
const detail = (e as CustomEvent)?.detail;
if (!detail || typeof detail.value === "undefined") {
console.warn("Invalid slider change event", e);
return;
}
const value = detail.value;
console.log("Slider changed to", value);
}
private updateSliderStyle(slider: HTMLInputElement) {
const percent = ((this.value - this.min) / (this.max - this.min)) * 100;
slider.style.background = `linear-gradient(to right, #2196f3 ${percent}%, #444 ${percent}%)`;
}
firstUpdated() {
const slider = this.renderRoot.querySelector(
"input[type=range]",
) as HTMLInputElement;
if (slider) this.updateSliderStyle(slider);
}
render() {
return html`
<div class="setting-item vertical${this.easter ? " easter-egg" : ""}">
<div class="setting-label-group">
<label class="setting-label" for="setting-slider-input"
>${this.label}</label
>
<div class="setting-description">${this.description}</div>
</div>
<input
type="range"
id="setting-slider-input"
class="setting-input slider full-width"
min=${this.min}
max=${this.max}
.value=${String(this.value)}
@input=${this.handleInput}
/>
<div class="slider-value">${this.value}%</div>
</div>
`;
}
}
@@ -0,0 +1,47 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("setting-toggle")
export class SettingToggle extends LitElement {
@property() label = "Setting";
@property() description = "";
@property() id = "";
@property({ type: Boolean, reflect: true }) checked = false;
@property({ type: Boolean }) easter = false;
createRenderRoot() {
return this;
}
private handleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.checked = input.checked;
this.dispatchEvent(
new CustomEvent("change", {
detail: { checked: this.checked },
bubbles: true,
composed: true,
}),
);
}
render() {
return html`
<div class="setting-item vertical${this.easter ? " easter-egg" : ""}">
<div class="toggle-row">
<label class="setting-label" for=${this.id}>${this.label}</label>
<label class="switch">
<input
type="checkbox"
id=${this.id}
?checked=${this.checked}
@change=${this.handleChange}
/>
<span class="slider-round"></span>
</label>
</div>
<div class="setting-description">${this.description}</div>
</div>
`;
}
}
+16 -1
View File
@@ -56,8 +56,16 @@ export class ControlPanel extends LitElement implements Layer {
private _popRateIsIncreasing: boolean = true;
private init_: boolean = false;
init() {
this.attackRatio = 0.2;
this.attackRatio = Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
);
this.targetTroopRatio = Number(
localStorage.getItem("settings.troopRatio") ?? "0.95",
);
this.init_ = true;
this.uiState.attackRatio = this.attackRatio;
this.currentTroopRatio = this.targetTroopRatio;
this.eventBus.on(AttackRatioEvent, (event) => {
@@ -87,6 +95,13 @@ export class ControlPanel extends LitElement implements Layer {
}
tick() {
if (this.init_) {
this.eventBus.emit(
new SendSetTargetTroopRatioEvent(this.targetTroopRatio),
);
this.init_ = false;
}
if (!this._isVisible && !this.game.inSpawnPhase()) {
this.setVisibile(true);
}
+15
View File
@@ -279,6 +279,20 @@
</div>
</main>
<!-- User Setting -->
<button
id="settings-button"
title="Settings"
class="fixed bottom-4 right-4 z-50 rounded-full p-2 shadow-lg transition-colors duration-300 flex items-center justify-center"
style="width: 80px; height: 80px; background-color: #0075ff"
>
<img
src="../../resources/images/SettingIconWhite.svg"
alt="Settings"
style="width: 72px; height: 72px"
/>
</button>
<!-- Game components -->
<div id="customMenu" class="mt-4 sm:mt-6 lg:mt-8">
<ul></ul>
@@ -353,6 +367,7 @@
<player-panel></player-panel>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<user-setting></user-setting>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
+1
View File
@@ -8,6 +8,7 @@
@import url("./styles/layout/container.css");
@import url("./styles/components/button.css");
@import url("./styles/components/modal.css");
@import url("./styles/components/setting.css");
@import url("./styles/components/controls.css");
* {
-webkit-box-sizing: border-box;
+257
View File
@@ -0,0 +1,257 @@
.settings-list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
align-items: center;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 10px;
padding: 12px 20px;
width: 360px !important;
max-width: 360px !important;
min-width: 360px !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
transition: background 0.3s ease;
gap: 12px;
}
@keyframes rainbow-background {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.setting-item.easter-egg {
background: linear-gradient(
270deg,
#990033,
#996600,
#336600,
#008080,
#1c3f99,
#5e0099,
#990033
);
background-size: 1400% 1400%;
animation: rainbow-background 10s ease infinite;
color: #fff;
}
.easter-egg-popup {
position: fixed;
top: 40px;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
padding: 16px 24px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 20px;
border-radius: 12px;
animation: fadePop 5s ease-out forwards;
z-index: 9999;
}
@keyframes fadePop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
30% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.05);
}
70% {
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
}
.setting-item:hover {
background: #2a2a2a;
}
.setting-item.easter-egg:hover {
background: linear-gradient(
270deg,
#990033,
#996600,
#336600,
#008080,
#1c3f99,
#5e0099,
#990033
);
background-size: 1400% 1400%;
animation: rainbow-background 10s ease infinite;
color: #fff;
}
.setting-label {
color: #f0f0f0;
font-size: 15px;
font-weight: 500;
}
.setting-input {
margin-left: 16px;
flex-shrink: 0;
}
.setting-item.vertical {
flex-direction: column;
align-items: stretch;
gap: 8px;
overflow: hidden;
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.slider-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.setting-input.slider.full-width {
width: 90%;
}
.setting-input.slider {
-webkit-appearance: none;
width: 180px;
height: 10px;
background: linear-gradient(to right, #2196f3 50%, #444 50%);
border-radius: 5px;
outline: none;
transition: background 0.3s;
}
.setting-input.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 2px solid #2196f3;
cursor: pointer;
}
.setting-input.slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 2px solid #2196f3;
cursor: pointer;
}
.setting-input.slider::-moz-range-track {
background: linear-gradient(to right, #2196f3 50%, #444 50%);
height: 10px;
border-radius: 5px;
}
.setting-input.slider:focus {
outline: none;
}
.slider-value {
width: 100%;
text-align: center;
font-size: 13px;
color: #aaa;
}
.setting-input.number {
width: 80px;
padding: 6px 8px;
border: 1px solid #aaa;
border-radius: 6px;
background-color: #ffffff;
color: #000000;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch.switch-right {
display: block;
margin-left: auto;
}
.slider-round {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #d9534f;
transition: 0.4s;
border-radius: 34px;
}
.slider-round::before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
.switch input:checked + .slider-round {
background-color: #4caf50;
}
.switch input:checked + .slider-round::before {
transform: translateX(24px);
}
.setting-label-group {
display: flex;
flex-direction: column;
}
.setting-description {
font-size: 12px;
color: #888;
margin-top: 2px;
white-space: normal;
word-break: break-word;
}