chore: adjust player tank's size
This commit is contained in:
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user