feat(catan): enhance AI trading logic and add UI tooltips/fullscreen support
- Implement intelligent AI trade initiation (proposeTrade) and response logic (respondToTrade) that adjusts willingness based on the requester's victory points. - Add hover tooltips for harbors, Longest Road, and Largest Army cards to improve user understanding of game mechanics. - Add a generic info tooltip system for UI elements. - Add fullscreen toggle button to Landing and Game Menu scenes. - Fix AI trade animation and status messages for better feedback.
This commit is contained in:
parent
4198dd5757
commit
1a7decfa0e
|
|
@ -6,6 +6,7 @@ import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoa
|
|||
import {
|
||||
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
|
||||
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
|
||||
victoryPoints, WIN_VP,
|
||||
} from './CatanLogic.js';
|
||||
|
||||
// Value of a vertex = production potential of its adjacent hexes + diversity.
|
||||
|
|
@ -272,21 +273,82 @@ function chooseHelpfulDev(state, seat, citySettlements, settleSpots) {
|
|||
return null;
|
||||
}
|
||||
|
||||
// ── AI trade initiation ─────────────────────────────────────────────────────────
|
||||
// Propose a 1-for-1 trade that moves `seat` toward its highest objective:
|
||||
// it gives `give` (a surplus resource) and wants `get` (a needed resource). Or null.
|
||||
export function proposeTrade(state, seat, exclude = new Set()) {
|
||||
const p = state.players[seat];
|
||||
const have = p.resources;
|
||||
const target = p.settlements.length ? COSTS.city : COSTS.settlement;
|
||||
if (canAfford(p, target)) return null; // nothing to trade for — just build
|
||||
const missing = RESOURCE_TYPES.filter((r) => (target[r] || 0) > have[r] && !exclude.has(r));
|
||||
if (!missing.length) return null;
|
||||
// Most-needed missing resource.
|
||||
const get = missing.sort((a, b) => ((target[b] || 0) - have[b]) - ((target[a] || 0) - have[a]))[0];
|
||||
// Give the resource we have the biggest surplus of (beyond what the target needs).
|
||||
let give = null, bestSpare = 0;
|
||||
for (const g of RESOURCE_TYPES) {
|
||||
if (g === get) continue;
|
||||
const spare = have[g] - (target[g] || 0);
|
||||
if (spare >= 1 && spare > bestSpare) { bestSpare = spare; give = g; }
|
||||
}
|
||||
if (!give) return null;
|
||||
return { give, get };
|
||||
}
|
||||
|
||||
// ── player↔AI trade evaluation ─────────────────────────────────────────────────
|
||||
// Decide whether `seat` (AI) accepts a trade where it GIVES `give` and GETS `get`.
|
||||
export function respondToTrade(state, seat, give, get) {
|
||||
// Willingness tightens as the *requester's* visible VP grows.
|
||||
export function respondToTrade(state, seat, give, get, requesterSeat = 0) {
|
||||
const p = state.players[seat];
|
||||
// Must be able to afford what it gives.
|
||||
for (const r of RESOURCE_TYPES) if ((give[r] || 0) > p.resources[r]) return false;
|
||||
const giveCount = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0), 0);
|
||||
const getCount = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0), 0);
|
||||
if (getCount === 0) return false;
|
||||
|
||||
// Value resources by how much they unblock our best target.
|
||||
const reqVP = publicVictoryPoints(state, requesterSeat);
|
||||
if (reqVP >= 8) return tradeWinsGame(state, seat, give, get); // very unwilling
|
||||
if (reqVP >= 6) return tradeBeneficial(state, seat, give, get); // beneficial-only
|
||||
return baseAccept(state, seat, give, get); // existing logic
|
||||
}
|
||||
|
||||
// 0–5 VP: original value heuristic.
|
||||
function baseAccept(state, seat, give, get) {
|
||||
const p = state.players[seat];
|
||||
const giveCount = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0), 0);
|
||||
const getCount = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0), 0);
|
||||
const target = p.settlements.length ? COSTS.city : COSTS.settlement;
|
||||
const need = (r) => Math.max(0, (target[r] || 0) - p.resources[r]);
|
||||
const valIn = RESOURCE_TYPES.reduce((s, r) => s + (get[r] || 0) * (1 + need(r)), 0);
|
||||
const valOut = RESOURCE_TYPES.reduce((s, r) => s + (give[r] || 0) * (1 + need(r) * 0.5), 0);
|
||||
// Accept if we gain value and it isn't badly lopsided in card count.
|
||||
return valIn >= valOut && getCount >= giveCount - 1;
|
||||
}
|
||||
|
||||
// 6–7 VP: only accept trades that clearly benefit us and cost us nothing we need.
|
||||
function tradeBeneficial(state, seat, give, get) {
|
||||
const p = state.players[seat];
|
||||
const have = p.resources;
|
||||
const target = p.settlements.length ? COSTS.city : COSTS.settlement;
|
||||
const need = (r) => Math.max(0, (target[r] || 0) - have[r]);
|
||||
const prod = productionByResource(state, seat);
|
||||
// Benefit: an incoming resource we need for our target, or one we can't produce.
|
||||
const benefit = RESOURCE_TYPES.some((r) => (get[r] || 0) > 0 && (need(r) > 0 || prod[r] === 0));
|
||||
// Not harmful: every outgoing resource stays at/above what our target needs.
|
||||
const notHarmful = RESOURCE_TYPES.every((r) => have[r] - (give[r] || 0) >= need(r));
|
||||
return benefit && notHarmful;
|
||||
}
|
||||
|
||||
// 8+ VP: only accept if the trade directly lets us build a game-winning piece.
|
||||
function tradeWinsGame(state, seat, give, get) {
|
||||
const p = state.players[seat];
|
||||
const have2 = { ...p.resources };
|
||||
for (const r of RESOURCE_TYPES) {
|
||||
have2[r] -= (give[r] || 0);
|
||||
have2[r] += (get[r] || 0);
|
||||
}
|
||||
const afford2 = (cost) => RESOURCE_TYPES.every((r) => have2[r] >= (cost[r] || 0));
|
||||
const ownVP = victoryPoints(state, seat);
|
||||
if (p.settlements.length && afford2(COSTS.city) && ownVP + 1 >= WIN_VP) return true;
|
||||
if (legalSettlementNodes(state, seat, false).length && afford2(COSTS.settlement) && ownVP + 1 >= WIN_VP) return true;
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,17 @@ export default class CatanGame extends Phaser.Scene {
|
|||
const sub = port.type === 'any' ? '' : RESOURCE_INFO[port.type].label[0];
|
||||
c.add(this.add.text(0, -4, label, { fontFamily: 'Righteous', fontSize: '13px', color: '#2a2118' }).setOrigin(0.5));
|
||||
if (sub) c.add(this.add.text(0, 8, sub, { fontFamily: 'Righteous', fontSize: '11px', color: '#8a5a18' }).setOrigin(0.5));
|
||||
|
||||
const title = port.type === 'any'
|
||||
? '3:1 Harbor'
|
||||
: `2:1 ${RESOURCE_INFO[port.type].label} Harbor`;
|
||||
const desc = port.type === 'any'
|
||||
? 'Trade 3 of any single resource for 1 resource of your choice. Works with brick, lumber, wool, grain, or ore.'
|
||||
: `Trade 2 ${RESOURCE_INFO[port.type].label.toLowerCase()} for 1 resource of your choice. Only ${RESOURCE_INFO[port.type].label.toLowerCase()} qualifies for this rate.`;
|
||||
c.setInteractive(new Phaser.Geom.Circle(0, 0, 19), Phaser.Geom.Circle.Contains);
|
||||
c.on('pointerover', () => this.showInfoTooltip(px, py - 19, title, desc, 0xb89a5e));
|
||||
c.on('pointerout', () => this.hideInfoTooltip());
|
||||
|
||||
// little jetties to the two coastal nodes
|
||||
const jg = this.add.graphics().setDepth(D.port - 1);
|
||||
jg.lineStyle(3, 0x6b4a1a, 0.8);
|
||||
|
|
@ -394,6 +405,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
new Button(this, 90, 60, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 120, height: 42, fontSize: 18 }).setDepth(D.hud);
|
||||
|
||||
this.buildDevCardTooltip();
|
||||
this.buildInfoTooltip();
|
||||
this._buildTurnTriangle();
|
||||
this._buildSpecialCards();
|
||||
}
|
||||
|
|
@ -413,17 +425,38 @@ export default class CatanGame extends Phaser.Scene {
|
|||
return c;
|
||||
};
|
||||
|
||||
this._lrCard = makeCard(1783, 0, 0xdaa520);
|
||||
this._laCard = makeCard(1847, 1, 0xb03030);
|
||||
this._lrCard = makeCard(1775, 0, 0xdaa520);
|
||||
this._laCard = makeCard(1855, 1, 0xb03030);
|
||||
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
|
||||
this._specialCardAnimating = { longestRoad: false, largestArmy: false };
|
||||
|
||||
const H = 90;
|
||||
const attachHover = (card, title, desc, borderColor) => {
|
||||
card.setInteractive(
|
||||
new Phaser.Geom.Rectangle(-32, -H / 2, 64, H),
|
||||
Phaser.Geom.Rectangle.Contains,
|
||||
);
|
||||
card.on('pointerover', () =>
|
||||
this.showInfoTooltip(card.x, card.y - (H / 2) * card.scaleY, title, desc, borderColor));
|
||||
card.on('pointerout', () => this.hideInfoTooltip());
|
||||
};
|
||||
attachHover(
|
||||
this._lrCard, 'Longest Road',
|
||||
'Held by the player with the longest unbroken road of 5 or more segments. Worth 2 Victory Points. Lost to any player who later builds a longer road.',
|
||||
0xdaa520,
|
||||
);
|
||||
attachHover(
|
||||
this._laCard, 'Largest Army',
|
||||
'Held by the first player to play 3 Knight cards. Worth 2 Victory Points. Lost to any player who later plays more Knights.',
|
||||
0xb03030,
|
||||
);
|
||||
}
|
||||
|
||||
_getSpecialCardPos(cardType, owner) {
|
||||
if (owner === null) {
|
||||
return cardType === 'longestRoad'
|
||||
? { x: 1783, y: 760, scale: 1 }
|
||||
: { x: 1847, y: 760, scale: 1 };
|
||||
? { x: 1775, y: 760, scale: 1 }
|
||||
: { x: 1855, y: 760, scale: 1 };
|
||||
}
|
||||
if (owner === 0) {
|
||||
return cardType === 'longestRoad'
|
||||
|
|
@ -571,6 +604,48 @@ export default class CatanGame extends Phaser.Scene {
|
|||
this._devTooltip?.setPosition(-9999, -9999);
|
||||
}
|
||||
|
||||
// Generic auto-sizing hover tooltip (title + wrapped description) used by the
|
||||
// Longest Road / Largest Army cards and the harbor markers.
|
||||
buildInfoTooltip() {
|
||||
const g = this.add.graphics();
|
||||
const titleTxt = this.add.text(0, 0, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#f2ead8', align: 'center',
|
||||
}).setOrigin(0.5, 0);
|
||||
const descTxt = this.add.text(0, 0, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
align: 'center', wordWrap: { width: 288 },
|
||||
}).setOrigin(0.5, 0);
|
||||
this._infoTooltip = this.add.container(-9999, -9999, [g, titleTxt, descTxt])
|
||||
.setDepth(D.panel + 5);
|
||||
this._infoTooltip.gfx = g;
|
||||
this._infoTooltip.titleTxt = titleTxt;
|
||||
this._infoTooltip.descTxt = descTxt;
|
||||
}
|
||||
|
||||
showInfoTooltip(anchorX, anchorTopY, title, desc, borderColor = COLORS.accent) {
|
||||
const tt = this._infoTooltip;
|
||||
const { gfx: g, titleTxt, descTxt } = tt;
|
||||
const popW = 320, padTop = 14, gap = 8, padBot = 14;
|
||||
titleTxt.setText(title);
|
||||
descTxt.setText(desc);
|
||||
const popH = padTop + titleTxt.height + gap + descTxt.height + padBot;
|
||||
const top = -popH / 2;
|
||||
titleTxt.setPosition(0, top + padTop);
|
||||
descTxt.setPosition(0, top + padTop + titleTxt.height + gap);
|
||||
g.clear();
|
||||
g.fillStyle(0x0d1117, 0.96);
|
||||
g.fillRoundedRect(-popW / 2, top, popW, popH, 10);
|
||||
g.lineStyle(2, borderColor, 0.9);
|
||||
g.strokeRoundedRect(-popW / 2, top, popW, popH, 10);
|
||||
const x = Phaser.Math.Clamp(anchorX, popW / 2 + 10, GAME_WIDTH - popW / 2 - 10);
|
||||
const y = Phaser.Math.Clamp(anchorTopY - popH / 2 - 12, popH / 2 + 10, GAME_HEIGHT - popH / 2 - 10);
|
||||
tt.setPosition(x, y);
|
||||
}
|
||||
|
||||
hideInfoTooltip() {
|
||||
this._infoTooltip?.setPosition(-9999, -9999);
|
||||
}
|
||||
|
||||
buildCostLegend() {
|
||||
const panelRight = 1900;
|
||||
const panelW = 320;
|
||||
|
|
@ -1553,6 +1628,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
async aiAction() {
|
||||
this.busy = true;
|
||||
const seat = this.gs.currentPlayer;
|
||||
await this.aiInitiateTrades(seat);
|
||||
let steps = 0;
|
||||
while (this.gs.phase === 'action' && steps++ < 60) {
|
||||
const a = AI.chooseAction(this.gs, seat);
|
||||
|
|
@ -1617,6 +1693,130 @@ export default class CatanGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// ── AI-initiated trades ───────────────────────────────────────────────────────
|
||||
// Up to 2 per turn. Each 1-for-1 offer goes to the human first (if they hold the
|
||||
// requested resource); on deny/no-hold it falls through to the other AIs.
|
||||
async aiInitiateTrades(seat) {
|
||||
if (this.gs.phase !== 'action') return;
|
||||
const exclude = new Set();
|
||||
for (let n = 0; n < 2; n++) {
|
||||
const offer = AI.proposeTrade(this.gs, seat, exclude);
|
||||
if (!offer) break;
|
||||
const { give, get } = offer; // requester gives `give`, wants `get`
|
||||
exclude.add(get);
|
||||
|
||||
let done = false;
|
||||
if (this.gs.players[0].resources[get] > 0) {
|
||||
const accepted = await this.promptHumanTrade(seat, give, get);
|
||||
if (accepted) {
|
||||
this.gs = L.executePlayerTrade(this.gs, seat, 0, { [give]: 1 }, { [get]: 1 });
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.flashStatus(`You traded ${RESOURCE_INFO[get].label} for ${RESOURCE_INFO[give].label} with ${this.pname(seat)}.`);
|
||||
this.renderAll();
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
let acc = null;
|
||||
for (let s = 1; s < this.gs.playerCount; s++) {
|
||||
if (s === seat) continue;
|
||||
// Accepter `s` gives the requested resource and gets the offered one.
|
||||
if (AI.respondToTrade(this.gs, s, { [get]: 1 }, { [give]: 1 }, seat)) { acc = s; break; }
|
||||
}
|
||||
if (acc != null) {
|
||||
this.gs = L.executePlayerTrade(this.gs, seat, acc, { [give]: 1 }, { [get]: 1 });
|
||||
await this.animateAiTrade(seat, acc, give, get);
|
||||
this.flashStatus(`${this.pname(seat)} traded ${RESOURCE_INFO[give].label} for ${RESOURCE_INFO[get].label} with ${this.pname(acc)}.`);
|
||||
this.renderAll();
|
||||
}
|
||||
}
|
||||
await this.delay(300);
|
||||
}
|
||||
}
|
||||
|
||||
// Show the "Trade Request" popup to the human. Resolves true on Accept, false on Deny.
|
||||
promptHumanTrade(requesterSeat, giveRes, getRes) {
|
||||
return new Promise((resolve) => {
|
||||
const panel = this.modalPanel(470, 'Trade Request');
|
||||
const cardW = 96, cardH = 134;
|
||||
const objs = panel.objs;
|
||||
|
||||
objs.push(this.add.text(1000, 360, `${this.pname(requesterSeat)} wants to trade`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.panel + 1));
|
||||
|
||||
const card = (x, label, res) => {
|
||||
objs.push(this.add.text(x, 410, label, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.panel + 1));
|
||||
objs.push(this.add.image(x, 490, 'catan-cards', RESOURCE_TYPES.indexOf(res))
|
||||
.setDisplaySize(cardW, cardH).setDepth(D.panel + 1));
|
||||
objs.push(this.add.text(x, 568, RESOURCE_INFO[res].label, {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.panel + 1));
|
||||
};
|
||||
card(890, 'You get', giveRes);
|
||||
card(1110, 'You give', getRes);
|
||||
|
||||
const arrowColor = 0xffdd00;
|
||||
const baseY = 490, amp = 12;
|
||||
const drawArrow = (x, dir) => {
|
||||
const g = this.add.graphics({ x, y: baseY }).setDepth(D.panel + 2);
|
||||
g.fillStyle(arrowColor, 1);
|
||||
if (dir === 'down') {
|
||||
g.fillRect(-5, -22, 10, 30);
|
||||
g.fillTriangle(-14, 6, 14, 6, 0, 26);
|
||||
} else {
|
||||
g.fillRect(-5, -8, 10, 30);
|
||||
g.fillTriangle(-14, -6, 14, -6, 0, -26);
|
||||
}
|
||||
objs.push(g);
|
||||
return g;
|
||||
};
|
||||
const getArrow = drawArrow(812, 'down'); // left of the card you receive
|
||||
const giveArrow = drawArrow(1188, 'up'); // right of the card you give
|
||||
|
||||
const t1 = this.tweens.add({ targets: getArrow, y: baseY + amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||
const t2 = this.tweens.add({ targets: giveArrow, y: baseY - amp, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||
objs.push({ destroy: () => { t1.stop(); t2.stop(); } });
|
||||
|
||||
objs.push(new Button(this, 905, 640, 'Accept', () => { panel.destroy(); resolve(true); },
|
||||
{ width: 170, height: 50, fontSize: 20 }).setDepth(D.panel + 1));
|
||||
objs.push(new Button(this, 1095, 640, 'Deny', () => { panel.destroy(); resolve(false); },
|
||||
{ variant: 'ghost', width: 170, height: 50, fontSize: 20 }).setDepth(D.panel + 1));
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
});
|
||||
}
|
||||
|
||||
// Two cards crossing between the two seats' portraits.
|
||||
animateAiTrade(fromSeat, toSeat, giveRes, getRes) {
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
const from = this._seatPortraitPos(fromSeat) ?? { x: 130, y: 300 };
|
||||
const to = this._seatPortraitPos(toSeat) ?? { x: 130, y: 300 };
|
||||
const cardW = 56, cardH = 78;
|
||||
|
||||
const flyCard = (res, src, dst) => new Promise((resolve) => {
|
||||
const img = this.add.image(0, 0, 'catan-cards', RESOURCE_TYPES.indexOf(res)).setDisplaySize(cardW, cardH);
|
||||
const border = this.add.graphics();
|
||||
border.lineStyle(3, RESOURCE_INFO[res]?.swatch ?? COLORS.accent, 1);
|
||||
border.strokeRoundedRect(-cardW / 2, -cardH / 2, cardW, cardH, 5);
|
||||
const container = this.add.container(src.x, src.y, [img, border]).setDepth(D.banner + 3);
|
||||
this.tweens.add({
|
||||
targets: container, x: dst.x, y: dst.y, duration: 700, ease: 'Quad.InOut',
|
||||
onComplete: () => this.tweens.add({
|
||||
targets: container, alpha: 0, duration: 220,
|
||||
onComplete: () => { container.destroy(); resolve(); },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all([
|
||||
flyCard(giveRes, from, to),
|
||||
flyCard(getRes, to, from),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── opponent dev card reveal ──────────────────────────────────────────────────
|
||||
async animateOppDevCardPlay(seat, cardType) {
|
||||
const VISUAL = {
|
||||
|
|
@ -1914,7 +2114,7 @@ export default class CatanGame extends Phaser.Scene {
|
|||
let accepted = null;
|
||||
for (let seat = 1; seat < this.gs.playerCount; seat++) {
|
||||
// AI gives `get` (what we want), receives `give` (what we offer).
|
||||
if (AI.respondToTrade(this.gs, seat, get, give)) { accepted = seat; break; }
|
||||
if (AI.respondToTrade(this.gs, seat, get, give, 0)) { accepted = seat; break; }
|
||||
}
|
||||
if (accepted == null) { this.flashStatus('No opponent accepted that offer.'); return; }
|
||||
close();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
export default class GameMenuScene extends Phaser.Scene {
|
||||
|
|
@ -12,6 +13,7 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
|
||||
addFullscreenButton(this);
|
||||
|
||||
const titleText = this.add.text(cx, 120, 'Choose a game', {
|
||||
fontFamily: 'Righteous',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
export default class LandingScene extends Phaser.Scene {
|
||||
|
|
@ -36,6 +37,7 @@ export default class LandingScene extends Phaser.Scene {
|
|||
});
|
||||
|
||||
this.renderButtons();
|
||||
addFullscreenButton(this);
|
||||
|
||||
if (this._authUnsub) this._authUnsub();
|
||||
this._authUnsub = auth.subscribe(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { GAME_WIDTH } from '../config.js';
|
||||
import { Button } from './Button.js';
|
||||
|
||||
export function addFullscreenButton(scene) {
|
||||
const width = 300;
|
||||
return new Button(
|
||||
scene,
|
||||
GAME_WIDTH - width / 2 - 30,
|
||||
52,
|
||||
'Toggle Fullscreen',
|
||||
() => scene.scale.toggleFullscreen(),
|
||||
{ width, height: 52, fontSize: 22, variant: 'ghost' },
|
||||
).setDepth(100);
|
||||
}
|
||||
Loading…
Reference in New Issue