chore: adjust player tank's size
This commit is contained in:
@@ -197,7 +197,7 @@ const PVP_BASE_HP = 5; // base hit points for 1v1 PVP mode
|
||||
// Server Configuration
|
||||
// ============================================================
|
||||
// const SERVER_URL = 'ws://192.168.1.103:3000'; // local testing server URL, replace with actual server URL in production
|
||||
const SERVER_URL = 'wss://www.igeek.site/games/wx/tankwar';
|
||||
const SERVER_URL = 'wss://game.igeek.site/tankwar/ws';
|
||||
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -45,6 +45,7 @@ class PlayerTank extends Tank {
|
||||
|
||||
// Skin colors (reserved for future use)
|
||||
this._skinColors = null;
|
||||
this._skinId = 'default';
|
||||
|
||||
// Fire level system
|
||||
this.fireLevel = FIRE_LEVEL.LV1;
|
||||
|
||||
+24
-2
@@ -13,6 +13,7 @@ const {
|
||||
DIRECTION,
|
||||
DIR_VECTORS,
|
||||
} = require('../base/GameGlobal');
|
||||
const { drawTankSkin, DESIGN_HALF_SIZE } = require('./TankSkinRenderer');
|
||||
|
||||
class Tank {
|
||||
/**
|
||||
@@ -288,9 +289,30 @@ class Tank {
|
||||
};
|
||||
ctx.rotate(angles[this.direction]);
|
||||
|
||||
const hs = this.halfSize;
|
||||
// ★ Unified skin path — any tank with a skin id uses the SAME drawing
|
||||
// code as the SkinScene preview. Scale to match the actual tank size.
|
||||
// Clip laterally to the collision box so wide tracks / decorations
|
||||
// don't make the tank look wider than non-skinned tanks. Leave the
|
||||
// top/bottom un-clipped so the barrel can extend naturally (same as
|
||||
// legacy rendering).
|
||||
if (this._skinId) {
|
||||
const t = (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
|
||||
const k = this.halfSize / DESIGN_HALF_SIZE;
|
||||
// Clip: lateral bounds = collision box; vertical = generous to allow barrel
|
||||
const barrelExtra = this.size * 0.55; // same as legacy barrelH
|
||||
ctx.beginPath();
|
||||
ctx.rect(-this.halfSize, -this.halfSize - barrelExtra, this.size, this.size + barrelExtra * 2);
|
||||
ctx.clip();
|
||||
ctx.save();
|
||||
ctx.scale(k, k);
|
||||
drawTankSkin(ctx, this._skinId, this._skinColors, t);
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine colors: use skin colors if this is a player tank with a skin
|
||||
// ── Legacy fallback for tanks without a skin id (enemy AI, etc.) ──
|
||||
const hs = this.halfSize;
|
||||
let bodyColor = this.color;
|
||||
let turretColor = this._darkenColor(this.color, 0.3);
|
||||
let trackColor = this._darkenColor(this.color, 0.4);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -20,6 +20,7 @@ module.exports = {
|
||||
// Menu Scene
|
||||
// ============================================================
|
||||
'menu.title': 'Tank Adventure',
|
||||
'profile.welcome': 'Welcome {name}!',
|
||||
'menu.subtitle': 'TANK WAR',
|
||||
'menu.classic': 'Classic',
|
||||
'menu.endless': 'Endless',
|
||||
@@ -205,6 +206,7 @@ module.exports = {
|
||||
'settings.sound': 'Sound',
|
||||
'settings.music': 'Music',
|
||||
'settings.vibration': 'Vibration',
|
||||
'settings.nickname': 'Display Name',
|
||||
|
||||
// ============================================================
|
||||
// Shop Scene (Simplified)
|
||||
@@ -281,8 +283,11 @@ module.exports = {
|
||||
'skin.phantom': 'Phantom',
|
||||
'skin.jungle': 'Jungle',
|
||||
'skin.neon': 'Neon',
|
||||
'skin.shadow': 'Shadow',
|
||||
'skin.nebula': 'Nebula',
|
||||
'skin.royal': 'Royal',
|
||||
'skin.sakura': 'Sakura',
|
||||
'skin.thunder': 'Thunder',
|
||||
'skin.diamond': 'Diamond',
|
||||
'skin.equipped': '✓ Equipped',
|
||||
'skin.owned': 'Owned',
|
||||
'skin.equipSuccess': '✓ Skin equipped!',
|
||||
|
||||
+6
-1
@@ -20,6 +20,7 @@ module.exports = {
|
||||
// Menu Scene
|
||||
// ============================================================
|
||||
'menu.title': '坦克探险',
|
||||
'profile.welcome': '欢迎 {name}!',
|
||||
'menu.subtitle': '经典坦克对战',
|
||||
'menu.classic': '经典模式',
|
||||
'menu.endless': '无尽模式',
|
||||
@@ -205,6 +206,7 @@ module.exports = {
|
||||
'settings.sound': '音效',
|
||||
'settings.music': '音乐',
|
||||
'settings.vibration': '振动',
|
||||
'settings.nickname': '显示名字',
|
||||
|
||||
// ============================================================
|
||||
// Shop Scene (Simplified)
|
||||
@@ -281,8 +283,11 @@ module.exports = {
|
||||
'skin.phantom': '幻影',
|
||||
'skin.jungle': '丛林',
|
||||
'skin.neon': '霓虹',
|
||||
'skin.shadow': '暗影',
|
||||
'skin.nebula': '星云',
|
||||
'skin.royal': '皇家',
|
||||
'skin.sakura': '樱花',
|
||||
'skin.thunder': '雷电',
|
||||
'skin.diamond': '钻石',
|
||||
'skin.equipped': '✓ 使用中',
|
||||
'skin.owned': '已拥有',
|
||||
'skin.equipSuccess': '✓ 已装备!',
|
||||
|
||||
@@ -51,9 +51,10 @@ class NetworkManager {
|
||||
/**
|
||||
* Connect to the WebSocket server.
|
||||
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws').
|
||||
* @param {number} [timeoutMs=10000] - Connect timeout in milliseconds.
|
||||
* @returns {Promise<boolean>} Whether connection succeeded.
|
||||
*/
|
||||
connect(serverUrl) {
|
||||
connect(serverUrl, timeoutMs = 10000) {
|
||||
return new Promise((resolve) => {
|
||||
if (this._connected || this._connecting) {
|
||||
resolve(this._connected);
|
||||
@@ -64,20 +65,53 @@ class NetworkManager {
|
||||
this._connecting = true;
|
||||
this._shouldReconnect = true;
|
||||
|
||||
// Guard: make sure resolve is called exactly once.
|
||||
let settled = false;
|
||||
const finish = (ok, reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
}
|
||||
if (!ok) {
|
||||
// Tear down broken socket so next connect() starts clean.
|
||||
this._connecting = false;
|
||||
this._shouldReconnect = false; // a first-time failure should NOT auto-reconnect
|
||||
if (this._ws) {
|
||||
try { this._ws.close({}); } catch (e) { /* ignore */ }
|
||||
this._ws = null;
|
||||
}
|
||||
console.warn('[NetworkManager] connect() failed:', reason || 'unknown');
|
||||
}
|
||||
resolve(ok);
|
||||
};
|
||||
|
||||
// Connection timeout guard (e.g. DNS/TLS hang on cellular).
|
||||
let connectTimer = setTimeout(() => {
|
||||
finish(false, `connect timeout after ${timeoutMs}ms, url=${serverUrl}`);
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
this._ws = wx.connectSocket({
|
||||
url: serverUrl,
|
||||
header: { 'content-type': 'application/json' },
|
||||
// Surface wx.connectSocket API-level failures (invalid url / domain not whitelisted / etc.)
|
||||
success: (res) => { console.log('[NetworkManager] wx.connectSocket invoked:', res && res.errMsg); },
|
||||
fail: (err) => {
|
||||
console.error('[NetworkManager] wx.connectSocket API failed:', JSON.stringify(err));
|
||||
finish(false, `wx.connectSocket fail: ${err && err.errMsg}`);
|
||||
},
|
||||
});
|
||||
|
||||
this._ws.onOpen(() => {
|
||||
console.log('[NetworkManager] Connected to server');
|
||||
console.log('[NetworkManager] Connected to server:', serverUrl);
|
||||
this._connected = true;
|
||||
this._connecting = false;
|
||||
this._reconnectAttempts = 0;
|
||||
this._startHeartbeat();
|
||||
this._emit('connected');
|
||||
resolve(true);
|
||||
finish(true);
|
||||
});
|
||||
|
||||
this._ws.onMessage((res) => {
|
||||
@@ -85,28 +119,45 @@ class NetworkManager {
|
||||
});
|
||||
|
||||
this._ws.onError((err) => {
|
||||
console.error('[NetworkManager] WebSocket error:', err);
|
||||
this._connecting = false;
|
||||
// Log as much context as possible; wx error objects vary across platforms.
|
||||
console.error('[NetworkManager] WebSocket error:',
|
||||
(err && (err.errMsg || err.message)) || err,
|
||||
'url=', serverUrl);
|
||||
this._emit('error', err);
|
||||
resolve(false);
|
||||
// If the error arrives before we ever got onOpen, treat it as a connect failure.
|
||||
if (!this._connected) {
|
||||
finish(false, `onError before open: ${err && (err.errMsg || err.message)}`);
|
||||
} else {
|
||||
// Runtime error on an established connection — let onClose handle reconnection.
|
||||
this._connecting = false;
|
||||
}
|
||||
});
|
||||
|
||||
this._ws.onClose((res) => {
|
||||
console.log('[NetworkManager] Connection closed:', res.code, res.reason);
|
||||
const code = res && res.code;
|
||||
const reason = res && res.reason;
|
||||
console.log('[NetworkManager] Connection closed:', code, reason, 'url=', serverUrl);
|
||||
|
||||
const wasConnected = this._connected;
|
||||
this._connected = false;
|
||||
this._connecting = false;
|
||||
this._stopHeartbeat();
|
||||
this._emit('disconnected', { code: res.code, reason: res.reason });
|
||||
this._emit('disconnected', { code, reason });
|
||||
|
||||
// Auto-reconnect if needed
|
||||
// If onClose arrives before onOpen, this is a connect failure.
|
||||
if (!wasConnected) {
|
||||
finish(false, `onClose before open: code=${code} reason=${reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-reconnect only for drops on an already-established connection.
|
||||
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
|
||||
this._attemptReconnect();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[NetworkManager] Failed to create WebSocket:', e);
|
||||
this._connecting = false;
|
||||
resolve(false);
|
||||
finish(false, `exception: ${e && e.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -145,10 +196,17 @@ class NetworkManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always include the player's current nickname (if any) so the server
|
||||
// can propagate it to other clients. Falls back silently when the
|
||||
// profile is not yet available.
|
||||
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
|
||||
const nickname = (profile && profile.nickname) ? profile.nickname : '';
|
||||
|
||||
const message = JSON.stringify({
|
||||
type,
|
||||
data,
|
||||
playerId: this._playerId,
|
||||
nickname,
|
||||
roomId: this._roomId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -514,6 +572,12 @@ class NetworkManager {
|
||||
return this._playerId;
|
||||
}
|
||||
|
||||
/** Player display nickname (may be empty until profile is fetched). */
|
||||
get nickname() {
|
||||
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
|
||||
return (profile && profile.nickname) ? profile.nickname : '';
|
||||
}
|
||||
|
||||
/** Current latency in ms. */
|
||||
get latency() {
|
||||
return this._latency;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* PlayerProfile.js
|
||||
* Manages the player's public profile (nickname + avatar url) for the game.
|
||||
*
|
||||
* Acquisition strategy (WeChat mini-game, compliant, 2024+):
|
||||
* 1. Try to read nickname from local storage cache (`playerProfile`).
|
||||
* 2. If absent, try `wx.getUserInfo({ withCredentials: false })` — this does NOT
|
||||
* require an authorization popup since 2022 and returns an anonymous
|
||||
* "微信用户" placeholder + default avatar. That is acceptable as the
|
||||
* silent fallback.
|
||||
* 3. The caller (MenuScene) can additionally create a `UserInfoButton`
|
||||
* and register a click handler via `bindUserInfoButton()` below to
|
||||
* upgrade the placeholder into the real nickname when the user taps
|
||||
* the overlay button.
|
||||
*
|
||||
* Display helpers:
|
||||
* - `getDisplayName()` — returns a "safe" display string (nickname if set,
|
||||
* otherwise "坦克手_XXXX" derived from playerId).
|
||||
* - `truncate(name, n)` — truncate at n Chinese-equivalent characters.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'playerProfile';
|
||||
|
||||
class PlayerProfile {
|
||||
constructor() {
|
||||
/** @type {string} Real WeChat nickname, '' if not yet granted. */
|
||||
this._nickname = '';
|
||||
/** @type {string} Avatar URL, '' if not yet granted. */
|
||||
this._avatarUrl = '';
|
||||
/** @type {boolean} Whether we have attempted a silent fetch at least once. */
|
||||
this._silentFetched = false;
|
||||
/** @type {boolean} Whether the real (non-anonymous) nickname has been granted. */
|
||||
this._granted = false;
|
||||
|
||||
this._loadFromCache();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/** @returns {string} the cached nickname, or '' if never set. */
|
||||
get nickname() {
|
||||
return this._nickname;
|
||||
}
|
||||
|
||||
/** @returns {string} avatar URL or '' */
|
||||
get avatarUrl() {
|
||||
return this._avatarUrl;
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether the user has granted the real nickname. */
|
||||
get granted() {
|
||||
return this._granted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a safe display name for UI rendering.
|
||||
* Order of preference: real nickname > anonymous from last silent fetch
|
||||
* > deterministic "Tanker_XXXX" fallback based on playerId.
|
||||
* @param {string} [playerIdFallback] - optional player id for deterministic fallback.
|
||||
* @returns {string}
|
||||
*/
|
||||
getDisplayName(playerIdFallback) {
|
||||
if (this._nickname) return this._nickname;
|
||||
if (playerIdFallback && typeof playerIdFallback === 'string') {
|
||||
// Use the last 4 chars of playerId for a stable anonymous tag.
|
||||
const tail = playerIdFallback.slice(-4).toUpperCase();
|
||||
return `Tanker_${tail}`;
|
||||
}
|
||||
return 'Tanker';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a display name to at most `maxChineseChars` Chinese-equivalent chars.
|
||||
* A Chinese char counts as 1; a latin char counts as 0.5.
|
||||
* @param {string} name
|
||||
* @param {number} [maxChineseChars=4]
|
||||
* @returns {string}
|
||||
*/
|
||||
truncate(name, maxChineseChars = 4) {
|
||||
if (!name) return '';
|
||||
let widthBudget = maxChineseChars * 2; // in half-width units
|
||||
let out = '';
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
const ch = name.charAt(i);
|
||||
const code = name.charCodeAt(i);
|
||||
// Treat CJK + Full-width chars as 2 half-width units, others as 1.
|
||||
const w = code > 0x7f ? 2 : 1;
|
||||
if (widthBudget - w < 0) {
|
||||
return out + '..';
|
||||
}
|
||||
widthBudget -= w;
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch the anonymous nickname silently.
|
||||
* In modern WeChat this resolves with a placeholder user ("微信用户") but
|
||||
* without popping any authorization UI. Safe to call on MenuScene enter.
|
||||
* @returns {Promise<boolean>} true if something was set.
|
||||
*/
|
||||
fetchSilent() {
|
||||
return new Promise((resolve) => {
|
||||
if (this._granted || this._silentFetched) {
|
||||
resolve(!!this._nickname);
|
||||
return;
|
||||
}
|
||||
this._silentFetched = true;
|
||||
|
||||
try {
|
||||
if (typeof wx === 'undefined' || typeof wx.getUserInfo !== 'function') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
wx.getUserInfo({
|
||||
withCredentials: false,
|
||||
success: (res) => {
|
||||
const u = res && res.userInfo;
|
||||
if (u && u.nickName) {
|
||||
// NOTE: Since WeChat base library 2.27+, wx.getUserInfo returns
|
||||
// an anonymous placeholder ("微信用户") on real devices, but the
|
||||
// devtools environment may still return the real nickname — we
|
||||
// MUST NOT promote it to "granted" here, otherwise the cached
|
||||
// granted=true would prevent the UserInfoButton from ever being
|
||||
// created on real devices. The only source of truth for a real,
|
||||
// granted nickname is `applyUserInfoResult` (button tap).
|
||||
if (!this._nickname) {
|
||||
this._nickname = u.nickName;
|
||||
this._avatarUrl = u.avatarUrl || '';
|
||||
this._saveToCache();
|
||||
}
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(false);
|
||||
},
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[PlayerProfile] fetchSilent error:', e && e.message);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the well-known WeChat anonymous placeholder. Since 2022-10,
|
||||
* `wx.getUserInfo` / `UserInfoButton` return this string for any user who
|
||||
* has not explicitly granted profile access — it must NOT be promoted to
|
||||
* the "granted" state.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPlaceholderName(name) {
|
||||
if (!name) return true;
|
||||
return name === '微信用户' || name === 'WeChat User' || name === 'Weixin User';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the result of a `UserInfoButton` tap. Should be wired in by
|
||||
* whichever scene created the button (usually MenuScene).
|
||||
* @param {object} res - the `res` payload passed into the button's onTap callback.
|
||||
* @returns {boolean} true if a REAL (non-placeholder) nickname was stored.
|
||||
*/
|
||||
applyUserInfoResult(res) {
|
||||
const u = res && res.userInfo;
|
||||
if (!u || !u.nickName) return false;
|
||||
|
||||
// Reject WeChat's anonymous placeholder — it's what `getUserInfo` now
|
||||
// returns for non-granted users and we must not mark that as "granted".
|
||||
if (this.isPlaceholderName(u.nickName)) {
|
||||
console.log('[PlayerProfile] Ignored placeholder nickname from UserInfoButton.');
|
||||
return false;
|
||||
}
|
||||
|
||||
this._nickname = u.nickName;
|
||||
this._avatarUrl = u.avatarUrl || '';
|
||||
this._granted = true;
|
||||
this._saveToCache();
|
||||
console.log('[PlayerProfile] Nickname granted:', this._nickname);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active authorization flow for WeChat mini-GAMES.
|
||||
*
|
||||
* Since 2022-10 `wx.createUserInfoButton` silently returns the "微信用户"
|
||||
* placeholder, so the ONLY API that still pops a real authorization UI and
|
||||
* returns the user's actual WeChat nickname on small-game runtimes is
|
||||
* `wx.getUserProfile`. This call MUST be triggered directly by a user tap
|
||||
* (a touchend / click handler) — not by any async continuation — or WeChat
|
||||
* will reject it with `fail api scope is not declared in the privacy agreement`.
|
||||
*
|
||||
* @returns {Promise<boolean>} true iff a real nickname was granted.
|
||||
*/
|
||||
requestUserProfile() {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
if (typeof wx === 'undefined' || typeof wx.getUserProfile !== 'function') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
wx.getUserProfile({
|
||||
desc: '用于在对战中展示你的昵称',
|
||||
lang: 'zh_CN',
|
||||
success: (res) => {
|
||||
const u = res && res.userInfo;
|
||||
if (!u || !u.nickName || this.isPlaceholderName(u.nickName)) {
|
||||
console.log('[PlayerProfile] getUserProfile returned placeholder.');
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
this._nickname = u.nickName;
|
||||
this._avatarUrl = u.avatarUrl || '';
|
||||
this._granted = true;
|
||||
this._saveToCache();
|
||||
console.log('[PlayerProfile] Nickname granted via getUserProfile:', this._nickname);
|
||||
resolve(true);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log('[PlayerProfile] getUserProfile fail:', err && err.errMsg);
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[PlayerProfile] requestUserProfile error:', e && e.message);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual nickname fallback — used when `getUserProfile` is unavailable or
|
||||
* denied (e.g. user refused, API deprecated). Stores the user-typed name
|
||||
* and marks the profile as "granted" so the button stops prompting.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
setManualNickname(name) {
|
||||
if (!name || typeof name !== 'string') return false;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || this.isPlaceholderName(trimmed)) return false;
|
||||
this._nickname = trimmed.slice(0, 16); // hard cap to 16 chars
|
||||
this._granted = true;
|
||||
this._saveToCache();
|
||||
console.log('[PlayerProfile] Nickname set manually:', this._nickname);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached profile (e.g. user wants to re-auth).
|
||||
*/
|
||||
reset() {
|
||||
this._nickname = '';
|
||||
this._avatarUrl = '';
|
||||
this._granted = false;
|
||||
try { wx.removeStorageSync(STORAGE_KEY); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private
|
||||
// ============================================================
|
||||
|
||||
_loadFromCache() {
|
||||
try {
|
||||
const raw = wx.getStorageSync(STORAGE_KEY);
|
||||
if (raw && typeof raw === 'object') {
|
||||
this._nickname = raw.nickname || '';
|
||||
this._avatarUrl = raw.avatarUrl || '';
|
||||
this._granted = !!raw.granted;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
_saveToCache() {
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY, {
|
||||
nickname: this._nickname,
|
||||
avatarUrl: this._avatarUrl,
|
||||
granted: this._granted,
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlayerProfile;
|
||||
@@ -4,6 +4,12 @@
|
||||
* Skins are cosmetic-only color schemes purchased with gold.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DEV MODE: Set to true to unlock all skins for testing.
|
||||
* ⚠️ MUST be set to false before publishing!
|
||||
*/
|
||||
const DEV_UNLOCK_ALL = false;
|
||||
|
||||
/** Skin definitions with id, name, cost, and color scheme. */
|
||||
const SKINS = {
|
||||
default: {
|
||||
@@ -45,15 +51,15 @@ const SKINS = {
|
||||
id: 'neon',
|
||||
nameKey: 'skin.neon',
|
||||
cost: 2000,
|
||||
colors: { body: '#00FF7F', turret: '#00CED1', track: '#008B8B' },
|
||||
preview: '#00FF7F',
|
||||
colors: { body: '#FF1493', turret: '#FF6EC7', track: '#C71585' },
|
||||
preview: '#FF1493',
|
||||
},
|
||||
shadow: {
|
||||
id: 'shadow',
|
||||
nameKey: 'skin.shadow',
|
||||
nebula: {
|
||||
id: 'nebula',
|
||||
nameKey: 'skin.nebula',
|
||||
cost: 3000,
|
||||
colors: { body: '#2C2C2C', turret: '#1A1A1A', track: '#0D0D0D' },
|
||||
preview: '#2C2C2C',
|
||||
colors: { body: '#6A0DAD', turret: '#FF00FF', track: '#3D0066' },
|
||||
preview: '#6A0DAD',
|
||||
},
|
||||
royal: {
|
||||
id: 'royal',
|
||||
@@ -62,10 +68,31 @@ const SKINS = {
|
||||
colors: { body: '#FFD700', turret: '#DAA520', track: '#B8860B' },
|
||||
preview: '#FFD700',
|
||||
},
|
||||
sakura: {
|
||||
id: 'sakura',
|
||||
nameKey: 'skin.sakura',
|
||||
cost: 3500,
|
||||
colors: { body: '#FFB7C5', turret: '#FF69B4', track: '#C44D78' },
|
||||
preview: '#FFB7C5',
|
||||
},
|
||||
thunder: {
|
||||
id: 'thunder',
|
||||
nameKey: 'skin.thunder',
|
||||
cost: 4000,
|
||||
colors: { body: '#1E90FF', turret: '#00BFFF', track: '#0A5E9C' },
|
||||
preview: '#1E90FF',
|
||||
},
|
||||
diamond: {
|
||||
id: 'diamond',
|
||||
nameKey: 'skin.diamond',
|
||||
cost: 8000,
|
||||
colors: { body: '#E0F7FF', turret: '#7DF9FF', track: '#5B8FA8' },
|
||||
preview: '#7DF9FF',
|
||||
},
|
||||
};
|
||||
|
||||
/** Ordered list of skin IDs for display. */
|
||||
const SKIN_ORDER = ['default', 'arctic', 'inferno', 'jungle', 'phantom', 'neon', 'shadow', 'royal'];
|
||||
const SKIN_ORDER = ['default', 'arctic', 'inferno', 'jungle', 'phantom', 'neon', 'nebula', 'royal', 'sakura', 'thunder', 'diamond'];
|
||||
|
||||
class SkinManager {
|
||||
constructor() {
|
||||
@@ -131,6 +158,7 @@ class SkinManager {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isUnlocked(skinId) {
|
||||
if (DEV_UNLOCK_ALL) return true;
|
||||
return this._unlocked.has(skinId);
|
||||
}
|
||||
|
||||
@@ -180,6 +208,19 @@ class SkinManager {
|
||||
return { success: false, error: 'Already unlocked' };
|
||||
}
|
||||
|
||||
// Dev mode: unlock for free without spending gold
|
||||
if (DEV_UNLOCK_ALL) {
|
||||
this._unlocked.add(skinId);
|
||||
this._save();
|
||||
console.log(`[SkinManager][DEV] Free unlock skin: ${skinId}`);
|
||||
try {
|
||||
if (GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('skin:purchased', { id: skinId, cost: 0 });
|
||||
}
|
||||
} catch (e) {}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const cm = GameGlobal.currencyManager;
|
||||
if (!cm || !cm.hasGold(skin.cost)) {
|
||||
return { success: false, error: 'Insufficient gold' };
|
||||
@@ -215,7 +256,7 @@ class SkinManager {
|
||||
return { success: false, error: 'Invalid skin' };
|
||||
}
|
||||
|
||||
if (!this._unlocked.has(skinId)) {
|
||||
if (!DEV_UNLOCK_ALL && !this._unlocked.has(skinId)) {
|
||||
return { success: false, error: 'Not unlocked' };
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ const GameScene = {
|
||||
// Apply equipped skin colors to player tank
|
||||
if (GameGlobal.skinManager) {
|
||||
this._playerTank._skinColors = GameGlobal.skinManager.getCurrentSkinColors();
|
||||
this._playerTank._skinId = GameGlobal.skinManager.getEquippedSkinId();
|
||||
}
|
||||
|
||||
// Safety: ensure player spawn area is clear of blocking terrain
|
||||
|
||||
+337
-110
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* MenuScene.js
|
||||
* Main menu scene - displays game title and mode selection buttons.
|
||||
* Main menu scene — military-tech themed UI with game title and mode selection.
|
||||
* Rendered entirely with Canvas API (no DOM).
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,26 @@ const {
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
// ============================================================
|
||||
// Style
|
||||
// ============================================================
|
||||
const MC = {
|
||||
BG_TOP: '#0b0e17',
|
||||
BG_BOT: '#141b2d',
|
||||
ACCENT: '#e94560',
|
||||
GOLD: '#FFD700',
|
||||
GOLD_DIM: '#B8860B',
|
||||
BTN_BG: '#16213e',
|
||||
BTN_BORDER: '#1e3054',
|
||||
BTN_HOVER: '#0f3460',
|
||||
BTN_TEXT: '#E8E8E8',
|
||||
TITLE: '#FFD700',
|
||||
SUBTITLE: '#8899AA',
|
||||
FOOTER: '#445566',
|
||||
TANK_BODY: '#FFD700',
|
||||
TANK_TRACK: '#B8860B',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Button Layout
|
||||
// ============================================================
|
||||
@@ -22,9 +42,7 @@ const BTN_GAP = Math.min(8, SCREEN_HEIGHT * 0.015);
|
||||
const BTN_START_Y = SCREEN_HEIGHT * 0.38;
|
||||
const BTN_X = (SCREEN_WIDTH - BTN_WIDTH) / 2;
|
||||
|
||||
// Half-width buttons for the utility row
|
||||
const HALF_BTN_WIDTH = (BTN_WIDTH - BTN_GAP) / 2;
|
||||
// Third-width buttons for 3-column row
|
||||
const THIRD_BTN_WIDTH = (BTN_WIDTH - BTN_GAP * 2) / 3;
|
||||
|
||||
// Main game mode buttons (full width, vertical)
|
||||
@@ -35,9 +53,9 @@ const MAIN_BUTTONS = [
|
||||
{ labelKey: 'menu.team3v3', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM },
|
||||
];
|
||||
|
||||
// Utility buttons: shop, daily gold, skin, ranking, settings (grid)
|
||||
// Utility buttons: daily gold, skin, ranking, settings (grid)
|
||||
// NOTE: Shop button is temporarily disabled
|
||||
const UTIL_BUTTONS = [
|
||||
{ labelKey: 'menu.shop', mode: null, scene: SCENE.SHOP },
|
||||
{ labelKey: 'dailyGold.btn', mode: null, scene: 'DAILY_GOLD' },
|
||||
{ labelKey: 'menu.skin', mode: null, scene: SCENE.SKIN },
|
||||
{ labelKey: 'menu.ranking', mode: null, scene: SCENE.RANKING },
|
||||
@@ -53,32 +71,20 @@ const mainBtnRects = MAIN_BUTTONS.map((btn, i) => ({
|
||||
...btn,
|
||||
}));
|
||||
|
||||
// Pre-calculate button rects for utility buttons (row1: 3 cols, row2: 2 cols centered)
|
||||
// Pre-calculate button rects for utility buttons (2 rows x 2 cols)
|
||||
const utilStartY = BTN_START_Y + MAIN_BUTTONS.length * (BTN_HEIGHT + BTN_GAP) + BTN_GAP;
|
||||
const utilBtnRects = UTIL_BUTTONS.map((btn, i) => {
|
||||
if (i < 3) {
|
||||
// First row: 3 buttons
|
||||
return {
|
||||
x: BTN_X + i * (THIRD_BTN_WIDTH + BTN_GAP),
|
||||
y: utilStartY,
|
||||
w: THIRD_BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
...btn,
|
||||
};
|
||||
} else {
|
||||
// Second row: 2 buttons centered
|
||||
const col = i - 3;
|
||||
return {
|
||||
x: BTN_X + col * (HALF_BTN_WIDTH + BTN_GAP),
|
||||
y: utilStartY + (BTN_HEIGHT + BTN_GAP),
|
||||
w: HALF_BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
...btn,
|
||||
};
|
||||
}
|
||||
const row = Math.floor(i / 2);
|
||||
const col = i % 2;
|
||||
return {
|
||||
x: BTN_X + col * (HALF_BTN_WIDTH + BTN_GAP),
|
||||
y: utilStartY + row * (BTN_HEIGHT + BTN_GAP),
|
||||
w: HALF_BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
...btn,
|
||||
};
|
||||
});
|
||||
|
||||
// Combined list for unified iteration
|
||||
const buttonRects = [...mainBtnRects, ...utilBtnRects];
|
||||
|
||||
// ============================================================
|
||||
@@ -86,18 +92,20 @@ const buttonRects = [...mainBtnRects, ...utilBtnRects];
|
||||
// ============================================================
|
||||
const MenuScene = {
|
||||
_pressedIndex: -1,
|
||||
_tankAnim: 0, // simple animation timer
|
||||
_tankAnim: 0,
|
||||
|
||||
enter() {
|
||||
this._pressedIndex = -1;
|
||||
this._tankAnim = 0;
|
||||
|
||||
// Auto-navigate to team room if there's a pending invite teamId
|
||||
// Kick off nickname acquisition as early as possible so that later
|
||||
// network messages (CREATE_TEAM, SOLO_MATCH, ...) can carry it.
|
||||
this._initPlayerProfile();
|
||||
|
||||
if (GameGlobal._pendingTeamId) {
|
||||
const teamId = GameGlobal._pendingTeamId;
|
||||
GameGlobal._pendingTeamId = null;
|
||||
console.log(`[MenuScene] Found pendingTeamId: ${teamId}, will auto-navigate to TeamRoomScene`);
|
||||
// Use setTimeout to allow the scene to fully initialize first
|
||||
setTimeout(() => {
|
||||
console.log(`[MenuScene] Auto-navigating to TeamRoomScene with teamId: ${teamId}`);
|
||||
const sm = GameGlobal.sceneManager;
|
||||
@@ -110,140 +118,338 @@ const MenuScene = {
|
||||
}
|
||||
},
|
||||
|
||||
exit() {},
|
||||
exit() {
|
||||
this._pressedIndex = -1;
|
||||
},
|
||||
|
||||
update(dt) {
|
||||
this._tankAnim += dt;
|
||||
},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
// ---- Background ----
|
||||
const bgGrad = ctx.createLinearGradient(0, 0, 0, SCREEN_HEIGHT);
|
||||
bgGrad.addColorStop(0, MC.BG_TOP);
|
||||
bgGrad.addColorStop(1, MC.BG_BOT);
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
// Decorative top bar
|
||||
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
|
||||
gradient.addColorStop(0, '#0f3460');
|
||||
gradient.addColorStop(0.5, '#e94560');
|
||||
gradient.addColorStop(1, '#0f3460');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
|
||||
// Scan-lines
|
||||
ctx.globalAlpha = 0.025;
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
for (let sy = 0; sy < SCREEN_HEIGHT; sy += 4) {
|
||||
ctx.fillRect(0, sy, SCREEN_WIDTH, 1);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Gold balance display at top
|
||||
// Top accent bar
|
||||
const accentGrad = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
|
||||
accentGrad.addColorStop(0, 'transparent');
|
||||
accentGrad.addColorStop(0.3, MC.ACCENT);
|
||||
accentGrad.addColorStop(0.7, MC.ACCENT);
|
||||
accentGrad.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = accentGrad;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, 3);
|
||||
|
||||
// ---- Gold Balance (top-right pill) ----
|
||||
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH - 15, 12);
|
||||
const goldText = `🪙 ${gold}`;
|
||||
ctx.font = 'bold 12px Arial';
|
||||
const gtw = ctx.measureText(goldText).width;
|
||||
const pillW = gtw + 16;
|
||||
const pillH = 22;
|
||||
const pillX = SCREEN_WIDTH - pillW - 12;
|
||||
const pillY = 10;
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 36px Arial';
|
||||
ctx.fillStyle = 'rgba(255, 215, 0, 0.08)';
|
||||
ctx.strokeStyle = 'rgba(255, 215, 0, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
this._roundRect(ctx, pillX, pillY, pillW, pillH, pillH / 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = MC.GOLD;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(goldText, pillX + pillW / 2, pillY + pillH / 2);
|
||||
|
||||
// ---- Title with glow ----
|
||||
ctx.save();
|
||||
ctx.shadowColor = MC.GOLD;
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.fillStyle = MC.TITLE;
|
||||
ctx.font = 'bold 34px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('menu.title'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.15);
|
||||
ctx.restore();
|
||||
|
||||
// Subtitle
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
// ---- Subtitle ----
|
||||
ctx.fillStyle = MC.SUBTITLE;
|
||||
ctx.font = '13px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22);
|
||||
|
||||
// Animated tank icon (simple oscillating triangle)
|
||||
// ---- Animated Tank Icon ----
|
||||
this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30);
|
||||
|
||||
// Main game mode buttons (full width)
|
||||
// ---- Main Buttons ----
|
||||
for (let i = 0; i < mainBtnRects.length; i++) {
|
||||
const btn = mainBtnRects[i];
|
||||
const isPressed = this._pressedIndex === i;
|
||||
|
||||
ctx.fillStyle = isPressed ? '#0f3460' : COLORS.MENU_BTN;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 18px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t(btn.labelKey), btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||||
this._drawMenuButton(ctx, btn, t(btn.labelKey), isPressed, 'bold 16px Arial', 8);
|
||||
}
|
||||
|
||||
// Utility buttons (2x2 grid, smaller font)
|
||||
// ---- Utility Buttons ----
|
||||
for (let i = 0; i < utilBtnRects.length; i++) {
|
||||
const btn = utilBtnRects[i];
|
||||
const globalIdx = mainBtnRects.length + i;
|
||||
const isPressed = this._pressedIndex === globalIdx;
|
||||
|
||||
// Special rendering for daily gold button
|
||||
const isDailyGold = btn.scene === 'DAILY_GOLD';
|
||||
let label = t(btn.labelKey);
|
||||
let btnColor = COLORS.MENU_BTN;
|
||||
let customBg = null;
|
||||
|
||||
if (isDailyGold) {
|
||||
const remaining = GameGlobal.adManager ? GameGlobal.adManager.getDailyGoldRemaining() : 0;
|
||||
if (remaining > 0) {
|
||||
label = `${t('dailyGold.btn')} ${remaining}/3`;
|
||||
btnColor = '#2E7D32'; // green tint
|
||||
customBg = '#1a3a2a';
|
||||
} else {
|
||||
label = t('dailyGold.exhausted') || 'Come back tomorrow';
|
||||
btnColor = '#555555';
|
||||
customBg = '#2a2a2a';
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = isPressed ? '#0f3460' : btnColor;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 6);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||||
this._drawMenuButton(ctx, btn, label, isPressed, 'bold 13px Arial', 6, customBg);
|
||||
}
|
||||
|
||||
// Footer
|
||||
ctx.fillStyle = '#555555';
|
||||
ctx.font = '11px Arial';
|
||||
// ---- Footer ----
|
||||
ctx.fillStyle = MC.FOOTER;
|
||||
ctx.font = '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20);
|
||||
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 18);
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw a simple animated tank icon.
|
||||
*/
|
||||
// ---- Menu Button ----
|
||||
_drawMenuButton(ctx, btn, label, isPressed, font, radius, customBg) {
|
||||
const r = radius || 8;
|
||||
|
||||
// Shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
this._roundRect(ctx, btn.x + 1, btn.y + 2, btn.w, btn.h, r);
|
||||
ctx.fill();
|
||||
|
||||
// Body
|
||||
ctx.fillStyle = isPressed ? MC.BTN_HOVER : (customBg || MC.BTN_BG);
|
||||
ctx.strokeStyle = MC.BTN_BORDER;
|
||||
ctx.lineWidth = 1.5;
|
||||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = isPressed ? MC.TITLE : MC.BTN_TEXT;
|
||||
ctx.font = font || 'bold 16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||||
},
|
||||
|
||||
// ---- Tank Icon ----
|
||||
_drawTankIcon(ctx, cx, cy) {
|
||||
const bounce = Math.sin(this._tankAnim * 3) * 3;
|
||||
const size = 20;
|
||||
const bounce = Math.sin(this._tankAnim * 3) * 2;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(this._tankAnim * 2);
|
||||
const s = 15; // body half-size (square tank: 2s × 2s)
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy + bounce);
|
||||
|
||||
// Tank body
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.fillRect(-size, -size / 2, size * 2, size);
|
||||
// ── 1. OUTER GLOW HALO (breathing golden aura) ──
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.15 + pulse * 0.12;
|
||||
ctx.fillStyle = MC.GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, s * 1.55, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Tank turret
|
||||
ctx.fillRect(-3, -size / 2 - 14, 6, 14);
|
||||
// ── 2. GROUND SHADOW (soft ellipse underneath) ──
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, s + 6, s * 1.1, 3, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Tank tracks
|
||||
ctx.fillStyle = '#B8860B';
|
||||
ctx.fillRect(-size - 4, -size / 2, 4, size);
|
||||
ctx.fillRect(size, -size / 2, 4, size);
|
||||
// ── 3. TRACKS (left & right, with segment pattern) ──
|
||||
const trackW = 5;
|
||||
const trackX = s;
|
||||
// Left track
|
||||
ctx.fillStyle = '#4A3508';
|
||||
this._roundRect(ctx, -trackX - trackW, -s, trackW, s * 2, 1.5);
|
||||
ctx.fill();
|
||||
// Right track
|
||||
this._roundRect(ctx, trackX, -s, trackW, s * 2, 1.5);
|
||||
ctx.fill();
|
||||
// Track top highlight
|
||||
ctx.fillStyle = 'rgba(255, 220, 120, 0.35)';
|
||||
ctx.fillRect(-trackX - trackW + 0.8, -s + 0.8, trackW - 1.6, 1);
|
||||
ctx.fillRect(trackX + 0.8, -s + 0.8, trackW - 1.6, 1);
|
||||
// Track segment lines (metallic plate pattern)
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
ctx.lineWidth = 0.8;
|
||||
for (let ty = -s + 4; ty < s - 1; ty += 4) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-trackX - trackW, ty);
|
||||
ctx.lineTo(-trackX, ty);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(trackX, ty);
|
||||
ctx.lineTo(trackX + trackW, ty);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── 4. BODY (square, with vertical metallic gradient) ──
|
||||
ctx.save();
|
||||
ctx.shadowColor = MC.GOLD;
|
||||
ctx.shadowBlur = 12;
|
||||
const bodyGrad = ctx.createLinearGradient(0, -s, 0, s);
|
||||
bodyGrad.addColorStop(0, '#FFF3A8'); // top highlight
|
||||
bodyGrad.addColorStop(0.3, MC.GOLD); // main gold
|
||||
bodyGrad.addColorStop(0.75, '#C89A1C');
|
||||
bodyGrad.addColorStop(1, '#7A5A0A'); // bottom shadow
|
||||
ctx.fillStyle = bodyGrad;
|
||||
this._roundRect(ctx, -s, -s, s * 2, s * 2, 3);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// ── 5. BODY EDGE OUTLINE ──
|
||||
ctx.strokeStyle = 'rgba(255, 235, 150, 0.7)';
|
||||
ctx.lineWidth = 1;
|
||||
this._roundRect(ctx, -s, -s, s * 2, s * 2, 3);
|
||||
ctx.stroke();
|
||||
|
||||
// ── 6. TOP HIGHLIGHT STRIP (bright metallic sheen) ──
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
|
||||
this._roundRect(ctx, -s + 2, -s + 2, s * 2 - 4, 3, 1.5);
|
||||
ctx.fill();
|
||||
|
||||
// ── 7. ARMOR PLATE DETAIL (X-cross rivets in 4 corners + center crosshair) ──
|
||||
const rivetOffset = s * 0.6;
|
||||
ctx.fillStyle = '#6B4A08';
|
||||
for (const [rx, ry] of [[-rivetOffset, -rivetOffset], [rivetOffset, -rivetOffset],
|
||||
[-rivetOffset, rivetOffset], [rivetOffset, rivetOffset]]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(rx, ry, 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Tiny rivet highlight
|
||||
ctx.fillStyle = 'rgba(255, 240, 180, 0.8)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(rx - 0.3, ry - 0.3, 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#6B4A08';
|
||||
}
|
||||
|
||||
// ── 8. TURRET (diamond-shaped center cap with gradient) ──
|
||||
const turretR = s * 0.42;
|
||||
ctx.save();
|
||||
ctx.shadowColor = MC.GOLD;
|
||||
ctx.shadowBlur = 6;
|
||||
const turretGrad = ctx.createRadialGradient(-turretR * 0.3, -turretR * 0.3, 0, 0, 0, turretR);
|
||||
turretGrad.addColorStop(0, '#FFF3A8');
|
||||
turretGrad.addColorStop(0.5, '#E0B020');
|
||||
turretGrad.addColorStop(1, '#8B6914');
|
||||
ctx.fillStyle = turretGrad;
|
||||
// Diamond (rotated square) for "military hatch" feel
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -turretR);
|
||||
ctx.lineTo(turretR, 0);
|
||||
ctx.lineTo(0, turretR);
|
||||
ctx.lineTo(-turretR, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Turret edge
|
||||
ctx.strokeStyle = 'rgba(255, 235, 150, 0.8)';
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -turretR);
|
||||
ctx.lineTo(turretR, 0);
|
||||
ctx.lineTo(0, turretR);
|
||||
ctx.lineTo(-turretR, 0);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
|
||||
// Turret internal cross
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -turretR); ctx.lineTo(0, turretR);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-turretR, 0); ctx.lineTo(turretR, 0);
|
||||
ctx.stroke();
|
||||
|
||||
// Turret center hatch
|
||||
ctx.fillStyle = '#FFF3A8';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// ── 9. BARREL (thick, with metallic gradient) ──
|
||||
const barrelW = 6;
|
||||
const barrelH = 16;
|
||||
const barrelY = -s - barrelH;
|
||||
ctx.save();
|
||||
const barrelGrad = ctx.createLinearGradient(-barrelW / 2, 0, barrelW / 2, 0);
|
||||
barrelGrad.addColorStop(0, '#6B4A08');
|
||||
barrelGrad.addColorStop(0.3, '#B8860B');
|
||||
barrelGrad.addColorStop(0.5, '#FFF3A8');
|
||||
barrelGrad.addColorStop(0.7, '#B8860B');
|
||||
barrelGrad.addColorStop(1, '#6B4A08');
|
||||
ctx.fillStyle = barrelGrad;
|
||||
this._roundRect(ctx, -barrelW / 2, barrelY, barrelW, barrelH, 1.5);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
// Barrel outline
|
||||
ctx.strokeStyle = 'rgba(255, 235, 150, 0.55)';
|
||||
ctx.lineWidth = 0.6;
|
||||
this._roundRect(ctx, -barrelW / 2, barrelY, barrelW, barrelH, 1.5);
|
||||
ctx.stroke();
|
||||
|
||||
// ── 10. MUZZLE TIP (flared end with glow) ──
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#FFF3A8';
|
||||
ctx.shadowBlur = 5 + pulse * 3;
|
||||
ctx.fillStyle = '#2A1D05';
|
||||
this._roundRect(ctx, -barrelW / 2 - 1, barrelY - 2, barrelW + 2, 3, 1);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
// Muzzle inner bright dot
|
||||
ctx.fillStyle = `rgba(255, 240, 150, ${0.6 + pulse * 0.4})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, barrelY - 0.5, 1, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// ── 11. HEADLIGHTS (front of tank — 2 small glowing dots) ──
|
||||
ctx.fillStyle = `rgba(255, 255, 200, ${0.7 + pulse * 0.3})`;
|
||||
ctx.shadowColor = '#FFF3A8';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(-s * 0.55, -s + 2.5, 1.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(s * 0.55, -s + 2.5, 1.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw a rounded rectangle path.
|
||||
*/
|
||||
// ---- Utility ----
|
||||
_roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
@@ -258,6 +464,30 @@ const MenuScene = {
|
||||
ctx.closePath();
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Player Profile (nickname acquisition)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Kick off profile acquisition on menu enter. Since WeChat 2022-10 there
|
||||
* is NO silent way to get the real nickname — we draw a canvas button and
|
||||
* call `wx.getUserProfile` directly from its touchend handler.
|
||||
* @private
|
||||
*/
|
||||
_initPlayerProfile() {
|
||||
const profile = GameGlobal.playerProfile;
|
||||
if (!profile) return;
|
||||
|
||||
// Best-effort placeholder fetch (used only to pre-fill _nickname with
|
||||
// "微信用户" on older devices; does not mark granted).
|
||||
if (typeof profile.fetchSilent === 'function') {
|
||||
profile.fetchSilent().catch(() => { /* ignore */ });
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Touch
|
||||
// ============================================================
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType === 'touchstart') {
|
||||
const touch = e.touches[0];
|
||||
@@ -276,17 +506,14 @@ const MenuScene = {
|
||||
const btn = buttonRects[this._pressedIndex];
|
||||
this._pressedIndex = -1;
|
||||
|
||||
// Navigate to the target scene
|
||||
const sm = GameGlobal.sceneManager;
|
||||
if (btn.scene === SCENE.GAME) {
|
||||
// Route through BuffSelectScene for PvE modes
|
||||
if (!sm._scenes.has(SCENE.BUFF_SELECT)) {
|
||||
const BuffSelectScene = require('./BuffSelectScene');
|
||||
sm.register(SCENE.BUFF_SELECT, BuffSelectScene);
|
||||
}
|
||||
sm.switchTo(SCENE.BUFF_SELECT, { mode: btn.mode });
|
||||
} else if (btn.scene === 'DAILY_GOLD') {
|
||||
// Handle daily gold ad
|
||||
const adm = GameGlobal.adManager;
|
||||
if (adm && adm.getDailyGoldRemaining() > 0) {
|
||||
adm.showDailyGoldAd((completed) => {
|
||||
|
||||
+138
-13
@@ -49,32 +49,90 @@ const SettingsScene = {
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
const cx = SCREEN_WIDTH / 2;
|
||||
let y = 60;
|
||||
|
||||
// Reset button map each frame so layout changes don't keep stale rects.
|
||||
this._buttons = {};
|
||||
|
||||
// Title
|
||||
const titleY = Math.max(48, SCREEN_HEIGHT * 0.08);
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 28px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('settings.title'), cx, y);
|
||||
ctx.fillText(t('settings.title'), cx, titleY);
|
||||
|
||||
y += 70;
|
||||
// Back button (reserved at bottom so we can layout rows above it).
|
||||
const backH = 42;
|
||||
const backMarginBottom = 28;
|
||||
const backCenterY = SCREEN_HEIGHT - backMarginBottom - backH / 2;
|
||||
|
||||
// Toggle items
|
||||
const toggles = [
|
||||
{ key: 'soundEnabled', label: t('settings.sound'), icon: '🔊' },
|
||||
{ key: 'musicEnabled', label: t('settings.music'), icon: '🎵' },
|
||||
{ key: 'vibrationEnabled', label: t('settings.vibration'), icon: '📳' },
|
||||
// Rows: nickname + 3 toggles. Distribute evenly between title and back btn.
|
||||
const rows = [
|
||||
{ type: 'nickname' },
|
||||
{ type: 'toggle', key: 'soundEnabled', label: t('settings.sound'), icon: '🔊' },
|
||||
{ type: 'toggle', key: 'musicEnabled', label: t('settings.music'), icon: '🎵' },
|
||||
{ type: 'toggle', key: 'vibrationEnabled', label: t('settings.vibration'), icon: '📳' },
|
||||
];
|
||||
const rowH = 50;
|
||||
const topPad = titleY + 36;
|
||||
const bottomPad = backCenterY - backH / 2 - 20;
|
||||
const availH = Math.max(rowH * rows.length, bottomPad - topPad);
|
||||
const step = Math.max(rowH + 8, availH / rows.length);
|
||||
const firstCenterY = topPad + step / 2;
|
||||
|
||||
for (const toggle of toggles) {
|
||||
this._renderToggle(ctx, cx, y, toggle);
|
||||
y += 70;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const cy = firstCenterY + i * step;
|
||||
if (row.type === 'nickname') {
|
||||
this._renderNicknameRow(ctx, cx, cy);
|
||||
} else {
|
||||
this._renderToggle(ctx, cx, cy, row);
|
||||
}
|
||||
}
|
||||
|
||||
// Back button
|
||||
y = SCREEN_HEIGHT - 80;
|
||||
this._renderBackButton(ctx, cx, y);
|
||||
this._renderBackButton(ctx, cx, backCenterY);
|
||||
},
|
||||
|
||||
_renderNicknameRow(ctx, cx, y) {
|
||||
const w = SCREEN_WIDTH * 0.7;
|
||||
const h = 50;
|
||||
const x = cx - w / 2;
|
||||
|
||||
this._buttons['nickname'] = { x, y: y - h / 2, w, h };
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#1e1e3a';
|
||||
ctx.fillRect(x, y - h / 2, w, h);
|
||||
ctx.strokeStyle = '#333366';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y - h / 2, w, h);
|
||||
|
||||
// Icon + label (left)
|
||||
ctx.fillStyle = COLORS.HUD_TEXT;
|
||||
ctx.font = '16px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
const leftLabel = `👤 ${t('settings.nickname') || '显示名字'}`;
|
||||
ctx.fillText(leftLabel, x + 15, y);
|
||||
|
||||
// Current value + chevron (right)
|
||||
const profile = GameGlobal.playerProfile;
|
||||
let shown = '';
|
||||
if (profile) {
|
||||
if (profile.granted && profile.nickname) {
|
||||
shown = profile.truncate ? profile.truncate(profile.nickname, 5) : profile.nickname;
|
||||
} else if (typeof profile.getDisplayName === 'function') {
|
||||
const pid = (GameGlobal.networkManager && GameGlobal.networkManager.playerId) || '';
|
||||
shown = profile.getDisplayName(pid);
|
||||
}
|
||||
}
|
||||
if (!shown) shown = 'Tanker';
|
||||
|
||||
ctx.fillStyle = profile && profile.granted ? '#FFD700' : '#8899AA';
|
||||
ctx.font = '13px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${shown} ›`, x + w - 15, y);
|
||||
},
|
||||
|
||||
_renderToggle(ctx, cx, y, toggle) {
|
||||
@@ -153,6 +211,10 @@ const SettingsScene = {
|
||||
if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) {
|
||||
if (key === 'back') {
|
||||
GameGlobal.sceneManager.switchTo(SCENE.MENU);
|
||||
} else if (key === 'nickname') {
|
||||
// IMPORTANT: wx.getUserProfile must be called synchronously from a
|
||||
// user tap handler; invoking it here is fine (touchstart is a tap).
|
||||
this._requestNicknameAuth();
|
||||
} else if (this._settings.hasOwnProperty(key)) {
|
||||
this._settings[key] = !this._settings[key];
|
||||
// Notify audio system
|
||||
@@ -162,6 +224,69 @@ const SettingsScene = {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Nickname acquisition (moved from MenuScene)
|
||||
// ============================================================
|
||||
_requestNicknameAuth() {
|
||||
const profile = GameGlobal.playerProfile;
|
||||
if (!profile) return;
|
||||
|
||||
const onDone = (ok) => {
|
||||
if (ok) {
|
||||
try {
|
||||
wx.showToast({
|
||||
title: `欢迎 ${profile.nickname}`,
|
||||
icon: 'none',
|
||||
duration: 1500,
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof profile.requestUserProfile === 'function') {
|
||||
profile.requestUserProfile().then((ok) => {
|
||||
if (ok) {
|
||||
onDone(true);
|
||||
} else {
|
||||
this._promptManualNickname(onDone);
|
||||
}
|
||||
}).catch(() => this._promptManualNickname(onDone));
|
||||
} else {
|
||||
this._promptManualNickname(onDone);
|
||||
}
|
||||
},
|
||||
|
||||
_promptManualNickname(cb) {
|
||||
try {
|
||||
if (typeof wx === 'undefined' || typeof wx.showModal !== 'function') {
|
||||
cb && cb(false);
|
||||
return;
|
||||
}
|
||||
wx.showModal({
|
||||
title: '设置昵称',
|
||||
content: '输入在对战中显示的名字(最长16字)',
|
||||
editable: true,
|
||||
placeholderText: '例如:坏蹄子',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const profile = GameGlobal.playerProfile;
|
||||
const ok = profile && typeof profile.setManualNickname === 'function'
|
||||
&& profile.setManualNickname(res.content || '');
|
||||
cb && cb(!!ok);
|
||||
} else {
|
||||
cb && cb(false);
|
||||
}
|
||||
},
|
||||
fail: () => cb && cb(false),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Settings] showModal failed:', e && e.message);
|
||||
cb && cb(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = SettingsScene;
|
||||
|
||||
+1481
-163
File diff suppressed because it is too large
Load Diff
+97
-10
@@ -240,6 +240,11 @@ const TeamGameScene = {
|
||||
tank.color = tankColor;
|
||||
// Unlimited lives for 3v3
|
||||
tank.lives = 999;
|
||||
// Apply equipped skin (only for the LOCAL player — other players keep team color)
|
||||
if (GameGlobal.skinManager && isLocal) {
|
||||
tank._skinColors = GameGlobal.skinManager.getCurrentSkinColors();
|
||||
tank._skinId = GameGlobal.skinManager.getEquippedSkinId();
|
||||
}
|
||||
}
|
||||
|
||||
tank.activateShield(3000);
|
||||
@@ -256,6 +261,7 @@ const TeamGameScene = {
|
||||
|
||||
const playerData = {
|
||||
playerId: member.playerId,
|
||||
nickname: member.nickname || '',
|
||||
tank,
|
||||
isBot,
|
||||
team,
|
||||
@@ -441,6 +447,29 @@ const TeamGameScene = {
|
||||
}
|
||||
}));
|
||||
|
||||
// Receive live team roster updates — keeps every tank's overhead label in
|
||||
// sync with the real WeChat nickname, which may be granted AFTER the match
|
||||
// has already started (via MenuScene's UserInfoButton).
|
||||
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
|
||||
if (!data) return;
|
||||
const rosterA = Array.isArray(data.teamA) ? data.teamA : [];
|
||||
const rosterB = Array.isArray(data.teamB) ? data.teamB : [];
|
||||
const byId = Object.create(null);
|
||||
for (const m of rosterA) if (m && m.playerId) byId[m.playerId] = m.nickname || '';
|
||||
for (const m of rosterB) if (m && m.playerId) byId[m.playerId] = m.nickname || '';
|
||||
let changed = false;
|
||||
for (const p of this._players) {
|
||||
const nn = byId[p.playerId];
|
||||
if (nn && p.nickname !== nn) {
|
||||
p.nickname = nn;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
console.log('[TeamGameScene] Roster nicknames refreshed.');
|
||||
}
|
||||
}));
|
||||
|
||||
this._unsubscribers = unsubs;
|
||||
},
|
||||
|
||||
@@ -1126,6 +1155,7 @@ const TeamGameScene = {
|
||||
stats: this._stats,
|
||||
players: this._players.map(p => ({
|
||||
playerId: p.playerId,
|
||||
nickname: p.nickname || '',
|
||||
team: p.team,
|
||||
isBot: p.isBot,
|
||||
isLocal: p.isLocal,
|
||||
@@ -1154,16 +1184,41 @@ const TeamGameScene = {
|
||||
if (player.tank.alive && !player.isRespawning) {
|
||||
player.tank.render(ctx);
|
||||
|
||||
// Draw team indicator above tank
|
||||
if (!player.isLocal) {
|
||||
const tx = player.tank.x;
|
||||
const ty = player.tank.y - player.tank.halfSize - 8;
|
||||
ctx.fillStyle = player.team === this._myTeam ? TEAM_A_COLOR : TEAM_B_COLOR;
|
||||
ctx.font = 'bold 8px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const label = player.isBot ? '🤖' : (player.team === this._myTeam ? '▲' : '▼');
|
||||
ctx.fillText(label, tx, ty);
|
||||
// Name & team indicator above the tank
|
||||
const tx = player.tank.x;
|
||||
const labelY = player.tank.y - player.tank.halfSize - 4;
|
||||
const nameY = labelY - 10;
|
||||
|
||||
// Per-tank team color:
|
||||
// - local player → gold
|
||||
// - ally (not me) → blue
|
||||
// - enemy → red
|
||||
let labelColor;
|
||||
if (player.isLocal) labelColor = LOCAL_PLAYER_COLOR;
|
||||
else if (player.team === this._myTeam) labelColor = TEAM_A_COLOR;
|
||||
else labelColor = TEAM_B_COLOR;
|
||||
|
||||
ctx.fillStyle = labelColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Arrow / bot tag
|
||||
ctx.font = 'bold 8px Arial';
|
||||
let marker;
|
||||
if (player.isLocal) marker = '★';
|
||||
else if (player.isBot) marker = '🤖';
|
||||
else marker = (player.team === this._myTeam) ? '▲' : '▼';
|
||||
ctx.fillText(marker, tx, labelY);
|
||||
|
||||
// Nickname (truncated to 4 Chinese-equivalent chars)
|
||||
const name = this._getTankLabel(player);
|
||||
if (name) {
|
||||
ctx.font = 'bold 9px Arial';
|
||||
// Outline for readability on busy backgrounds
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.strokeText(name, tx, nameY);
|
||||
ctx.fillText(name, tx, nameY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1220,6 +1275,38 @@ const TeamGameScene = {
|
||||
return { kills, deaths };
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute a short label (≤ 4 Chinese-equivalent chars) to draw above a tank.
|
||||
* Uses real WeChat nickname if available, otherwise a stable fallback.
|
||||
* @private
|
||||
*/
|
||||
_getTankLabel(player) {
|
||||
if (!player) return '';
|
||||
const profile = GameGlobal.playerProfile;
|
||||
let raw = '';
|
||||
if (player.isLocal) {
|
||||
// For local player prefer the freshest profile nickname if granted.
|
||||
if (profile && profile.nickname) raw = profile.nickname;
|
||||
else raw = player.nickname || '';
|
||||
} else {
|
||||
raw = player.nickname || '';
|
||||
}
|
||||
if (!raw) {
|
||||
if (player.isBot) {
|
||||
raw = ''; // bot — we already draw the 🤖 marker, skip name
|
||||
} else if (profile && typeof profile.getDisplayName === 'function') {
|
||||
raw = profile.getDisplayName(player.playerId);
|
||||
} else {
|
||||
raw = player.playerId || '';
|
||||
}
|
||||
}
|
||||
if (!raw) return '';
|
||||
if (profile && typeof profile.truncate === 'function') {
|
||||
return profile.truncate(raw, 4);
|
||||
}
|
||||
return raw.length > 8 ? raw.substring(0, 8) + '..' : raw;
|
||||
},
|
||||
|
||||
_renderHUD(ctx) {
|
||||
const hudY = 4;
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ const TeamResultScene = {
|
||||
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
|
||||
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId);
|
||||
const name = player.isBot ? t('teamResult.bot') : this._getDisplayName(player);
|
||||
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
|
||||
|
||||
// Stats
|
||||
@@ -406,7 +406,7 @@ const TeamResultScene = {
|
||||
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
|
||||
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId);
|
||||
const name = player.isBot ? t('teamResult.bot') : this._getDisplayName(player);
|
||||
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
|
||||
|
||||
// Stats
|
||||
@@ -451,7 +451,7 @@ const TeamResultScene = {
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 11px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
const mvpName = mvp.isBot ? t('teamResult.bot').replace('🤖 ', '') : mvp.playerId;
|
||||
const mvpName = mvp.isBot ? t('teamResult.bot').replace('🤖 ', '') : this._getDisplayName(mvp);
|
||||
ctx.fillText(t('teamResult.mvp', { name: mvpName, kills: maxKills }), CENTER_X, y);
|
||||
}
|
||||
|
||||
@@ -583,6 +583,32 @@ const TeamResultScene = {
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute a display name for the results table (≤ 4 CJK chars).
|
||||
* @private
|
||||
*/
|
||||
_getDisplayName(player) {
|
||||
if (!player) return '';
|
||||
const profile = GameGlobal.playerProfile;
|
||||
let raw = '';
|
||||
if (player.isLocal && profile && profile.nickname) {
|
||||
raw = profile.nickname;
|
||||
} else {
|
||||
raw = player.nickname || '';
|
||||
}
|
||||
if (!raw) {
|
||||
if (profile && typeof profile.getDisplayName === 'function') {
|
||||
raw = profile.getDisplayName(player.playerId);
|
||||
} else {
|
||||
raw = player.playerId || '';
|
||||
}
|
||||
}
|
||||
if (profile && typeof profile.truncate === 'function') {
|
||||
return profile.truncate(raw, 4);
|
||||
}
|
||||
return raw.length > 10 ? raw.substring(0, 10) + '..' : raw;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = TeamResultScene;
|
||||
|
||||
@@ -419,7 +419,7 @@ const TeamRoomScene = {
|
||||
// Player name (truncated)
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '10px Arial';
|
||||
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
|
||||
const name = this._getDisplayName(member);
|
||||
ctx.fillText(name, avatarCX, rect.y + rect.h * 0.7);
|
||||
|
||||
// Ready state
|
||||
@@ -488,7 +488,7 @@ const TeamRoomScene = {
|
||||
ctx.font = '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
|
||||
const name = this._getDisplayName(member);
|
||||
ctx.fillText(name, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
}
|
||||
}
|
||||
@@ -575,6 +575,29 @@ const TeamRoomScene = {
|
||||
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute a display name for a team member entry.
|
||||
* Uses real WeChat nickname when available, otherwise a stable fallback.
|
||||
* Truncated to 4 Chinese-equivalent chars to fit the slot UI.
|
||||
* @private
|
||||
*/
|
||||
_getDisplayName(member) {
|
||||
if (!member) return '';
|
||||
const profile = GameGlobal.playerProfile;
|
||||
let raw = member.nickname || '';
|
||||
if (!raw) {
|
||||
if (profile && typeof profile.getDisplayName === 'function') {
|
||||
raw = profile.getDisplayName(member.playerId);
|
||||
} else {
|
||||
raw = member.playerId || '';
|
||||
}
|
||||
}
|
||||
if (profile && typeof profile.truncate === 'function') {
|
||||
return profile.truncate(raw, 4);
|
||||
}
|
||||
return raw.length > 8 ? raw.substring(0, 8) + '..' : raw;
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart') return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user