Files
tankwar_proj/js/scenes/TeamRoomScene.js
T

1016 lines
30 KiB
JavaScript

/**
* TeamRoomScene.js
* 3v3 Team room UI scene.
* Supports team creation, joining, ready state, leader controls,
* matchmaking, and WeChat friend invitation.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
TEAM_SIZE,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Layout Constants
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = 10;
const CENTER_X = SCREEN_WIDTH / 2;
const SLOT_WIDTH = Math.min(SCREEN_WIDTH * 0.15, 80);
const SLOT_HEIGHT = Math.min(SCREEN_HEIGHT * 0.18, 90);
const SLOT_GAP = 8;
// ============================================================
// Team Room States
// ============================================================
const TEAM_STATE = {
MODE_SELECT: 'mode_select', // Choose: create team or solo match
JOINING: 'joining', // Auto-joining a team from invite
FORMING: 'forming', // Team room, waiting for members
MATCHING: 'matching', // In matchmaking queue
COUNTDOWN: 'countdown', // Match found, counting down
ERROR: 'error', // Error state
};
// ============================================================
// Team Room Scene
// ============================================================
const TeamRoomScene = {
_state: TEAM_STATE.MODE_SELECT,
_teamData: null, // { teamId, state, leaderId, teamA, teamB }
_errorMsg: '',
_animTimer: 0,
_matchTimer: 0, // Seconds elapsed in matching
_countdown: 3,
_countdownTimer: 0,
_networkManager: null,
_unsubscribers: [],
_isLeader: false,
_myPlayerId: null,
// Server URL (from global config)
_serverUrl: SERVER_URL,
// Button rects
_createTeamBtnRect: null,
_soloMatchBtnRect: null,
_backBtnRect: null,
_inviteBtnRect: null,
_matchBtnRect: null,
_readyBtnRect: null,
_disbandBtnRect: null,
_leaveBtnRect: null,
_cancelMatchBtnRect: null,
_slotRects: [],
_kickBtnRects: [],
_avatarImages: {},
enter(params) {
this._state = TEAM_STATE.MODE_SELECT;
this._teamData = null;
this._errorMsg = '';
this._animTimer = 0;
this._matchTimer = 0;
this._countdown = 3;
this._countdownTimer = 0;
this._networkManager = GameGlobal.networkManager;
this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now();
this._isLeader = false;
this._avatarImages = {};
this._buildLayout();
// Setup network events BEFORE auto-join so listeners are ready
this._setupNetworkEvents();
// Listen for profile updates (avatar/nickname granted mid-session)
this._setupProfileListener();
// If entering with a teamId (from invite card), auto-join
if (params && params.teamId) {
this._autoJoinTeam(params.teamId);
}
},
/**
* Update share content so that any share from the top-right menu
* always carries the current teamId.
* @private
*/
_updateShareContent() {
if (!this._teamData || !this._teamData.teamId) return;
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.setShareContent({
title: t('teamRoom.shareTitle'),
imageUrl: '',
query: `teamId=${this._teamData.teamId}`,
});
}
},
exit() {
this._cleanupNetworkEvents();
this._cleanupProfileListener();
// Reset share content when leaving team room
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.resetShareContent();
}
},
_buildLayout() {
const modeY = SCREEN_HEIGHT * 0.4;
this._createTeamBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._soloMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = {
x: 10,
y: 10,
w: 60,
h: 30,
};
// Team member slots (5 slots in a row)
const totalSlotsWidth = TEAM_SIZE * SLOT_WIDTH + (TEAM_SIZE - 1) * SLOT_GAP;
const slotsStartX = CENTER_X - totalSlotsWidth / 2;
const slotsY = SCREEN_HEIGHT * 0.25;
this._slotRects = [];
this._kickBtnRects = [];
for (let i = 0; i < TEAM_SIZE; i++) {
const x = slotsStartX + i * (SLOT_WIDTH + SLOT_GAP);
this._slotRects.push({
x, y: slotsY, w: SLOT_WIDTH, h: SLOT_HEIGHT,
});
// Kick button (small X at top-right of slot)
this._kickBtnRects.push({
x: x + SLOT_WIDTH - 18, y: slotsY + 2, w: 16, h: 16,
});
}
// Action buttons (below slots)
const actionY = SCREEN_HEIGHT * 0.58;
const smallBtnW = BTN_WIDTH * 0.8;
this._inviteBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._matchBtnRect = {
x: CENTER_X + BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._readyBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._disbandBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._leaveBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._cancelMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.7,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
// Invalidate avatar cache for members whose avatarUrl changed
// so that _loadAvatar will reload the new image.
if (data.teamA) {
for (const m of data.teamA) {
if (m.avatarUrl && this._avatarImages[m.playerId] === null) {
// null = previously failed to load; the new URL may work
delete this._avatarImages[m.playerId];
}
}
}
if (data.teamB) {
for (const m of data.teamB) {
if (m.avatarUrl && this._avatarImages[m.playerId] === null) {
delete this._avatarImages[m.playerId];
}
}
}
this._teamData = data;
this._isLeader = data.leaderId === this._myPlayerId;
if (data.state === 'forming') {
this._state = TEAM_STATE.FORMING;
} else if (data.state === 'matching') {
this._state = TEAM_STATE.MATCHING;
}
// Keep share content up-to-date with current teamId
this._updateShareContent();
}));
unsubs.push(nm.on(NET_MSG.TEAM_DISBAND, (data) => {
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
if (data.reason === 'kicked') {
this._errorMsg = t('common.kicked');
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on(NET_MSG.MATCH_FOUND, () => {
this._state = TEAM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
this._startTeamGame(data);
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
// Only switch to error state if not already in game transition
if (this._state !== TEAM_STATE.COUNTDOWN) {
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = TEAM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== TEAM_STATE.MODE_SELECT) {
this._errorMsg = t('common.disconnected');
this._state = TEAM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
/**
* Listen for profile:updated events. When the avatar URL is granted
* (e.g. after fetchSilent completes or user taps UserInfoButton),
* we need to trigger a network message so the server propagates
* the new avatarUrl to other team members.
* @private
*/
_setupProfileListener() {
this._cleanupProfileListener();
const bus = (typeof GameGlobal !== 'undefined') ? GameGlobal.eventBus : null;
if (!bus || typeof bus.on !== 'function') return;
this._profileUnsub = bus.on('profile:updated', (data) => {
console.log('[TeamRoom] profile:updated received, avatarUrl:', data && data.avatarUrl ? 'present' : 'empty');
// If we are in a team room and have a network connection, send a
// lightweight ping so the server picks up the updated avatarUrl
// from the next message's top-level field.
if (this._teamData && this._networkManager && this._networkManager.connected) {
this._networkManager.send(NET_MSG.PING);
console.log('[TeamRoom] Sent PING to sync updated profile to server');
}
// Also invalidate cached avatar for self so it reloads
if (this._myPlayerId && this._avatarImages[this._myPlayerId] !== undefined) {
delete this._avatarImages[this._myPlayerId];
}
});
},
_cleanupProfileListener() {
if (this._profileUnsub) {
this._profileUnsub();
this._profileUnsub = null;
}
},
update(dt) {
this._animTimer += dt;
if (this._state === TEAM_STATE.MATCHING) {
this._matchTimer += dt;
}
if (this._state === TEAM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
}
}
},
_startTeamGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._teamData ? this._teamData.teamId : null,
mapId: data.mapId,
teamA: data.teamA,
teamB: data.teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId: this._myPlayerId,
});
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top accent 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);
// Back button
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 22px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamRoom.title'), CENTER_X, SCREEN_HEIGHT * 0.08);
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
this._renderModeSelect(ctx);
break;
case TEAM_STATE.JOINING:
this._renderJoining(ctx);
break;
case TEAM_STATE.FORMING:
this._renderForming(ctx);
break;
case TEAM_STATE.MATCHING:
this._renderMatching(ctx);
break;
case TEAM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case TEAM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderJoining(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamRoom.joining') + dots, CENTER_X, SCREEN_HEIGHT * 0.45);
},
_renderModeSelect(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.chooseMode'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createTeamBtnRect, t('teamRoom.createTeam'));
this._drawButton(ctx, this._soloMatchBtnRect, t('teamRoom.soloMatch'));
},
_renderForming(ctx) {
if (!this._teamData) return;
// Team ID display
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.teamId', { id: this._teamData.teamId }), CENTER_X, SCREEN_HEIGHT * 0.16);
// Render team member slots
const members = this._teamData.teamA || [];
for (let i = 0; i < TEAM_SIZE; i++) {
const rect = this._slotRects[i];
const member = members[i];
// Slot background
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
// Avatar centered in the slot
const avatarR = Math.min(rect.w, rect.h) * 0.32;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.42;
this._drawAvatar(ctx, member, avatarCX, avatarCY, avatarR);
// Ready state (below avatar)
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88);
}
// Kick button (only for leader, not on self)
if (this._isLeader && !member.isLeader && member.playerId !== this._myPlayerId) {
const kickRect = this._kickBtnRects[i];
ctx.fillStyle = '#FF4444';
ctx.font = 'bold 12px Arial';
ctx.fillText('✕', kickRect.x + kickRect.w / 2, kickRect.y + kickRect.h / 2);
}
} else {
// Empty slot
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
ctx.fillStyle = '#666666';
ctx.font = '10px Arial';
ctx.fillText(t('teamRoom.emptySlot'), rect.x + rect.w / 2, rect.y + rect.h * 0.78);
}
}
// Action buttons based on role
if (this._isLeader) {
this._drawButton(ctx, this._inviteBtnRect, t('teamRoom.invite'));
// Match button: only enabled if all ready
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
this._drawButton(ctx, this._matchBtnRect, t('teamRoom.startMatch'), false, 14, allReady ? null : '#555555');
this._drawButton(ctx, this._disbandBtnRect, t('teamRoom.disband'), false, 12, '#8B0000');
} else {
// Member: ready/unready button
const myMember = members.find(m => m.playerId === this._myPlayerId);
const readyLabel = myMember && myMember.ready ? t('teamRoom.cancelReady') : t('teamRoom.readyBtn');
this._drawButton(ctx, this._readyBtnRect, readyLabel);
this._drawButton(ctx, this._leaveBtnRect, t('teamRoom.leaveTeam'), false, 12, '#8B0000');
}
},
_renderMatching(ctx) {
if (!this._teamData) return;
// Render team slots (smaller, at top)
const members = this._teamData.teamA || [];
for (let i = 0; i < TEAM_SIZE; i++) {
const rect = this._slotRects[i];
const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
// Avatar centered in the slot
const avatarR = Math.min(rect.w, rect.h) * 0.32;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.42;
this._drawAvatar(ctx, member, avatarCX, avatarCY, avatarR);
// Ready state (below avatar)
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88);
}
} else {
// Empty slot
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
}
}
// Matching animation
const elapsed = Math.floor(this._matchTimer);
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.matching', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.waitTime', { seconds: elapsed }), CENTER_X, SCREEN_HEIGHT * 0.62);
// Cancel button (leader only)
if (this._isLeader) {
this._drawButton(ctx, this._cancelMatchBtnRect, t('teamRoom.cancelMatch'));
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.matchFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(Math.max(1, this._countdown)), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.enterBattle'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderError(ctx) {
ctx.fillStyle = '#FF4444';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
_drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
if (!rect) return;
const fs = fontSize || 14;
ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = `bold ${fs}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_drawRoundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
/**
* Draw an avatar for a team member: WeChat image if loaded, else colored placeholder.
* For the local player, also checks PlayerProfile.avatarUrl as a fallback
* source (it may be set before the server broadcasts the update).
* @private
*/
_drawAvatar(ctx, member, cx, cy, r) {
const img = this._avatarImages[member.playerId];
if (img) {
// Circular clip for the avatar image
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2);
ctx.restore();
// Border ring
ctx.strokeStyle = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);
ctx.stroke();
} else {
// For the local player, check if PlayerProfile has an avatarUrl that
// hasn't been loaded into _avatarImages yet (async timing gap).
if (member.playerId === this._myPlayerId) {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
if (profile && profile.avatarUrl && !this._avatarImages[member.playerId]) {
// Profile has an avatarUrl but we haven't loaded it yet — trigger load
this._loadAvatar(member);
// Don't draw the placeholder; next frame will render the image.
// However, to avoid a flash, still draw placeholder this frame.
}
}
// Placeholder: colored circle with a simple person silhouette
const bgColor = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.fillStyle = bgColor;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// Draw a simple person silhouette (head + shoulders)
ctx.fillStyle = 'rgba(255,255,255,0.7)';
// Head
ctx.beginPath();
ctx.arc(cx, cy - r * 0.2, r * 0.3, 0, Math.PI * 2);
ctx.fill();
// Shoulders
ctx.beginPath();
ctx.ellipse(cx, cy + r * 0.55, r * 0.55, r * 0.35, 0, Math.PI, 0);
ctx.fill();
// Border ring for placeholder
ctx.strokeStyle = bgColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);
ctx.stroke();
// Trigger async load
this._loadAvatar(member);
}
},
/**
* Asynchronously load a WeChat avatar image for the given member.
* Caches the Image object so subsequent frames render the real avatar.
* For the local player, also checks PlayerProfile as a fallback source
* for the avatarUrl (it may have been granted after the team state was
* last broadcast from the server).
* @private
*/
_loadAvatar(member) {
// For self, prefer the latest PlayerProfile avatarUrl (it updates
// asynchronously via fetchSilent / UserInfoButton, possibly after
// the server last broadcast the team state).
let avatarUrl = member.avatarUrl;
if (member.playerId === this._myPlayerId) {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
if (profile && profile.avatarUrl) {
avatarUrl = profile.avatarUrl;
}
}
if (!avatarUrl || this._avatarImages[member.playerId] !== undefined) return;
// Mark as loading (null) to prevent duplicate loads
this._avatarImages[member.playerId] = null;
try {
const img = wx.createImage();
img.onload = () => {
this._avatarImages[member.playerId] = img;
console.log(`[TeamRoom] Avatar loaded for ${member.playerId}, url=${avatarUrl.substring(0, 60)}...`);
};
img.onerror = (err) => {
// Keep null so we don't retry endlessly
console.warn(`[TeamRoom] Failed to load avatar for ${member.playerId}, url=${avatarUrl.substring(0, 60)}..., err:`, err);
};
img.src = avatarUrl;
} catch (e) {
console.warn('[TeamRoom] wx.createImage not available:', e);
}
},
/**
* 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;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Back button
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
if (this._hitTest(tx, ty, this._createTeamBtnRect)) {
this._handleCreateTeam();
} else if (this._hitTest(tx, ty, this._soloMatchBtnRect)) {
this._handleSoloMatch();
}
break;
case TEAM_STATE.FORMING:
this._handleFormingTouch(tx, ty);
break;
case TEAM_STATE.MATCHING:
if (this._isLeader && this._hitTest(tx, ty, this._cancelMatchBtnRect)) {
this._handleCancelMatch();
}
break;
case TEAM_STATE.ERROR:
this._state = TEAM_STATE.MODE_SELECT;
this._errorMsg = '';
break;
}
},
_handleFormingTouch(tx, ty) {
if (!this._teamData) return;
if (this._isLeader) {
// Invite button
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
this._handleInvite();
return;
}
// Match button
if (this._hitTest(tx, ty, this._matchBtnRect)) {
this._handleStartMatch();
return;
}
// Disband button
if (this._hitTest(tx, ty, this._disbandBtnRect)) {
this._handleDisband();
return;
}
// Kick buttons
const members = this._teamData.teamA || [];
for (let i = 0; i < members.length; i++) {
const member = members[i];
if (member && !member.isLeader && member.playerId !== this._myPlayerId) {
if (this._hitTest(tx, ty, this._kickBtnRects[i])) {
this._handleKick(member.playerId);
return;
}
}
}
} else {
// Ready button
if (this._hitTest(tx, ty, this._readyBtnRect)) {
this._handleReady();
return;
}
// Leave button
if (this._hitTest(tx, ty, this._leaveBtnRect)) {
this._handleLeave();
return;
}
}
},
async _handleCreateTeam() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
nm.send(NET_MSG.CREATE_TEAM, {});
},
async _handleSoloMatch() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
this._matchTimer = 0;
// State will be updated by TEAM_STATE event from server
nm.send(NET_MSG.SOLO_MATCH, {});
},
async _autoJoinTeam(teamId) {
const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
// Show a joining indicator
this._state = TEAM_STATE.JOINING;
this._errorMsg = '';
try {
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
console.log(`[TeamRoom] Auto-joining team ${teamId} as ${this._myPlayerId}`);
nm.send(NET_MSG.JOIN_TEAM, { teamId });
} catch (e) {
console.error('[TeamRoom] Auto-join failed:', e);
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
}
},
_handleInvite() {
if (!this._teamData) return;
const teamId = this._teamData.teamId;
const shareData = {
title: t('teamRoom.shareTitle'),
imageUrl: '',
query: `teamId=${teamId}`,
};
console.log(`[TeamRoom] Sharing invite with query: teamId=${teamId}`);
// WeChat mini-game policy: direct wx.shareAppMessage() calls are forbidden.
// Must use passive sharing via onShareAppMessage callback.
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
} catch (e) {
console.log('[TeamRoom] Share not available, teamId:', teamId);
}
}
},
_handleStartMatch() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
// Check all members are ready before sending
const members = this._teamData.teamA || [];
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
if (!allReady) return;
this._matchTimer = 0;
nm.send(NET_MSG.MATCH_START, {});
},
_handleCancelMatch() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.MATCH_CANCEL, {});
},
_handleReady() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
const myMember = (this._teamData.teamA || []).find(m => m.playerId === this._myPlayerId);
nm.send(NET_MSG.TEAM_READY, { ready: myMember ? !myMember.ready : true });
},
_handleKick(playerId) {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_KICK, { playerId });
},
_handleDisband() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_DISBAND, {});
},
_handleLeave() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.LEAVE_TEAM, {});
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
},
_goBack() {
// Leave team if in one
if (this._teamData) {
const nm = this._networkManager;
if (nm) {
if (this._state === TEAM_STATE.MATCHING && this._isLeader) {
// Cancel match first, then disband
nm.send(NET_MSG.MATCH_CANCEL, {});
}
if (this._isLeader) {
nm.send(NET_MSG.TEAM_DISBAND, {});
} else {
nm.send(NET_MSG.LEAVE_TEAM, {});
}
}
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
module.exports = TeamRoomScene;