From 38294c040c8ec7fd73a0781dd2e54d38002de598 Mon Sep 17 00:00:00 2001 From: jakciehan Date: Sat, 2 May 2026 13:50:52 +0800 Subject: [PATCH] chore: adjust player tank's size --- deploy/edge/nginx.conf | 109 ++ deploy/k8s/deployment.yaml | 62 + deploy/k8s/namespace.yaml | 7 + deploy/k8s/scripts/run-deploy.sh | 165 ++ deploy/k8s/service.yaml | 48 + deploy/nginx-wss-snippet.conf | 36 + deploy/warmcheck-nginx-merge/nginx.conf.new | 408 ++++ .../warmcheck-nginx-merge/nginx.conf.original | 331 ++++ game.js | 16 + js/base/GameGlobal.js | 2 +- js/entities/PlayerTank.js | 1 + js/entities/Tank.js | 26 +- js/entities/TankSkinRenderer.js | 1025 ++++++++++ js/i18n/en.js | 7 +- js/i18n/zh.js | 7 +- js/managers/NetworkManager.js | 86 +- js/managers/PlayerProfile.js | 293 +++ js/managers/SkinManager.js | 59 +- js/scenes/GameScene.js | 1 + js/scenes/MenuScene.js | 447 +++-- js/scenes/SettingsScene.js | 151 +- js/scenes/SkinScene.js | 1644 +++++++++++++++-- js/scenes/TeamGameScene.js | 107 +- js/scenes/TeamResultScene.js | 32 +- js/scenes/TeamRoomScene.js | 27 +- server/.dockerignore | 8 + server/DEPLOYMENT_GUIDE.md | 256 +++ server/Dockerfile | 25 + server/README.md | 183 ++ server/deploy.sh | 46 + server/index.js | 118 +- server/k8s-deployment.yaml | 138 ++ server/run-deploy.sh | 97 + server/test-deployment.sh | 75 + server/verify-deployment.sh | 72 + 35 files changed, 5767 insertions(+), 348 deletions(-) create mode 100644 deploy/edge/nginx.conf create mode 100644 deploy/k8s/deployment.yaml create mode 100644 deploy/k8s/namespace.yaml create mode 100755 deploy/k8s/scripts/run-deploy.sh create mode 100644 deploy/k8s/service.yaml create mode 100644 deploy/nginx-wss-snippet.conf create mode 100644 deploy/warmcheck-nginx-merge/nginx.conf.new create mode 100644 deploy/warmcheck-nginx-merge/nginx.conf.original create mode 100644 js/entities/TankSkinRenderer.js create mode 100644 js/managers/PlayerProfile.js create mode 100644 server/.dockerignore create mode 100644 server/DEPLOYMENT_GUIDE.md create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100755 server/deploy.sh create mode 100644 server/k8s-deployment.yaml create mode 100755 server/run-deploy.sh create mode 100755 server/test-deployment.sh create mode 100644 server/verify-deployment.sh diff --git a/deploy/edge/nginx.conf b/deploy/edge/nginx.conf new file mode 100644 index 0000000..19e446f --- /dev/null +++ b/deploy/edge/nginx.conf @@ -0,0 +1,109 @@ +# +# Edge reverse proxy: 42.194.185.163 +# Forwards public 80/443 traffic (L4 passthrough) to the K8s worker nodes +# that run the warmcheck/nginx DaemonSet (hostNetwork hostPort 80/443). +# +# Worker public IPs (cross-VPC, so we must use public addresses): +# - 43.138.255.42 (cvm-42 / 172.16.16.17) +# - 159.75.104.221 (cvm-221 / 172.16.16.8) +# +# Master (43.139.80.61 / 172.16.16.16) is excluded — DaemonSet nodeAffinity +# skips control-plane nodes, so it does NOT listen on :80/:443. +# + +user nginx; +worker_processes auto; +worker_rlimit_nofile 65535; + +error_log /var/log/nginx/error.log notice; +pid /run/nginx.pid; + +# Load dynamic modules (stream module ships as a dynamic module on +# OpenCloudOS 9 / RHEL 9 and lives in /usr/share/nginx/modules/). +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 8192; + use epoll; + multi_accept on; +} + +# ============================================================ +# L4 stream passthrough (HTTP 80 + HTTPS 443 + WSS) +# ============================================================ +stream { + log_format basic '$remote_addr [$time_local] ' + '$protocol $status $bytes_sent $bytes_received ' + '$session_time "$upstream_addr" ' + '"$upstream_bytes_sent" "$upstream_bytes_received" ' + '"$upstream_connect_time"'; + + access_log /var/log/nginx/stream-access.log basic buffer=32k flush=5s; + + # ---- HTTP upstream (80) ---- + upstream k8s_http { + # 2 workers; passive health check via max_fails/fail_timeout. + server 43.138.255.42:80 max_fails=3 fail_timeout=30s; + server 159.75.104.221:80 max_fails=3 fail_timeout=30s; + } + + # ---- HTTPS upstream (443, SNI passthrough) ---- + upstream k8s_https { + server 43.138.255.42:443 max_fails=3 fail_timeout=30s; + server 159.75.104.221:443 max_fails=3 fail_timeout=30s; + } + + # ---- Listeners ---- + server { + listen 80; + listen [::]:80; + proxy_pass k8s_http; + proxy_connect_timeout 5s; + proxy_timeout 300s; # long enough for WS keep-alive + proxy_socket_keepalive on; + } + + server { + listen 443; + listen [::]:443; + proxy_pass k8s_https; + proxy_connect_timeout 5s; + proxy_timeout 300s; # long enough for WSS keep-alive + proxy_socket_keepalive on; + } +} + +# ============================================================ +# Local-only HTTP block for status / health probing on :8080 +# (bound to 127.0.0.1 so it doesn't clash with stream :80) +# ============================================================ +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 4096; + + server { + listen 127.0.0.1:8080; + server_name _; + + location = /edge-health { + return 200 "edge-ok\n"; + add_header Content-Type text/plain; + } + + location / { + return 404; + } + } +} diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml new file mode 100644 index 0000000..bc8acf0 --- /dev/null +++ b/deploy/k8s/deployment.yaml @@ -0,0 +1,62 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tankwar-server + namespace: tankwar + labels: + app: tankwar-server +spec: + # Single replica for Plan A (in-memory room state). + # Kubernetes will auto-reschedule if the pod or its node fails. + replicas: 1 + strategy: + # Recreate instead of RollingUpdate so the port & state are never + # duplicated across two pods with in-memory room registry. + type: Recreate + selector: + matchLabels: + app: tankwar-server + template: + metadata: + labels: + app: tankwar-server + spec: + terminationGracePeriodSeconds: 15 + containers: + - name: tankwar-server + image: tankwar-server:latest + imagePullPolicy: IfNotPresent + ports: + - name: ws + containerPort: 3000 + protocol: TCP + env: + - name: NODE_ENV + value: "production" + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "3000" + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "1000m" + memory: "512Mi" + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml new file mode 100644 index 0000000..a8d60f5 --- /dev/null +++ b/deploy/k8s/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: tankwar + labels: + name: tankwar + app: tankwar diff --git a/deploy/k8s/scripts/run-deploy.sh b/deploy/k8s/scripts/run-deploy.sh new file mode 100755 index 0000000..d9c136d --- /dev/null +++ b/deploy/k8s/scripts/run-deploy.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# ============================================================================= +# Tank War Server — one-shot K8s deploy +# +# Mirrors the style of WarmCheck's run-deploy.sh: +# 1) sync source to master +# 2) build docker image on master +# 3) distribute image tarball to all worker nodes and `ctr` import +# 4) apply K8s manifests and restart the deployment +# ============================================================================= +set -euo pipefail + +# ---------- Configurable ------------------------------------------------------ +# Host that the LOCAL developer machine can reach (uses your ssh_config alias). +MASTER_HOST="${MASTER_HOST:-host_172.16.16.16}" +# Intranet IPs that the MASTER uses to reach workers (no alias on the CVMs). +WORKER_INTRANET_IPS=( + "172.16.16.17" + "172.16.16.8" +) + +NAMESPACE="tankwar" +IMAGE_NAME="tankwar-server" +IMAGE_TAG="${IMAGE_TAG:-latest}" +REMOTE_WORKDIR="/root/tankwar" +SSH_USER="root" +SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" + +# ---------- Paths ------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +SERVER_DIR="${PROJECT_ROOT}/server" +K8S_DIR="${PROJECT_ROOT}/deploy/k8s" + +# ---------- Helpers ----------------------------------------------------------- +log() { printf "\033[1;36m[deploy]\033[0m %s\n" "$*"; } +ok() { printf "\033[1;32m[ ok ]\033[0m %s\n" "$*"; } +warn() { printf "\033[1;33m[warn]\033[0m %s\n" "$*"; } +die() { printf "\033[1;31m[fail]\033[0m %s\n" "$*" >&2; exit 1; } + +ssh_master() { ssh ${SSH_OPTS} "${SSH_USER}@${MASTER_HOST}" "$@"; } +ssh_host() { local h="$1"; shift; ssh ${SSH_OPTS} "${SSH_USER}@${h}" "$@"; } + +# ============================================================================= +# Step 1 — sync server source code & k8s manifests to master +# ============================================================================= +step_sync() { + log "1/5 Syncing source to master (${MASTER_HOST}) ..." + ssh_master "mkdir -p ${REMOTE_WORKDIR}/server ${REMOTE_WORKDIR}/deploy/k8s" + + rsync -az --delete \ + --exclude 'node_modules' \ + --exclude '.git' \ + --exclude '.DS_Store' \ + -e "ssh ${SSH_OPTS}" \ + "${SERVER_DIR}/" \ + "${SSH_USER}@${MASTER_HOST}:${REMOTE_WORKDIR}/server/" + + rsync -az --delete \ + -e "ssh ${SSH_OPTS}" \ + "${K8S_DIR}/" \ + "${SSH_USER}@${MASTER_HOST}:${REMOTE_WORKDIR}/deploy/k8s/" + + ok "source synced" +} + +# ============================================================================= +# Step 2 — build docker image on master +# ============================================================================= +step_build() { + log "2/5 Building image ${IMAGE_NAME}:${IMAGE_TAG} on master ..." + ssh_master "cd ${REMOTE_WORKDIR}/server && \ + docker build --pull -t ${IMAGE_NAME}:${IMAGE_TAG} -t ${IMAGE_NAME}:latest ." + ok "image built" +} + +# ============================================================================= +# Step 3 — distribute image to every worker via containerd (ctr import) +# +# The cluster uses containerd directly (not docker-shim). Each node must +# have the image in the "k8s.io" namespace to be usable by kubelet. +# ============================================================================= +step_distribute() { + log "3/5 Distributing image to workers ..." + + # Export once on master + local remote_tar="/tmp/${IMAGE_NAME}-${IMAGE_TAG}.tar" + ssh_master "docker save ${IMAGE_NAME}:${IMAGE_TAG} -o ${remote_tar} && ls -lh ${remote_tar}" + + # Import on master's containerd (k8s.io ns) so the scheduler can use it locally too + ssh_master "ctr -n k8s.io images import ${remote_tar} && \ + ctr -n k8s.io images tag --force docker.io/library/${IMAGE_NAME}:${IMAGE_TAG} \ + docker.io/library/${IMAGE_NAME}:latest" + ok "master imported" + + # Fan-out to workers — executed FROM the master using intranet IPs. + for ip in "${WORKER_INTRANET_IPS[@]}"; do + log " -> ${ip}" + ssh_master "scp ${SSH_OPTS} ${remote_tar} ${SSH_USER}@${ip}:${remote_tar} && \ + ssh ${SSH_OPTS} ${SSH_USER}@${ip} 'ctr -n k8s.io images import ${remote_tar} && \ + ctr -n k8s.io images tag --force docker.io/library/${IMAGE_NAME}:${IMAGE_TAG} \ + docker.io/library/${IMAGE_NAME}:latest && \ + rm -f ${remote_tar}'" + ok " ${ip} imported" + done + + ssh_master "rm -f ${remote_tar}" + ok "distribution done" +} + +# ============================================================================= +# Step 4 — apply manifests & roll the deployment +# ============================================================================= +step_apply() { + log "4/5 Applying K8s manifests ..." + ssh_master "kubectl apply -f ${REMOTE_WORKDIR}/deploy/k8s/namespace.yaml" + ssh_master "kubectl apply -f ${REMOTE_WORKDIR}/deploy/k8s/service.yaml" + ssh_master "kubectl apply -f ${REMOTE_WORKDIR}/deploy/k8s/deployment.yaml" + + # Force a new rollout so we pick up the newly-imported image even + # when the tag stays :latest. + ssh_master "kubectl -n ${NAMESPACE} set image deploy/tankwar-server \ + tankwar-server=${IMAGE_NAME}:${IMAGE_TAG} --record=false || true" + + log " waiting for rollout ..." + ssh_master "kubectl -n ${NAMESPACE} rollout status deploy/tankwar-server --timeout=180s" + ok "deployment is live" +} + +# ============================================================================= +# Step 5 — sanity check +# ============================================================================= +step_verify() { + log "5/5 Verifying ..." + ssh_master "kubectl -n ${NAMESPACE} get pods,svc -o wide" + echo + ssh_master "kubectl -n ${NAMESPACE} logs deploy/tankwar-server --tail=20 || true" + echo + ok "all done. NodePort: 30081" + cat < 0.9 && twinkle > 0.7) { + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 0.4; + ctx.beginPath(); ctx.moveTo(star.x - 2, star.y); ctx.lineTo(star.x + 2, star.y); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(star.x, star.y - 2); ctx.lineTo(star.x, star.y + 2); ctx.stroke(); + } + } + ctx.restore(); + ctx.save(); + ctx.globalAlpha = 0.2; + ctx.strokeStyle = '#FF00FF'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let a = 0; a < Math.PI * 4; a += 0.15) { + const r = 2 + a * 1.2; + const sx = Math.cos(a + tt * 0.5) * r; + const sy = Math.sin(a + tt * 0.5) * r; + if (a === 0) ctx.moveTo(sx, sy); + else ctx.lineTo(sx, sy); + if (r > s - 2) break; + } + ctx.stroke(); + ctx.restore(); + ctx.save(); + ctx.shadowColor = '#FF00FF'; + ctx.shadowBlur = 6 * pulse; + ctx.fillStyle = tc; + ctx.beginPath(); ctx.arc(0, 0, 5.5, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = `rgba(255,200,255,${0.5 + pulse * 0.3})`; + ctx.beginPath(); ctx.arc(0, 0, 2, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.beginPath(); ctx.arc(0, 0, 0.8, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(-3, -5); ctx.lineTo(3, -5); + ctx.lineTo(2, -s - 9); ctx.lineTo(-2, -s - 9); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = `rgba(255,0,255,${pulse * 0.6})`; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(-3.5, -s); ctx.lineTo(3.5, -s); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-3, -s - 4); ctx.lineTo(3, -s - 4); ctx.stroke(); + ctx.save(); + ctx.shadowColor = '#FF00FF'; + ctx.shadowBlur = 8 * pulse; + ctx.fillStyle = '#FF00FF'; + ctx.beginPath(); ctx.arc(0, -s - 9, 2.8, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.beginPath(); ctx.arc(0, -s - 9, 1.2, 0, Math.PI * 2); ctx.fill(); +} + +// ======== ROYAL ======== +function _tankRoyal(ctx, bc, tc, kc /* , t */) { + const s = 12; + ctx.fillStyle = kc; + _roundRect(ctx, -s - 5, -s + 1, 5, s * 2 - 2, 2); ctx.fill(); + _roundRect(ctx, s, -s + 1, 5, s * 2 - 2, 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,215,0,0.5)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(-s - 5, -s + 1); ctx.lineTo(-s - 5, s - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 5, -s + 1); ctx.lineTo(s + 5, s - 1); ctx.stroke(); + ctx.fillStyle = bc; + _roundRect(ctx, -s, -s, s * 2, s * 2, 5); ctx.fill(); + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(-4, -5); + ctx.lineTo(4, -5); + ctx.lineTo(4, 1); + ctx.quadraticCurveTo(4, 5, 0, 7); + ctx.quadraticCurveTo(-4, 5, -4, 1); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = 'rgba(255,215,0,0.6)'; + ctx.beginPath(); + ctx.moveTo(-2, -3); + ctx.lineTo(2, -3); + ctx.lineTo(2, 0); + ctx.quadraticCurveTo(2, 3, 0, 4); + ctx.quadraticCurveTo(-2, 3, -2, 0); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#FFD700'; + ctx.beginPath(); + ctx.moveTo(-6, -s + 2); + ctx.lineTo(-5, -s - 2); + ctx.lineTo(-3, -s + 1); + ctx.lineTo(0, -s - 4); + ctx.lineTo(3, -s + 1); + ctx.lineTo(5, -s - 2); + ctx.lineTo(6, -s + 2); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#FF0000'; + ctx.beginPath(); ctx.arc(0, -s - 2, 1.2, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#0066FF'; + ctx.beginPath(); ctx.arc(-4, -s, 0.8, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(4, -s, 0.8, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = tc; + _roundRect(ctx, -2.5, -s - 10, 5, s - 4, 2); ctx.fill(); + ctx.strokeStyle = '#FFD700'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(-3, -s - 3); ctx.lineTo(3, -s - 3); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-3, -s - 7); ctx.lineTo(3, -s - 7); ctx.stroke(); + ctx.fillStyle = '#FFD700'; + ctx.beginPath(); ctx.arc(0, -s - 10, 2.5, 0, Math.PI * 2); ctx.fill(); +} + +// ======== SAKURA ======== +function _tankSakura(ctx, bc, tc, kc, t) { + const s = 12; + const tt = t || 0; + ctx.fillStyle = kc; + _roundRect(ctx, -s - 4, -s + 1, 4, s * 2 - 2, 2); ctx.fill(); + _roundRect(ctx, s, -s + 1, 4, s * 2 - 2, 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,105,180,0.4)'; + ctx.lineWidth = 0.7; + ctx.beginPath(); ctx.moveTo(-s - 4, -s + 1); ctx.lineTo(-s - 4, s - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 4, -s + 1); ctx.lineTo(s + 4, s - 1); ctx.stroke(); + ctx.fillStyle = bc; + _roundRect(ctx, -s, -s, s * 2, s * 2, 6); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.beginPath(); + ctx.moveTo(-s + 2, -s); + ctx.lineTo(s - 2, -s); + ctx.lineTo(-s + 2, -s + 8); + ctx.closePath(); + ctx.fill(); + ctx.save(); + ctx.globalAlpha = 0.6; + const petals = [ + { ox: -6, oy: -7, phase: 0 }, + { ox: 7, oy: -4, phase: 1.5 }, + { ox: -3, oy: 6, phase: 3 }, + { ox: 5, oy: 5, phase: 4.5 }, + ]; + for (const p of petals) { + const drift = Math.sin(tt * 2 + p.phase) * 1.5; + const px = p.ox + drift; + const py = p.oy; + ctx.fillStyle = '#FF69B4'; + ctx.beginPath(); + for (let i = 0; i < 5; i++) { + const angle = (i / 5) * Math.PI * 2 - Math.PI / 2; + const pr = 2.2; + const fx = px + Math.cos(angle) * pr; + const fy = py + Math.sin(angle) * pr; + ctx.moveTo(fx, fy); + ctx.arc(fx, fy, 1.2, 0, Math.PI * 2); + } + ctx.fill(); + ctx.fillStyle = '#FFE4E1'; + ctx.beginPath(); ctx.arc(px, py, 0.8, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + ctx.fillStyle = tc; + ctx.beginPath(); ctx.arc(0, 0, 5.5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = '#FFE4E1'; + ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(-3, -5); ctx.lineTo(3, -5); + ctx.lineTo(2, -s - 9); ctx.lineTo(-2, -s - 9); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = 'rgba(255,183,197,0.7)'; + ctx.beginPath(); ctx.arc(0, -s - 9, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.beginPath(); ctx.arc(0, -s - 9, 1.2, 0, Math.PI * 2); ctx.fill(); +} + +// ======== THUNDER ======== +function _tankThunder(ctx, bc, tc, kc, t) { + const s = 12; + const tt = t || 0; + const flash = 0.7 + Math.sin(tt * 8) * 0.3; + ctx.fillStyle = kc; + _roundRect(ctx, -s - 5, -s, 5, s * 2, 2); ctx.fill(); + _roundRect(ctx, s, -s, 5, s * 2, 2); ctx.fill(); + ctx.save(); + ctx.globalAlpha = flash * 0.6; + ctx.strokeStyle = '#00BFFF'; + ctx.lineWidth = 0.8; + for (let ty = -s + 2; ty < s - 2; ty += 5) { + ctx.beginPath(); + ctx.moveTo(-s - 5, ty); + ctx.lineTo(-s - 3, ty + 1.5); + ctx.lineTo(-s - 5, ty + 3); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(s + 5, ty); + ctx.lineTo(s + 3, ty + 1.5); + ctx.lineTo(s + 5, ty + 3); + ctx.stroke(); + } + ctx.restore(); + ctx.fillStyle = bc; + ctx.beginPath(); + ctx.moveTo(-s + 1, -s - 2); + ctx.lineTo(s - 1, -s - 2); + ctx.lineTo(s + 2, -s + 3); + ctx.lineTo(s + 2, s - 3); + ctx.lineTo(s - 1, s + 2); + ctx.lineTo(-s + 1, s + 2); + ctx.lineTo(-s - 2, s - 3); + ctx.lineTo(-s - 2, -s + 3); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = 'rgba(0,191,255,0.5)'; + ctx.beginPath(); + ctx.moveTo(-1, -7); + ctx.lineTo(3, -7); + ctx.lineTo(0, -1); + ctx.lineTo(4, -1); + ctx.lineTo(-2, 8); + ctx.lineTo(0, 2); + ctx.lineTo(-3, 2); + ctx.closePath(); + ctx.fill(); + ctx.save(); + ctx.shadowColor = '#00BFFF'; + ctx.shadowBlur = 6 * flash; + ctx.strokeStyle = 'rgba(0,191,255,0.35)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(-s + 1, -s - 2); + ctx.lineTo(s - 1, -s - 2); + ctx.lineTo(s + 2, -s + 3); + ctx.lineTo(s + 2, s - 3); + ctx.lineTo(s - 1, s + 2); + ctx.lineTo(-s + 1, s + 2); + ctx.lineTo(-s - 2, s - 3); + ctx.lineTo(-s - 2, -s + 3); + ctx.closePath(); + ctx.stroke(); + ctx.fillStyle = tc; + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = (i / 6) * Math.PI * 2 - Math.PI / 6; + const hx = Math.cos(angle) * 5.5; + const hy = Math.sin(angle) * 5.5; + if (i === 0) ctx.moveTo(hx, hy); + else ctx.lineTo(hx, hy); + } + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = `rgba(0,191,255,${0.4 + flash * 0.3})`; + ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = `rgba(255,255,255,${0.5 + flash * 0.3})`; + ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = tc; + _roundRect(ctx, -2.5, -s - 10, 5, s - 3, 2); ctx.fill(); + ctx.strokeStyle = `rgba(0,191,255,${flash * 0.8})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(-2.5, -s); ctx.lineTo(-5, -s - 3); + ctx.lineTo(-2.5, -s - 5); ctx.lineTo(-5, -s - 8); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(2.5, -s - 1); ctx.lineTo(5, -s - 4); + ctx.lineTo(2.5, -s - 6); ctx.lineTo(5, -s - 9); + ctx.stroke(); + ctx.fillStyle = '#00BFFF'; + ctx.beginPath(); ctx.arc(0, -s - 10, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.beginPath(); ctx.arc(0, -s - 10, 1.5, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); +} + +// ======== DIAMOND 💎 ======== +// Full crystalline tank — all-diamond body, gem-encrusted tracks, +// hexagonal faceted turret, jewel barrel. Stays within the standard +// [−s, +s] design footprint so it matches the size of every other skin. +// Reference: clean icy-blue gem tank, faceted triangular surfaces, +// sapphire track wheels, centered diamond rivet. +function _tankDiamond(ctx, bc, tc, kc, t) { + const s = 12; + const tt = t || 0; + const breathe = 0.5 + Math.sin(tt * 0.5) * 0.5; // very slow breathing + const shimmer = 0.5 + Math.sin(tt * 0.7) * 0.5; // slow shimmer + + // Ice-blue palette (reference image): + // light = crystal highlight, mid = surface, deep = shadow / outline + const light = '#EAF6FF'; + const midA = '#BCDFFF'; + const midB = '#7DC4FF'; + const deep = '#1E5B9E'; + const edge = '#7DF9FF'; + + // ── 0. Very faint outer aura (doesn't enlarge footprint) ── + ctx.save(); + ctx.globalAlpha = 0.10 + breathe * 0.06; + ctx.fillStyle = edge; + ctx.beginPath(); + ctx.arc(0, 0, s + 2, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + // ═══════════════════════════════════════════════════════════ + // TRACKS (left & right) — sapphire-bead chains with hub wheels + // ═══════════════════════════════════════════════════════════ + const tW = 4; // track half-width + const tx = -s - tW * 0.5; // left track center x + const rx = s + tW * 0.5; // right track center x + const trackTop = -s + 1; + const trackBot = s - 1; + + // Track outer shell (dark steel) + ctx.fillStyle = '#0E3562'; + ctx.fillRect(tx - tW, trackTop - 0.5, tW * 2, trackBot - trackTop + 1); + ctx.fillRect(rx - tW, trackTop - 0.5, tW * 2, trackBot - trackTop + 1); + + // Track top/bottom rounded caps + ctx.beginPath(); ctx.arc(tx, trackTop, tW, Math.PI, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(tx, trackBot, tW, 0, Math.PI); ctx.fill(); + ctx.beginPath(); ctx.arc(rx, trackTop, tW, Math.PI, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(rx, trackBot, tW, 0, Math.PI); ctx.fill(); + + // Gem beads along each track (small blue sapphires) + const beadCount = 5; + for (let i = 0; i < beadCount; i++) { + const by = trackTop + 3 + (trackBot - trackTop - 6) * (i / (beadCount - 1)); + for (const cx of [tx, rx]) { + // Bead body (rhombus-ish) + ctx.fillStyle = midB; + ctx.beginPath(); + ctx.moveTo(cx, by - 1.6); + ctx.lineTo(cx + 2.2, by); + ctx.lineTo(cx, by + 1.6); + ctx.lineTo(cx - 2.2, by); + ctx.closePath(); + ctx.fill(); + // Highlight + ctx.fillStyle = light; + ctx.beginPath(); + ctx.moveTo(cx, by - 1.6); + ctx.lineTo(cx + 0.8, by - 0.4); + ctx.lineTo(cx - 0.8, by - 0.4); + ctx.closePath(); + ctx.fill(); + // Dark edge bottom + ctx.fillStyle = deep; + ctx.beginPath(); + ctx.moveTo(cx + 2.2, by); + ctx.lineTo(cx, by + 1.6); + ctx.lineTo(cx + 0.4, by + 0.2); + ctx.closePath(); + ctx.fill(); + } + } + + // Hub wheels (big sapphire wheels at each end) + const hubR = 2.6; + for (const cx of [tx, rx]) { + for (const hy of [trackTop, trackBot]) { + // Outer ring + ctx.fillStyle = deep; + ctx.beginPath(); ctx.arc(cx, hy, hubR + 0.6, 0, Math.PI * 2); ctx.fill(); + // Gem core + ctx.fillStyle = midB; + ctx.beginPath(); ctx.arc(cx, hy, hubR, 0, Math.PI * 2); ctx.fill(); + // Star-cut facets (4-point) + ctx.strokeStyle = 'rgba(255,255,255,0.65)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); ctx.moveTo(cx - hubR, hy); ctx.lineTo(cx + hubR, hy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx, hy - hubR); ctx.lineTo(cx, hy + hubR); ctx.stroke(); + // Inner highlight + ctx.fillStyle = light; + ctx.beginPath(); ctx.arc(cx - 0.6, hy - 0.6, 0.8, 0, Math.PI * 2); ctx.fill(); + } + } + + // ═══════════════════════════════════════════════════════════ + // BODY — teardrop/bullet silhouette, faceted diamond surface + // Width stays within ±s; height uses the full ±s footprint. + // ═══════════════════════════════════════════════════════════ + // Main body outline (rear wider, front slightly narrower — classic tank nose) + const body = [ + [-s + 2, -s + 1], // front-left + [ s - 2, -s + 1], // front-right + [ s, -s + 4], + [ s, s - 4], + [ s - 2, s - 1], + [-s + 2, s - 1], + [-s, s - 4], + [-s, -s + 4], + ]; + + // Base fill — soft ice-blue gradient approximation (radial-ish) + ctx.fillStyle = midA; + ctx.beginPath(); + ctx.moveTo(body[0][0], body[0][1]); + for (let i = 1; i < body.length; i++) ctx.lineTo(body[i][0], body[i][1]); + ctx.closePath(); + ctx.fill(); + + // Faceted triangles — all emanating from a single "highlight point" so the + // crystal looks like it has a single light source, mimicking the reference. + const hp = [-s + 4, -s + 3]; // highlight point (upper-left region) + const facetRim = [ + [-s + 2, -s + 1], [ s - 2, -s + 1], [ s, -s + 4], [ s, s - 4], + [ s - 2, s - 1], [-s + 2, s - 1], [-s, s - 4], [-s, -s + 4], + ]; + // Triangles from highlight point to each rim edge + for (let i = 0; i < facetRim.length; i++) { + const a = facetRim[i]; + const b = facetRim[(i + 1) % facetRim.length]; + // Shade based on angle away from light point + const mx = (a[0] + b[0]) * 0.5; + const my = (a[1] + b[1]) * 0.5; + const dx = mx - hp[0]; + const dy = my - hp[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + // Nearer to hp → brighter, farther → deeper blue + const tBright = Math.max(0, 1 - dist / 22); + const colA = tBright > 0.6 ? light : tBright > 0.3 ? midA : midB; + ctx.fillStyle = colA; + ctx.globalAlpha = 0.55; + ctx.beginPath(); + ctx.moveTo(hp[0], hp[1]); + ctx.lineTo(a[0], a[1]); + ctx.lineTo(b[0], b[1]); + ctx.closePath(); + ctx.fill(); + } + ctx.globalAlpha = 1; + + // Secondary facet lines — subtle geometric net + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 0.4; + // From highlight point to every rim vertex + for (const p of facetRim) { + ctx.beginPath(); ctx.moveTo(hp[0], hp[1]); ctx.lineTo(p[0], p[1]); ctx.stroke(); + } + // Rim outline (crisp inner) + ctx.strokeStyle = 'rgba(255,255,255,0.55)'; + ctx.lineWidth = 0.6; + ctx.beginPath(); + ctx.moveTo(body[0][0], body[0][1]); + for (let i = 1; i < body.length; i++) ctx.lineTo(body[i][0], body[i][1]); + ctx.closePath(); + ctx.stroke(); + + // Deep blue body outline (crisp edge) + ctx.strokeStyle = deep; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.moveTo(body[0][0], body[0][1]); + for (let i = 1; i < body.length; i++) ctx.lineTo(body[i][0], body[i][1]); + ctx.closePath(); + ctx.stroke(); + + // ── Centered "diamond rivet" — the signature small gem on the hull ── + ctx.save(); + ctx.fillStyle = midB; + ctx.beginPath(); + ctx.moveTo(0, -2.8); + ctx.lineTo(2.4, 0); + ctx.lineTo(0, 2.8); + ctx.lineTo(-2.4, 0); + ctx.closePath(); + ctx.fill(); + // Rivet facet lines + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); ctx.moveTo(0, -2.8); ctx.lineTo(0, 2.8); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-2.4, 0); ctx.lineTo(2.4, 0); ctx.stroke(); + // Rivet highlight + ctx.fillStyle = light; + ctx.beginPath(); + ctx.moveTo(0, -2.8); ctx.lineTo(0.9, -1); ctx.lineTo(-0.9, -1); + ctx.closePath(); + ctx.fill(); + // Rivet dark outline + ctx.strokeStyle = deep; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, -2.8); ctx.lineTo(2.4, 0); ctx.lineTo(0, 2.8); + ctx.lineTo(-2.4, 0); ctx.closePath(); + ctx.stroke(); + ctx.restore(); + + // ═══════════════════════════════════════════════════════════ + // TURRET — hexagonal gem prism (top view) sitting on the hull + // ═══════════════════════════════════════════════════════════ + const turretR = 6.5; + const turretRy = 5.5; // slightly squashed vertically (perspective feel) + // Turret base ring (darker blue outline for contrast with body) + ctx.fillStyle = deep; + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const ang = (i / 6) * Math.PI * 2 - Math.PI / 2; + const hx = Math.cos(ang) * (turretR + 0.6); + const hy = Math.sin(ang) * (turretRy + 0.6) - 1; + if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); + } + ctx.closePath(); + ctx.fill(); + + // Turret body (6-sided gem) + const turretPts = []; + for (let i = 0; i < 6; i++) { + const ang = (i / 6) * Math.PI * 2 - Math.PI / 2; + turretPts.push([Math.cos(ang) * turretR, Math.sin(ang) * turretRy - 1]); + } + ctx.fillStyle = midA; + ctx.beginPath(); + ctx.moveTo(turretPts[0][0], turretPts[0][1]); + for (let i = 1; i < 6; i++) ctx.lineTo(turretPts[i][0], turretPts[i][1]); + ctx.closePath(); + ctx.fill(); + + // Turret facets from center + const tHp = [-1.8, -2.5]; + for (let i = 0; i < 6; i++) { + const a = turretPts[i]; + const b = turretPts[(i + 1) % 6]; + const mx = (a[0] + b[0]) * 0.5; + const my = (a[1] + b[1]) * 0.5; + const d = Math.sqrt((mx - tHp[0]) ** 2 + (my - tHp[1]) ** 2); + const tBright = Math.max(0, 1 - d / 10); + ctx.fillStyle = tBright > 0.55 ? light : tBright > 0.25 ? midA : midB; + ctx.globalAlpha = 0.7; + ctx.beginPath(); + ctx.moveTo(tHp[0], tHp[1]); + ctx.lineTo(a[0], a[1]); + ctx.lineTo(b[0], b[1]); + ctx.closePath(); + ctx.fill(); + } + ctx.globalAlpha = 1; + + // Turret facet lines + ctx.strokeStyle = 'rgba(255,255,255,0.55)'; + ctx.lineWidth = 0.5; + for (const p of turretPts) { + ctx.beginPath(); ctx.moveTo(tHp[0], tHp[1]); ctx.lineTo(p[0], p[1]); ctx.stroke(); + } + // Turret crisp outline + ctx.strokeStyle = deep; + ctx.lineWidth = 0.7; + ctx.beginPath(); + ctx.moveTo(turretPts[0][0], turretPts[0][1]); + for (let i = 1; i < 6; i++) ctx.lineTo(turretPts[i][0], turretPts[i][1]); + ctx.closePath(); + ctx.stroke(); + + // Turret crown highlight band + ctx.fillStyle = `rgba(255,255,255,${0.25 + breathe * 0.12})`; + ctx.beginPath(); + ctx.ellipse(-1, -3.8, 3.2, 1.3, -0.3, 0, Math.PI * 2); + ctx.fill(); + + // Turret center (small cut-gem apex) + ctx.fillStyle = midB; + ctx.beginPath(); + ctx.arc(tHp[0], tHp[1], 1.2, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#FFFFFF'; + ctx.beginPath(); + ctx.arc(tHp[0] - 0.3, tHp[1] - 0.3, 0.5, 0, Math.PI * 2); + ctx.fill(); + + // ═══════════════════════════════════════════════════════════ + // BARREL — hex-prism gem cannon with metal band + glowing muzzle + // ═══════════════════════════════════════════════════════════ + const barrelBase = -3; // at turret edge + const barrelTip = -s - 4; // doesn't exceed footprint much + const barrelHalfW = 2.2; + + // Barrel body — faceted + ctx.fillStyle = midA; + ctx.beginPath(); + ctx.moveTo(-barrelHalfW, barrelBase); + ctx.lineTo( barrelHalfW, barrelBase); + ctx.lineTo( barrelHalfW * 0.8, barrelTip); + ctx.lineTo(-barrelHalfW * 0.8, barrelTip); + ctx.closePath(); + ctx.fill(); + + // Barrel left/right facet shade + ctx.fillStyle = midB; + ctx.globalAlpha = 0.7; + ctx.beginPath(); + ctx.moveTo( barrelHalfW, barrelBase); + ctx.lineTo( barrelHalfW * 0.8, barrelTip); + ctx.lineTo( 0, barrelTip); + ctx.lineTo( 0, barrelBase); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + + // Barrel center highlight stripe + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(-0.6, barrelBase); + ctx.lineTo(-0.5, barrelTip); + ctx.stroke(); + + // Barrel outline + ctx.strokeStyle = deep; + ctx.lineWidth = 0.7; + ctx.beginPath(); + ctx.moveTo(-barrelHalfW, barrelBase); + ctx.lineTo( barrelHalfW, barrelBase); + ctx.lineTo( barrelHalfW * 0.8, barrelTip); + ctx.lineTo(-barrelHalfW * 0.8, barrelTip); + ctx.closePath(); + ctx.stroke(); + + // Metal collar near turret (dark band) + ctx.fillStyle = deep; + ctx.fillRect(-barrelHalfW - 0.4, barrelBase - 0.4, (barrelHalfW + 0.4) * 2, 1.4); + ctx.fillStyle = '#3E88C8'; + ctx.fillRect(-barrelHalfW - 0.4, barrelBase - 0.1, (barrelHalfW + 0.4) * 2, 0.4); + + // Muzzle ring + ctx.fillStyle = deep; + ctx.fillRect(-barrelHalfW * 0.9, barrelTip - 0.8, barrelHalfW * 1.8, 1.2); + + // Glowing gem muzzle + ctx.save(); + ctx.shadowColor = edge; + ctx.shadowBlur = 5 + breathe * 3; + ctx.fillStyle = light; + ctx.beginPath(); + ctx.arc(0, barrelTip - 0.4, 1.4, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + ctx.fillStyle = '#FFFFFF'; + ctx.beginPath(); + ctx.arc(0, barrelTip - 0.4, 0.7, 0, Math.PI * 2); + ctx.fill(); + + // ═══════════════════════════════════════════════════════════ + // SPARKLES — 4 sparse 4-point stars, very slow twinkle + // ═══════════════════════════════════════════════════════════ + ctx.save(); + const flares = [ + { x: -s + 3, y: -s + 2, phase: 0, len: 2.2 }, + { x: s - 4, y: -4, phase: 2.0, len: 1.8 }, + { x: -4, y: s - 3, phase: 4.0, len: 2.0 }, + { x: s - 2, y: s - 6, phase: 6.0, len: 1.6 }, + ]; + for (const fl of flares) { + const tw = Math.max(0, Math.sin(tt * 0.6 + fl.phase)); + if (tw > 0.65) { + const a = (tw - 0.65) * 3; + const col = `rgba(255,255,255,${Math.min(1, a)})`; + ctx.strokeStyle = col; + ctx.fillStyle = col; + ctx.lineWidth = 0.5; + ctx.beginPath(); ctx.moveTo(fl.x - fl.len, fl.y); ctx.lineTo(fl.x + fl.len, fl.y); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(fl.x, fl.y - fl.len); ctx.lineTo(fl.x, fl.y + fl.len); ctx.stroke(); + ctx.beginPath(); ctx.arc(fl.x, fl.y, 0.6, 0, Math.PI * 2); ctx.fill(); + } + } + ctx.restore(); + + // ── Very subtle shimmer band sliding across body (crystal gleam) ── + ctx.save(); + ctx.globalAlpha = 0.12 * shimmer; + ctx.fillStyle = '#FFFFFF'; + const gleamY = -s + (s * 2) * ((tt * 0.25) % 1); + ctx.beginPath(); + ctx.moveTo(-s + 2, gleamY); + ctx.lineTo( s - 2, gleamY - 3); + ctx.lineTo( s - 2, gleamY - 1); + ctx.lineTo(-s + 2, gleamY + 2); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} + +// --------------------------------------------------------------- +// Dispatcher — the ONE function every caller should use. +// @param {CanvasRenderingContext2D} ctx — already translated+rotated to tank +// @param {string} skinId — 'default', 'arctic', ... 'diamond' +// @param {object|null} colors — { body, turret, track } (null = default) +// @param {number} t — animation timer in seconds +// --------------------------------------------------------------- +const FALLBACK_COLORS = { body: '#FFD700', turret: '#B8860B', track: '#8B6914' }; + +function drawTankSkin(ctx, skinId, colors, t) { + const c = colors || FALLBACK_COLORS; + const bc = c.body || FALLBACK_COLORS.body; + const tc = c.turret || FALLBACK_COLORS.turret; + const kc = c.track || FALLBACK_COLORS.track; + const tt = t || 0; + switch (skinId) { + case 'arctic': return _tankArctic(ctx, bc, tc, kc, tt); + case 'inferno': return _tankInferno(ctx, bc, tc, kc, tt); + case 'phantom': return _tankPhantom(ctx, bc, tc, kc, tt); + case 'jungle': return _tankJungle(ctx, bc, tc, kc, tt); + case 'neon': return _tankNeon(ctx, bc, tc, kc, tt); + case 'nebula': return _tankNebula(ctx, bc, tc, kc, tt); + case 'royal': return _tankRoyal(ctx, bc, tc, kc, tt); + case 'sakura': return _tankSakura(ctx, bc, tc, kc, tt); + case 'thunder': return _tankThunder(ctx, bc, tc, kc, tt); + case 'diamond': return _tankDiamond(ctx, bc, tc, kc, tt); + case 'default': + default: return _tankDefault(ctx, bc, tc, kc, tt); + } +} + +module.exports = { + drawTankSkin, + DESIGN_HALF_SIZE, +}; diff --git a/js/i18n/en.js b/js/i18n/en.js index fd651bb..aba5506 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -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!', diff --git a/js/i18n/zh.js b/js/i18n/zh.js index fdbb754..4144047 100644 --- a/js/i18n/zh.js +++ b/js/i18n/zh.js @@ -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': '✓ 已装备!', diff --git a/js/managers/NetworkManager.js b/js/managers/NetworkManager.js index b340f14..d9d00e7 100644 --- a/js/managers/NetworkManager.js +++ b/js/managers/NetworkManager.js @@ -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} 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; diff --git a/js/managers/PlayerProfile.js b/js/managers/PlayerProfile.js new file mode 100644 index 0000000..3d52e04 --- /dev/null +++ b/js/managers/PlayerProfile.js @@ -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} 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} 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; diff --git a/js/managers/SkinManager.js b/js/managers/SkinManager.js index db47008..76a9766 100644 --- a/js/managers/SkinManager.js +++ b/js/managers/SkinManager.js @@ -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' }; } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 730cd76..d0752d4 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index b031e49..2e4676e 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -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) => { diff --git a/js/scenes/SettingsScene.js b/js/scenes/SettingsScene.js index 6d9dd4a..3b26418 100644 --- a/js/scenes/SettingsScene.js +++ b/js/scenes/SettingsScene.js @@ -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; diff --git a/js/scenes/SkinScene.js b/js/scenes/SkinScene.js index 543b4ac..4394951 100644 --- a/js/scenes/SkinScene.js +++ b/js/scenes/SkinScene.js @@ -1,6 +1,7 @@ /** * SkinScene.js - * Tank skin gallery scene where players can preview, purchase, and equip skins. + * Tank skin gallery — military-tech themed UI. + * Players can preview, purchase, and equip cosmetic tank skins. */ const { @@ -10,27 +11,72 @@ const { SCENE, } = require('../base/GameGlobal'); const { t } = require('../i18n/I18n'); +const { drawTankSkin } = require('../entities/TankSkinRenderer'); -// Layout constants +// ============================================================ +// Layout +// ============================================================ const COLS = 2; -const CARD_GAP = 10; -const CARD_W = Math.min((SCREEN_WIDTH * 0.85 - CARD_GAP) / COLS, 150); -const CARD_H = 100; -const GRID_W = COLS * CARD_W + (COLS - 1) * CARD_GAP; -const GRID_LEFT = (SCREEN_WIDTH - GRID_W) / 2; -const CARDS_START_Y = SCREEN_HEIGHT * 0.18; +const CARD_GAP = 12; +const SIDE_PAD = Math.max(16, (SCREEN_WIDTH - 320) / 2); +const GRID_W = SCREEN_WIDTH - SIDE_PAD * 2; +const CARD_W = (GRID_W - CARD_GAP) / COLS; +const CARD_H = 108; +const HEADER_H = SCREEN_HEIGHT * 0.15; +const CARDS_START_Y = HEADER_H + 8; +const BACK_BTN_W = 110; +const BACK_BTN_H = 38; +// ============================================================ +// Colors & Style +// ============================================================ +const C = { + BG_TOP: '#0b0e17', + BG_BOT: '#141b2d', + HEADER_LINE: '#e94560', + CARD_BG: 'rgba(22, 33, 62, 0.85)', + CARD_BORDER: '#1e3054', + CARD_EQUIPPED_BG: 'rgba(46, 125, 50, 0.18)', + CARD_EQUIPPED_BORDER: '#4CAF50', + CARD_LOCKED_BG: 'rgba(15, 20, 35, 0.9)', + CARD_LOCKED_BORDER: '#2a2a3a', + GOLD: '#FFD700', + GOLD_DIM: '#B8860B', + TEXT_PRIMARY: '#E8E8E8', + TEXT_SECONDARY: '#8899AA', + TEXT_MUTED: '#556677', + GREEN: '#4CAF50', + RED: '#FF4444', + ORANGE: '#FF9800', + BACK_BTN: '#1a2744', + BACK_BTN_BORDER: '#2a4060', + TOAST_BG: 'rgba(0,0,0,0.82)', +}; + +// ============================================================ +// Scene +// ============================================================ const SkinScene = { _buttons: {}, _skinCards: [], _message: '', _messageTimer: 0, + _animTimer: 0, + // Scroll state _scrollY: 0, + _maxScrollY: 0, + _touchStartY: 0, + _touchLastY: 0, + _isDragging: false, + _scrollVelocity: 0, enter() { this._message = ''; this._messageTimer = 0; + this._animTimer = 0; this._scrollY = 0; + this._scrollVelocity = 0; + this._isDragging = false; this._calculateLayout(); }, @@ -46,7 +92,7 @@ const SkinScene = { for (let i = 0; i < skins.length; i++) { const col = i % COLS; const row = Math.floor(i / COLS); - const x = GRID_LEFT + col * (CARD_W + CARD_GAP); + const x = SIDE_PAD + col * (CARD_W + CARD_GAP); const y = CARDS_START_Y + row * (CARD_H + CARD_GAP); this._skinCards.push({ @@ -55,194 +101,1415 @@ const SkinScene = { }); } - // Back button - const backW = 100; - const backH = 36; - const lastRow = Math.ceil(skins.length / COLS); - const backY = CARDS_START_Y + lastRow * (CARD_H + CARD_GAP) + 10; + // Back button — fixed at bottom of screen (not scrollable) + const BACK_BTN_BOTTOM_PAD = 12; this._buttons = { - back: { x: (SCREEN_WIDTH - backW) / 2, y: backY, w: backW, h: backH }, + back: { + x: (SCREEN_WIDTH - BACK_BTN_W) / 2, + y: SCREEN_HEIGHT - BACK_BTN_H - BACK_BTN_BOTTOM_PAD, + w: BACK_BTN_W, + h: BACK_BTN_H, + }, }; + + // Calculate max scroll: total content height vs visible area + // Visible area ends where the fixed back button starts + const visibleBottom = SCREEN_HEIGHT - BACK_BTN_H - BACK_BTN_BOTTOM_PAD - 8; + const lastRow = Math.ceil(skins.length / COLS); + const contentBottom = CARDS_START_Y + lastRow * (CARD_H + CARD_GAP); + this._maxScrollY = Math.max(0, contentBottom - visibleBottom); + // Clamp current scroll + this._scrollY = Math.min(this._scrollY, this._maxScrollY); }, update(dt) { + this._animTimer += dt; if (this._messageTimer > 0) { this._messageTimer -= dt; if (this._messageTimer <= 0) { this._message = ''; } } + // Inertia scrolling + if (!this._isDragging && Math.abs(this._scrollVelocity) > 0.5) { + this._scrollY += this._scrollVelocity * dt; + this._scrollVelocity *= 0.92; // friction + // Clamp + if (this._scrollY < 0) { + this._scrollY = 0; + this._scrollVelocity = 0; + } else if (this._scrollY > this._maxScrollY) { + this._scrollY = this._maxScrollY; + this._scrollVelocity = 0; + } + } else if (!this._isDragging) { + this._scrollVelocity = 0; + } }, + // ============================================================ + // Render + // ============================================================ render(ctx) { - // Background - ctx.fillStyle = COLORS.MENU_BG; - ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); - - // Title - ctx.fillStyle = COLORS.MENU_TITLE; - ctx.font = 'bold 22px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(t('skin.title') || 'Tank Skins', SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.06); - - // Gold balance - const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0; - ctx.fillStyle = '#FFD700'; - ctx.font = 'bold 14px Arial'; - ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.12); + this._drawBackground(ctx); const sm = GameGlobal.skinManager; if (!sm) return; - const equippedId = sm.getEquippedSkinId(); - // Render skin cards + // Header (fixed, not affected by scroll) + this._drawHeader(ctx); + + // Clip scrollable area (between header and back button) + const clipTop = HEADER_H; + const clipBottom = this._buttons.back.y - 4; + ctx.save(); + ctx.beginPath(); + ctx.rect(0, clipTop, SCREEN_WIDTH, clipBottom - clipTop); + ctx.clip(); + + // Apply scroll offset for cards + ctx.save(); + ctx.translate(0, -this._scrollY); + + // Cards for (const card of this._skinCards) { + // Skip cards that are completely off-screen (optimization) + const cardScreenY = card.rect.y - this._scrollY; + if (cardScreenY + card.rect.h < clipTop - 10 || cardScreenY > clipBottom + 10) continue; this._renderSkinCard(ctx, card, sm.isUnlocked(card.id), equippedId === card.id); } - // Back button - this._renderButton(ctx, this._buttons.back, t('common.back') || '← Back', '#666666'); + ctx.restore(); // scroll translate + ctx.restore(); // clip - // Message toast - if (this._message) { - ctx.fillStyle = 'rgba(0,0,0,0.7)'; - const msgW = 200; - const msgH = 30; - const msgX = (SCREEN_WIDTH - msgW) / 2; - const msgY = SCREEN_HEIGHT * 0.92; - ctx.fillRect(msgX, msgY, msgW, msgH); - ctx.fillStyle = '#FFFFFF'; - ctx.font = '13px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(this._message, SCREEN_WIDTH / 2, msgY + msgH / 2); - } + // Back button (fixed at bottom, not affected by scroll) + this._drawBackButton(ctx); + + // Toast (fixed position, not affected by scroll) + this._drawToast(ctx); }, - _renderSkinCard(ctx, card, isUnlocked, isEquipped) { - const { rect } = card; + // ---- Background ---- + _drawBackground(ctx) { + // Gradient background + const grad = ctx.createLinearGradient(0, 0, 0, SCREEN_HEIGHT); + grad.addColorStop(0, C.BG_TOP); + grad.addColorStop(1, C.BG_BOT); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); - // Card background - if (isEquipped) { - ctx.fillStyle = 'rgba(76, 175, 80, 0.3)'; - ctx.strokeStyle = '#4CAF50'; - } else if (isUnlocked) { - ctx.fillStyle = 'rgba(255,255,255,0.08)'; - ctx.strokeStyle = '#666666'; - } else { - ctx.fillStyle = 'rgba(255,255,255,0.03)'; - ctx.strokeStyle = '#444444'; + // Subtle horizontal scan-lines + ctx.globalAlpha = 0.03; + ctx.fillStyle = '#FFFFFF'; + for (let sy = 0; sy < SCREEN_HEIGHT; sy += 4) { + ctx.fillRect(0, sy, SCREEN_WIDTH, 1); } - ctx.lineWidth = isEquipped ? 2.5 : 1.5; + ctx.globalAlpha = 1; + }, - // Rounded rect - const r = 8; + // ---- Header ---- + _drawHeader(ctx) { + // Accent line + const lineGrad = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0); + lineGrad.addColorStop(0, 'transparent'); + lineGrad.addColorStop(0.3, C.HEADER_LINE); + lineGrad.addColorStop(0.7, C.HEADER_LINE); + lineGrad.addColorStop(1, 'transparent'); + ctx.fillStyle = lineGrad; + ctx.fillRect(0, 0, SCREEN_WIDTH, 3); + + // Title + ctx.fillStyle = C.GOLD; + ctx.font = 'bold 20px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const titleY = HEADER_H * 0.38; + ctx.fillText(t('skin.title') || 'Tank Skins', SCREEN_WIDTH / 2, titleY); + + // Decorative dashes beside title + const tw = ctx.measureText(t('skin.title') || 'Tank Skins').width; + ctx.strokeStyle = C.GOLD_DIM; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.5; + const dashW = 30; + const dashY = titleY; + // left ctx.beginPath(); - ctx.moveTo(rect.x + r, rect.y); - ctx.lineTo(rect.x + rect.w - r, rect.y); - ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r); - ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r); - ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r); - ctx.lineTo(rect.x + r, rect.y + rect.h); - ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r); - ctx.lineTo(rect.x, rect.y + r); - ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r); - ctx.closePath(); + ctx.moveTo(SCREEN_WIDTH / 2 - tw / 2 - 12, dashY); + ctx.lineTo(SCREEN_WIDTH / 2 - tw / 2 - 12 - dashW, dashY); + ctx.stroke(); + // right + ctx.beginPath(); + ctx.moveTo(SCREEN_WIDTH / 2 + tw / 2 + 12, dashY); + ctx.lineTo(SCREEN_WIDTH / 2 + tw / 2 + 12 + dashW, dashY); + ctx.stroke(); + ctx.globalAlpha = 1; + + // Gold balance — pill badge + const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0; + const goldText = `🪙 ${gold}`; + ctx.font = 'bold 13px Arial'; + const gtw = ctx.measureText(goldText).width; + const pillW = gtw + 20; + const pillH = 22; + const pillX = (SCREEN_WIDTH - pillW) / 2; + const pillY = HEADER_H * 0.7 - pillH / 2; + + // Pill background + ctx.fillStyle = 'rgba(255, 215, 0, 0.1)'; + ctx.strokeStyle = 'rgba(255, 215, 0, 0.35)'; + ctx.lineWidth = 1; + this._roundRect(ctx, pillX, pillY, pillW, pillH, pillH / 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = C.GOLD; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(goldText, SCREEN_WIDTH / 2, HEADER_H * 0.7); + }, + + // ---- Skin Card ---- + _renderSkinCard(ctx, card, isUnlocked, isEquipped) { + const { rect } = card; + const r = 10; + + // Card shadow + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + this._roundRect(ctx, rect.x + 2, rect.y + 2, rect.w, rect.h, r); + ctx.fill(); + + // Card background + if (isEquipped) { + ctx.fillStyle = C.CARD_EQUIPPED_BG; + ctx.strokeStyle = C.CARD_EQUIPPED_BORDER; + ctx.lineWidth = 2; + } else if (isUnlocked) { + ctx.fillStyle = C.CARD_BG; + ctx.strokeStyle = C.CARD_BORDER; + ctx.lineWidth = 1.5; + } else { + ctx.fillStyle = C.CARD_LOCKED_BG; + ctx.strokeStyle = C.CARD_LOCKED_BORDER; + ctx.lineWidth = 1; + } + + this._roundRect(ctx, rect.x, rect.y, rect.w, rect.h, r); + ctx.fill(); + ctx.stroke(); + + // Equipped glow pulse + if (isEquipped) { + const pulse = 0.12 + Math.sin(this._animTimer * 3) * 0.08; + ctx.save(); + ctx.shadowColor = C.GREEN; + ctx.shadowBlur = 12; + ctx.globalAlpha = pulse; + ctx.strokeStyle = C.GREEN; + ctx.lineWidth = 2; + this._roundRect(ctx, rect.x, rect.y, rect.w, rect.h, r); + ctx.stroke(); + ctx.restore(); + } + const cx = rect.x + rect.w / 2; - // Tank preview (mini tank icon) - this._drawMiniTank(ctx, cx, rect.y + 28, card); + // Tank preview area — subtle dark circle backdrop + const previewY = rect.y + 34; + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ctx.beginPath(); + ctx.arc(cx, previewY, 20, 0, Math.PI * 2); + ctx.fill(); + + // Mini tank + this._drawMiniTank(ctx, cx, previewY, card, isUnlocked); + + // Locked overlay icon + if (!isUnlocked) { + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.beginPath(); + ctx.arc(cx, previewY, 20, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = C.TEXT_MUTED; + ctx.font = '16px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('🔒', cx, previewY); + } // Skin name - ctx.fillStyle = '#FFFFFF'; + ctx.fillStyle = isUnlocked ? C.TEXT_PRIMARY : C.TEXT_SECONDARY; ctx.font = 'bold 12px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(t(card.nameKey) || card.id, cx, rect.y + 58); + ctx.fillText(t(card.nameKey) || card.id, cx, rect.y + 62); - // Status / price + // Status / price tag + const tagY = rect.y + rect.h - 18; if (isEquipped) { - ctx.fillStyle = '#4CAF50'; - ctx.font = 'bold 11px Arial'; - ctx.fillText(t('skin.equipped') || '✓ Equipped', cx, rect.y + rect.h - 16); + // Green badge + const badgeText = t('skin.equipped') || '✓ Equipped'; + ctx.font = 'bold 10px Arial'; + const bw = ctx.measureText(badgeText).width + 14; + const bh = 18; + const bx = cx - bw / 2; + ctx.fillStyle = 'rgba(76, 175, 80, 0.2)'; + ctx.strokeStyle = C.GREEN; + ctx.lineWidth = 1; + this._roundRect(ctx, bx, tagY - bh / 2, bw, bh, bh / 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = C.GREEN; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(badgeText, cx, tagY); } else if (isUnlocked) { - ctx.fillStyle = '#AAAAAA'; - ctx.font = '11px Arial'; - ctx.fillText(t('skin.owned') || 'Owned', cx, rect.y + rect.h - 16); + ctx.fillStyle = C.TEXT_MUTED; + ctx.font = '10px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(t('skin.owned') || 'Owned', cx, tagY); } else { + // Price tag const canAfford = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(card.cost); - ctx.fillStyle = canAfford ? '#FFD700' : '#FF4444'; + const priceText = `🪙 ${card.cost}`; ctx.font = 'bold 11px Arial'; - ctx.fillText(`🪙 ${card.cost}`, cx, rect.y + rect.h - 16); + const pw = ctx.measureText(priceText).width + 14; + const ph = 18; + const px = cx - pw / 2; + ctx.fillStyle = canAfford ? 'rgba(255, 215, 0, 0.12)' : 'rgba(255, 68, 68, 0.12)'; + ctx.strokeStyle = canAfford ? C.GOLD_DIM : C.RED; + ctx.lineWidth = 1; + this._roundRect(ctx, px, tagY - ph / 2, pw, ph, ph / 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = canAfford ? C.GOLD : C.RED; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(priceText, cx, tagY); } }, - /** - * Draw a mini tank preview with skin colors. - */ - _drawMiniTank(ctx, cx, cy, card) { - const size = 14; - const bodyColor = card.colors ? card.colors.body : '#FFD700'; - const turretColor = card.colors ? card.colors.turret : '#B8860B'; - const trackColor = card.colors ? card.colors.track : '#8B6914'; - + // ---- Mini Tank (dispatcher) ---- + // ★ Delegates to the shared TankSkinRenderer — SAME drawing code as the + // in-game Tank.render() path, so preview and battle look identical. + _drawMiniTank(ctx, cx, cy, card, isUnlocked) { ctx.save(); ctx.translate(cx, cy); + if (!isUnlocked) ctx.globalAlpha = 0.35; - // Body - ctx.fillStyle = bodyColor; - ctx.fillRect(-size, -size / 2, size * 2, size); - - // Turret (barrel) - ctx.fillStyle = turretColor; - ctx.fillRect(-2, -size / 2 - 10, 4, 10); - - // Center detail - ctx.fillStyle = turretColor; - ctx.fillRect(-size * 0.3, -size * 0.3, size * 0.6, size * 0.6); - - // Tracks - ctx.fillStyle = trackColor; - ctx.fillRect(-size - 3, -size / 2, 3, size); - ctx.fillRect(size, -size / 2, 3, size); + drawTankSkin(ctx, card.id, card.colors, this._animTimer || 0); ctx.restore(); }, - _renderButton(ctx, rect, label, color) { - if (!rect) return; + // ======== DEFAULT — Classic rounded tank ======== + _tankDefault(ctx, bc, tc, kc) { + const s = 12; + // Tracks + ctx.fillStyle = kc; + this._roundRect(ctx, -s - 4, -s + 1, 4, s * 2 - 2, 2); ctx.fill(); + this._roundRect(ctx, s, -s + 1, 4, s * 2 - 2, 2); ctx.fill(); + // Body + ctx.fillStyle = bc; + this._roundRect(ctx, -s, -s, s * 2, s * 2, 3); ctx.fill(); + // Highlight + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + ctx.fillRect(-s + 2, -s + 2, s - 2, 3); + // Turret base + ctx.fillStyle = tc; + ctx.beginPath(); ctx.arc(0, 0, s * 0.4, 0, Math.PI * 2); ctx.fill(); + // Barrel + ctx.fillStyle = tc; + this._roundRect(ctx, -2.5, -s - 8, 5, s - 2, 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.2)'; + ctx.fillRect(-1.5, -s - 8, 3, 3); + }, - ctx.fillStyle = color; - ctx.strokeStyle = '#333333'; - ctx.lineWidth = 1; - - const r = 6; + // ======== ARCTIC — Ice-crystal armor with snowflake detail ======== + _tankArctic(ctx, bc, tc, kc) { + const s = 12; + // Wide icy tracks with tread marks + ctx.fillStyle = kc; + ctx.fillRect(-s - 5, -s, 5, s * 2); + ctx.fillRect(s, -s, 5, s * 2); + // Tread lines + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; + ctx.lineWidth = 0.8; + for (let ty = -s + 3; ty < s; ty += 4) { + ctx.beginPath(); ctx.moveTo(-s - 5, ty); ctx.lineTo(-s, ty); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s, ty); ctx.lineTo(s + 5, ty); ctx.stroke(); + } + // Body — hexagonal ice shape + ctx.fillStyle = bc; ctx.beginPath(); - ctx.moveTo(rect.x + r, rect.y); - ctx.lineTo(rect.x + rect.w - r, rect.y); - ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r); - ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r); - ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r); - ctx.lineTo(rect.x + r, rect.y + rect.h); - ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r); - ctx.lineTo(rect.x, rect.y + r); - ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r); + ctx.moveTo(0, -s - 2); + ctx.lineTo(s - 1, -s + 4); + ctx.lineTo(s - 1, s - 4); + ctx.lineTo(0, s + 2); + ctx.lineTo(-s + 1, s - 4); + ctx.lineTo(-s + 1, -s + 4); ctx.closePath(); ctx.fill(); + // Ice shimmer + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.beginPath(); + ctx.moveTo(-s + 3, -s + 4); + ctx.lineTo(0, -s - 1); + ctx.lineTo(2, -s + 5); + ctx.closePath(); + ctx.fill(); + // Snowflake center detail (cross + diagonals) + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, -4); ctx.lineTo(0, 4); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-4, 0); ctx.lineTo(4, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-3, -3); ctx.lineTo(3, 3); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(3, -3); ctx.lineTo(-3, 3); ctx.stroke(); + // Turret — diamond shape + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(0, -5); ctx.lineTo(5, 0); ctx.lineTo(0, 5); ctx.lineTo(-5, 0); + ctx.closePath(); + ctx.fill(); + // Barrel — tapered + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(-3, -s + 3); ctx.lineTo(3, -s + 3); + ctx.lineTo(2, -s - 9); ctx.lineTo(-2, -s - 9); + ctx.closePath(); + ctx.fill(); + // Ice tip glow + ctx.fillStyle = 'rgba(176,224,230,0.6)'; + ctx.beginPath(); ctx.arc(0, -s - 9, 2, 0, Math.PI * 2); ctx.fill(); + }, + + // ======== INFERNO — Aggressive angular flame tank ======== + _tankInferno(ctx, bc, tc, kc) { + const s = 12; + // Spiked tracks + ctx.fillStyle = kc; + ctx.fillRect(-s - 4, -s, 4, s * 2); + ctx.fillRect(s, -s, 4, s * 2); + // Track spikes + for (let ty = -s + 2; ty < s - 2; ty += 5) { + ctx.fillStyle = kc; + ctx.beginPath(); + ctx.moveTo(-s - 4, ty); ctx.lineTo(-s - 7, ty + 2.5); ctx.lineTo(-s - 4, ty + 5); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(s + 4, ty); ctx.lineTo(s + 7, ty + 2.5); ctx.lineTo(s + 4, ty + 5); + ctx.fill(); + } + // Body — angular/aggressive shape + ctx.fillStyle = bc; + ctx.beginPath(); + ctx.moveTo(-s + 2, -s - 1); + ctx.lineTo(s - 2, -s - 1); + ctx.lineTo(s + 1, -s + 4); + ctx.lineTo(s + 1, s - 2); + ctx.lineTo(s - 3, s + 1); + ctx.lineTo(-s + 3, s + 1); + ctx.lineTo(-s - 1, s - 2); + ctx.lineTo(-s - 1, -s + 4); + ctx.closePath(); + ctx.fill(); + // Flame stripes on body + ctx.fillStyle = 'rgba(255,165,0,0.5)'; + ctx.beginPath(); + ctx.moveTo(-s + 3, s); ctx.lineTo(-s + 6, 0); ctx.lineTo(-s + 3, -2); + ctx.lineTo(-s + 1, s - 2); + ctx.closePath(); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(s - 3, s); ctx.lineTo(s - 6, 0); ctx.lineTo(s - 3, -2); + ctx.lineTo(s + 1, s - 2); + ctx.closePath(); + ctx.fill(); + // Hot glow center + ctx.fillStyle = 'rgba(255,69,0,0.3)'; + ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI * 2); ctx.fill(); + // Turret — angular pentagon + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(0, -5); ctx.lineTo(5, -2); ctx.lineTo(4, 4); + ctx.lineTo(-4, 4); ctx.lineTo(-5, -2); + ctx.closePath(); + ctx.fill(); + // Barrel — dual cannon + ctx.fillStyle = tc; + this._roundRect(ctx, -4, -s - 10, 3, s - 1, 1); ctx.fill(); + this._roundRect(ctx, 1, -s - 10, 3, s - 1, 1); ctx.fill(); + // Muzzle flash dots + ctx.fillStyle = 'rgba(255,100,0,0.7)'; + ctx.beginPath(); ctx.arc(-2.5, -s - 10, 2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(2.5, -s - 10, 2, 0, Math.PI * 2); ctx.fill(); + }, + + // ======== PHANTOM — Ghostly translucent stealth tank ======== + _tankPhantom(ctx, bc, tc, kc) { + const s = 12; + const t = this._animTimer || 0; + const flicker = 0.6 + Math.sin(t * 5) * 0.15; + ctx.globalAlpha = ctx.globalAlpha * flicker; + // Slim tracks + ctx.fillStyle = kc; + this._roundRect(ctx, -s - 3, -s + 2, 3, s * 2 - 4, 1.5); ctx.fill(); + this._roundRect(ctx, s, -s + 2, 3, s * 2 - 4, 1.5); ctx.fill(); + // Body — sleek elliptical + ctx.fillStyle = bc; + ctx.beginPath(); + ctx.ellipse(0, 0, s - 1, s + 1, 0, 0, Math.PI * 2); + ctx.fill(); + // Ghost aura rings + ctx.strokeStyle = 'rgba(147,112,219,0.3)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.ellipse(0, 0, s + 2, s + 4, 0, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = 'rgba(147,112,219,0.15)'; + ctx.beginPath(); ctx.ellipse(0, 0, s + 5, s + 7, 0, 0, Math.PI * 2); ctx.stroke(); + // Inner glow + ctx.fillStyle = 'rgba(200,180,255,0.2)'; + ctx.beginPath(); ctx.ellipse(0, -2, s * 0.5, s * 0.6, 0, 0, Math.PI * 2); ctx.fill(); + // Turret — crescent shape + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.arc(0, 0, 5, Math.PI * 0.2, Math.PI * 1.8); + ctx.closePath(); + ctx.fill(); + // Barrel — thin and long + ctx.fillStyle = tc; + ctx.fillRect(-1.5, -s - 10, 3, s); + // Phantom tip — glowing orb + ctx.fillStyle = 'rgba(147,112,219,0.8)'; + ctx.beginPath(); ctx.arc(0, -s - 11, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.beginPath(); ctx.arc(0, -s - 11, 1, 0, Math.PI * 2); ctx.fill(); + }, + + // ======== JUNGLE — Camo-patterned heavy tank with wide tracks ======== + _tankJungle(ctx, bc, tc, kc) { + const s = 13; + // Extra-wide tracks with tread pattern + ctx.fillStyle = kc; + this._roundRect(ctx, -s - 6, -s, 6, s * 2, 2); ctx.fill(); + this._roundRect(ctx, s, -s, 6, s * 2, 2); ctx.fill(); + // Tread chevrons + ctx.strokeStyle = 'rgba(0,100,0,0.4)'; + ctx.lineWidth = 1; + for (let ty = -s + 2; ty < s - 2; ty += 4) { + ctx.beginPath(); ctx.moveTo(-s - 6, ty); ctx.lineTo(-s - 3, ty + 2); ctx.lineTo(-s - 6, ty + 4); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 6, ty); ctx.lineTo(s + 3, ty + 2); ctx.lineTo(s + 6, ty + 4); ctx.stroke(); + } + // Body — boxy heavy tank + ctx.fillStyle = bc; + this._roundRect(ctx, -s, -s, s * 2, s * 2, 2); ctx.fill(); + // Camo patches + ctx.fillStyle = 'rgba(0,80,0,0.35)'; + ctx.beginPath(); ctx.arc(-4, -4, 5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(34,60,20,0.3)'; + ctx.beginPath(); ctx.arc(5, 3, 4, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(0,50,0,0.25)'; + ctx.beginPath(); ctx.arc(-2, 6, 3.5, 0, Math.PI * 2); ctx.fill(); + // Leaf detail + ctx.strokeStyle = 'rgba(0,100,0,0.4)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(6, -8); ctx.quadraticCurveTo(10, -6, 7, -3); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(8, -7); ctx.quadraticCurveTo(11, -8, 9, -4); ctx.stroke(); + // Turret — square with rounded corners + ctx.fillStyle = tc; + this._roundRect(ctx, -5, -5, 10, 10, 3); ctx.fill(); + // Turret camo dot + ctx.fillStyle = 'rgba(0,50,0,0.3)'; + ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill(); + // Barrel — thick and short + ctx.fillStyle = tc; + this._roundRect(ctx, -3, -s - 7, 6, s - 3, 2); ctx.fill(); + // Muzzle brake + ctx.fillStyle = kc; + ctx.fillRect(-4, -s - 7, 8, 2); + }, + + // ======== NEON — Glowing wireframe sci-fi tank ======== + _tankNeon(ctx, bc, tc, kc) { + const s = 12; + const t = this._animTimer || 0; + const glow = 0.6 + Math.sin(t * 4) * 0.4; + + // Neon glow aura + ctx.save(); + ctx.shadowColor = bc; + ctx.shadowBlur = 8 * glow; + + // Tracks — glowing lines + ctx.strokeStyle = kc; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(-s - 3, -s); ctx.lineTo(-s - 3, s); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 3, -s); ctx.lineTo(s + 3, s); ctx.stroke(); + // Track end caps + ctx.fillStyle = kc; + ctx.beginPath(); ctx.arc(-s - 3, -s, 2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(-s - 3, s, 2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(s + 3, -s, 2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(s + 3, s, 2, 0, Math.PI * 2); ctx.fill(); + + // Body — wireframe rectangle with glow + ctx.strokeStyle = bc; + ctx.lineWidth = 1.5; + ctx.strokeRect(-s, -s, s * 2, s * 2); + // Diagonal cross wires + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(-s, -s); ctx.lineTo(s, s); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s, -s); ctx.lineTo(-s, s); ctx.stroke(); + // Inner fill (very subtle) + ctx.fillStyle = 'rgba(0,255,127,0.08)'; + ctx.fillRect(-s, -s, s * 2, s * 2); + + // Circuit lines + ctx.strokeStyle = bc; + ctx.lineWidth = 0.6; + ctx.beginPath(); ctx.moveTo(-s, 0); ctx.lineTo(-3, 0); ctx.lineTo(-3, -5); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s, 0); ctx.lineTo(3, 0); ctx.lineTo(3, 5); ctx.stroke(); + + // Turret — glowing ring + ctx.strokeStyle = tc; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI * 2); ctx.stroke(); + // Center dot + ctx.fillStyle = bc; + ctx.beginPath(); ctx.arc(0, 0, 2, 0, Math.PI * 2); ctx.fill(); + + // Barrel — energy beam line + ctx.strokeStyle = tc; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, -5); ctx.lineTo(0, -s - 10); ctx.stroke(); + // Barrel energy nodes + ctx.fillStyle = bc; + ctx.beginPath(); ctx.arc(0, -s - 2, 1.5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(0, -s - 6, 1.5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(0, -s - 10, 2.5, 0, Math.PI * 2); ctx.fill(); + + ctx.restore(); + }, + + // ======== NEBULA — Cosmic purple tank with starfield and magenta energy ======== + _tankNebula(ctx, bc, tc, kc) { + const s = 12; + const t = this._animTimer || 0; + const pulse = 0.7 + Math.sin(t * 3) * 0.3; + + // Cosmic tracks with star dust + ctx.fillStyle = kc; + this._roundRect(ctx, -s - 4, -s + 1, 4, s * 2 - 2, 2); ctx.fill(); + this._roundRect(ctx, s, -s + 1, 4, s * 2 - 2, 2); ctx.fill(); + // Magenta trim on tracks + ctx.strokeStyle = 'rgba(255,0,255,0.45)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(-s - 4, -s + 1); ctx.lineTo(-s - 4, s - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 4, -s + 1); ctx.lineTo(s + 4, s - 1); ctx.stroke(); + + // Body — rounded cosmic hull + ctx.fillStyle = bc; + this._roundRect(ctx, -s, -s, s * 2, s * 2, 5); ctx.fill(); + + // Nebula gradient overlay (top-left bright, bottom-right dark) + ctx.fillStyle = 'rgba(180,0,255,0.15)'; + ctx.beginPath(); + ctx.moveTo(-s, -s); ctx.lineTo(s, -s); ctx.lineTo(-s, s); + ctx.closePath(); + ctx.fill(); + + // Starfield particles (twinkling) + ctx.save(); + const stars = [ + { x: -7, y: -8, size: 1.2, phase: 0 }, + { x: 5, y: -6, size: 0.9, phase: 1.2 }, + { x: -4, y: 3, size: 1.0, phase: 2.4 }, + { x: 8, y: 5, size: 0.8, phase: 3.6 }, + { x: -2, y: -3, size: 1.1, phase: 4.8 }, + { x: 6, y: -1, size: 0.7, phase: 0.8 }, + { x: -8, y: 6, size: 0.9, phase: 2.0 }, + { x: 3, y: 7, size: 1.0, phase: 3.2 }, + ]; + for (const star of stars) { + const twinkle = 0.4 + Math.sin(t * 5 + star.phase) * 0.6; + ctx.globalAlpha = Math.max(0, twinkle); + ctx.fillStyle = '#FFFFFF'; + ctx.beginPath(); + ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2); + ctx.fill(); + // Star cross sparkle for larger stars + if (star.size > 0.9 && twinkle > 0.7) { + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 0.4; + ctx.beginPath(); ctx.moveTo(star.x - 2, star.y); ctx.lineTo(star.x + 2, star.y); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(star.x, star.y - 2); ctx.lineTo(star.x, star.y + 2); ctx.stroke(); + } + } + ctx.restore(); + + // Cosmic swirl (subtle rotating nebula cloud) + ctx.save(); + ctx.globalAlpha = 0.2; + ctx.strokeStyle = '#FF00FF'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let a = 0; a < Math.PI * 4; a += 0.15) { + const r = 2 + a * 1.2; + const sx = Math.cos(a + t * 0.5) * r; + const sy = Math.sin(a + t * 0.5) * r; + if (a === 0) ctx.moveTo(sx, sy); + else ctx.lineTo(sx, sy); + if (r > s - 2) break; + } + ctx.stroke(); + ctx.restore(); + + // Turret — glowing magenta orb + ctx.save(); + ctx.shadowColor = '#FF00FF'; + ctx.shadowBlur = 6 * pulse; + ctx.fillStyle = tc; + ctx.beginPath(); ctx.arc(0, 0, 5.5, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + + // Turret inner ring + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, Math.PI * 2); ctx.stroke(); + + // Turret core — bright white-magenta + ctx.fillStyle = `rgba(255,200,255,${0.5 + pulse * 0.3})`; + ctx.beginPath(); ctx.arc(0, 0, 2, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.beginPath(); ctx.arc(0, 0, 0.8, 0, Math.PI * 2); ctx.fill(); + + // Barrel — cosmic energy beam + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(-3, -5); ctx.lineTo(3, -5); + ctx.lineTo(2, -s - 9); ctx.lineTo(-2, -s - 9); + ctx.closePath(); + ctx.fill(); + + // Barrel energy rings + ctx.strokeStyle = `rgba(255,0,255,${pulse * 0.6})`; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(-3.5, -s); ctx.lineTo(3.5, -s); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-3, -s - 4); ctx.lineTo(3, -s - 4); ctx.stroke(); + + // Barrel tip — bright cosmic flare + ctx.save(); + ctx.shadowColor = '#FF00FF'; + ctx.shadowBlur = 8 * pulse; + ctx.fillStyle = '#FF00FF'; + ctx.beginPath(); ctx.arc(0, -s - 9, 2.8, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.beginPath(); ctx.arc(0, -s - 9, 1.2, 0, Math.PI * 2); ctx.fill(); + }, + + // ======== ROYAL — Ornate golden tank with crown ======== + _tankRoyal(ctx, bc, tc, kc) { + const s = 12; + // Ornate tracks with gold trim + ctx.fillStyle = kc; + this._roundRect(ctx, -s - 5, -s + 1, 5, s * 2 - 2, 2); ctx.fill(); + this._roundRect(ctx, s, -s + 1, 5, s * 2 - 2, 2); ctx.fill(); + // Gold trim lines on tracks + ctx.strokeStyle = 'rgba(255,215,0,0.5)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(-s - 5, -s + 1); ctx.lineTo(-s - 5, s - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 5, -s + 1); ctx.lineTo(s + 5, s - 1); ctx.stroke(); + + // Body — rounded luxury shape + ctx.fillStyle = bc; + this._roundRect(ctx, -s, -s, s * 2, s * 2, 5); ctx.fill(); + + // Royal emblem — shield shape in center + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(-4, -5); + ctx.lineTo(4, -5); + ctx.lineTo(4, 1); + ctx.quadraticCurveTo(4, 5, 0, 7); + ctx.quadraticCurveTo(-4, 5, -4, 1); + ctx.closePath(); + ctx.fill(); + // Emblem inner + ctx.fillStyle = 'rgba(255,215,0,0.6)'; + ctx.beginPath(); + ctx.moveTo(-2, -3); + ctx.lineTo(2, -3); + ctx.lineTo(2, 0); + ctx.quadraticCurveTo(2, 3, 0, 4); + ctx.quadraticCurveTo(-2, 3, -2, 0); + ctx.closePath(); + ctx.fill(); + + // Crown on top of turret + ctx.fillStyle = '#FFD700'; + ctx.beginPath(); + ctx.moveTo(-6, -s + 2); + ctx.lineTo(-5, -s - 2); + ctx.lineTo(-3, -s + 1); + ctx.lineTo(0, -s - 4); + ctx.lineTo(3, -s + 1); + ctx.lineTo(5, -s - 2); + ctx.lineTo(6, -s + 2); + ctx.closePath(); + ctx.fill(); + // Crown jewels + ctx.fillStyle = '#FF0000'; + ctx.beginPath(); ctx.arc(0, -s - 2, 1.2, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#0066FF'; + ctx.beginPath(); ctx.arc(-4, -s, 0.8, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(4, -s, 0.8, 0, Math.PI * 2); ctx.fill(); + + // Barrel — ornate with rings + ctx.fillStyle = tc; + this._roundRect(ctx, -2.5, -s - 10, 5, s - 4, 2); ctx.fill(); + // Barrel rings + ctx.strokeStyle = '#FFD700'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(-3, -s - 3); ctx.lineTo(3, -s - 3); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-3, -s - 7); ctx.lineTo(3, -s - 7); ctx.stroke(); + // Royal tip + ctx.fillStyle = '#FFD700'; + ctx.beginPath(); ctx.arc(0, -s - 10, 2.5, 0, Math.PI * 2); ctx.fill(); + }, + + // ======== SAKURA — Elegant cherry-blossom tank with petal details ======== + _tankSakura(ctx, bc, tc, kc) { + const s = 12; + const t = this._animTimer || 0; + + // Soft rounded tracks with petal-pink trim + ctx.fillStyle = kc; + this._roundRect(ctx, -s - 4, -s + 1, 4, s * 2 - 2, 2); ctx.fill(); + this._roundRect(ctx, s, -s + 1, 4, s * 2 - 2, 2); ctx.fill(); + // Pink trim highlights + ctx.strokeStyle = 'rgba(255,105,180,0.4)'; + ctx.lineWidth = 0.7; + ctx.beginPath(); ctx.moveTo(-s - 4, -s + 1); ctx.lineTo(-s - 4, s - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 4, -s + 1); ctx.lineTo(s + 4, s - 1); ctx.stroke(); + + // Body — soft rounded rectangle + ctx.fillStyle = bc; + this._roundRect(ctx, -s, -s, s * 2, s * 2, 6); ctx.fill(); + + // Petal shimmer overlay + ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.beginPath(); + ctx.moveTo(-s + 2, -s); + ctx.lineTo(s - 2, -s); + ctx.lineTo(-s + 2, -s + 8); + ctx.closePath(); + ctx.fill(); + + // Floating cherry blossom petals (animated) + ctx.save(); + ctx.globalAlpha = 0.6; + const petals = [ + { ox: -6, oy: -7, phase: 0 }, + { ox: 7, oy: -4, phase: 1.5 }, + { ox: -3, oy: 6, phase: 3 }, + { ox: 5, oy: 5, phase: 4.5 }, + ]; + for (const p of petals) { + const drift = Math.sin(t * 2 + p.phase) * 1.5; + const px = p.ox + drift; + const py = p.oy; + ctx.fillStyle = '#FF69B4'; + ctx.beginPath(); + // 5-petal flower shape + for (let i = 0; i < 5; i++) { + const angle = (i / 5) * Math.PI * 2 - Math.PI / 2; + const pr = 2.2; + const fx = px + Math.cos(angle) * pr; + const fy = py + Math.sin(angle) * pr; + ctx.moveTo(fx, fy); + ctx.arc(fx, fy, 1.2, 0, Math.PI * 2); + } + ctx.fill(); + // Flower center + ctx.fillStyle = '#FFE4E1'; + ctx.beginPath(); ctx.arc(px, py, 0.8, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + + // Turret — circular with sakura emblem + ctx.fillStyle = tc; + ctx.beginPath(); ctx.arc(0, 0, 5.5, 0, Math.PI * 2); ctx.fill(); + // Inner petal ring + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, Math.PI * 2); ctx.stroke(); + // Center dot + ctx.fillStyle = '#FFE4E1'; + ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2); ctx.fill(); + + // Barrel — elegant tapered with pink glow + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(-3, -5); ctx.lineTo(3, -5); + ctx.lineTo(2, -s - 9); ctx.lineTo(-2, -s - 9); + ctx.closePath(); + ctx.fill(); + // Barrel tip glow + ctx.fillStyle = 'rgba(255,183,197,0.7)'; + ctx.beginPath(); ctx.arc(0, -s - 9, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.beginPath(); ctx.arc(0, -s - 9, 1.2, 0, Math.PI * 2); ctx.fill(); + }, + + // ======== THUNDER — Electric-blue lightning tank with arc effects ======== + _tankThunder(ctx, bc, tc, kc) { + const s = 12; + const t = this._animTimer || 0; + const flash = 0.7 + Math.sin(t * 8) * 0.3; + + // Heavy electrified tracks + ctx.fillStyle = kc; + this._roundRect(ctx, -s - 5, -s, 5, s * 2, 2); ctx.fill(); + this._roundRect(ctx, s, -s, 5, s * 2, 2); ctx.fill(); + // Electric sparks on tracks + ctx.save(); + ctx.globalAlpha = flash * 0.6; + ctx.strokeStyle = '#00BFFF'; + ctx.lineWidth = 0.8; + for (let ty = -s + 2; ty < s - 2; ty += 5) { + // Left track spark + ctx.beginPath(); + ctx.moveTo(-s - 5, ty); + ctx.lineTo(-s - 3, ty + 1.5); + ctx.lineTo(-s - 5, ty + 3); + ctx.stroke(); + // Right track spark + ctx.beginPath(); + ctx.moveTo(s + 5, ty); + ctx.lineTo(s + 3, ty + 1.5); + ctx.lineTo(s + 5, ty + 3); + ctx.stroke(); + } + ctx.restore(); + + // Body — angular armored shape + ctx.fillStyle = bc; + ctx.beginPath(); + ctx.moveTo(-s + 1, -s - 2); + ctx.lineTo(s - 1, -s - 2); + ctx.lineTo(s + 2, -s + 3); + ctx.lineTo(s + 2, s - 3); + ctx.lineTo(s - 1, s + 2); + ctx.lineTo(-s + 1, s + 2); + ctx.lineTo(-s - 2, s - 3); + ctx.lineTo(-s - 2, -s + 3); + ctx.closePath(); + ctx.fill(); + + // Lightning bolt emblem on body + ctx.fillStyle = 'rgba(0,191,255,0.5)'; + ctx.beginPath(); + ctx.moveTo(-1, -7); + ctx.lineTo(3, -7); + ctx.lineTo(0, -1); + ctx.lineTo(4, -1); + ctx.lineTo(-2, 8); + ctx.lineTo(0, 2); + ctx.lineTo(-3, 2); + ctx.closePath(); + ctx.fill(); + + // Electric aura glow (pulsing) + ctx.save(); + ctx.shadowColor = '#00BFFF'; + ctx.shadowBlur = 6 * flash; + + // Body edge glow + ctx.strokeStyle = 'rgba(0,191,255,0.35)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(-s + 1, -s - 2); + ctx.lineTo(s - 1, -s - 2); + ctx.lineTo(s + 2, -s + 3); + ctx.lineTo(s + 2, s - 3); + ctx.lineTo(s - 1, s + 2); + ctx.lineTo(-s + 1, s + 2); + ctx.lineTo(-s - 2, s - 3); + ctx.lineTo(-s - 2, -s + 3); + ctx.closePath(); + ctx.stroke(); + + // Turret — hexagonal with energy core + ctx.fillStyle = tc; + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = (i / 6) * Math.PI * 2 - Math.PI / 6; + const hx = Math.cos(angle) * 5.5; + const hy = Math.sin(angle) * 5.5; + if (i === 0) ctx.moveTo(hx, hy); + else ctx.lineTo(hx, hy); + } + ctx.closePath(); + ctx.fill(); + + // Energy core (pulsing bright center) + ctx.fillStyle = `rgba(0,191,255,${0.4 + flash * 0.3})`; + ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = `rgba(255,255,255,${0.5 + flash * 0.3})`; + ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2); ctx.fill(); + + // Barrel — energy cannon with lightning nodes + ctx.fillStyle = tc; + this._roundRect(ctx, -2.5, -s - 10, 5, s - 3, 2); ctx.fill(); + + // Lightning arcs along barrel + ctx.strokeStyle = `rgba(0,191,255,${flash * 0.8})`; + ctx.lineWidth = 1; + // Left arc + ctx.beginPath(); + ctx.moveTo(-2.5, -s); ctx.lineTo(-5, -s - 3); + ctx.lineTo(-2.5, -s - 5); ctx.lineTo(-5, -s - 8); + ctx.stroke(); + // Right arc + ctx.beginPath(); + ctx.moveTo(2.5, -s - 1); ctx.lineTo(5, -s - 4); + ctx.lineTo(2.5, -s - 6); ctx.lineTo(5, -s - 9); + ctx.stroke(); + + // Barrel tip — bright energy ball + ctx.fillStyle = '#00BFFF'; + ctx.beginPath(); ctx.arc(0, -s - 10, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.beginPath(); ctx.arc(0, -s - 10, 1.5, 0, Math.PI * 2); ctx.fill(); + + ctx.restore(); + }, + + // ======== DIAMOND — Ultra-luxurious brilliant-cut crystalline tank ======== + // Design: Multi-layered prismatic facets with rainbow fire dispersion, + // rotating spectral rays, breathing aurora glow, and diamond dust particles. + // ======== DIAMOND 💎 — Elegant brilliant-cut gem tank ======== + // Inspired by the 💎 emoji: clean geometric facets, ice-blue tones, + // gentle luxurious glow — NOT frantic flashing. Slow, elegant, premium. + _tankDiamond(ctx, bc, tc, kc) { + const s = 12; + const t = this._animTimer || 0; + // ★ All animation speeds deliberately slow for elegance + const breathe = 0.5 + Math.sin(t * 0.8) * 0.5; // very slow breathing (~8s cycle) + const shimmer = 0.7 + Math.sin(t * 1.2) * 0.3; // gentle shimmer (~5s cycle) + const rotPhase = t * 0.15; // ultra-slow rotation + + // ── SOFT OUTER GLOW (subtle ice-blue aura) ── + ctx.save(); + ctx.globalAlpha = 0.06 + breathe * 0.04; + ctx.fillStyle = 'rgba(125, 249, 255, 0.2)'; + ctx.beginPath(); ctx.arc(0, -2, 20, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(180, 220, 255, 0.15)'; + ctx.beginPath(); ctx.arc(0, -2, 16, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + + // ── SLOW ROTATING PRISMATIC RAYS (very subtle, 6 rays) ── + ctx.save(); + ctx.globalAlpha = 0.04 + breathe * 0.03; + const rayColors = ['#FF9AA2', '#FFD6A5', '#CAFFBF', '#9BF6FF', '#BDB2FF', '#FFC6FF']; + for (let i = 0; i < 6; i++) { + const angle = rotPhase + (i / 6) * Math.PI * 2; + const rayLen = 15 + Math.sin(t * 0.5 + i * 1.0) * 3; + ctx.strokeStyle = rayColors[i]; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(Math.cos(angle) * 4, Math.sin(angle) * 4 - 2); + ctx.lineTo(Math.cos(angle) * rayLen, Math.sin(angle) * rayLen - 2); + ctx.stroke(); + } + ctx.restore(); + + // ── CRYSTAL TRACKS ── + ctx.fillStyle = kc; + this._roundRect(ctx, -s - 5, -s + 1, 5, s * 2 - 2, 2); ctx.fill(); + this._roundRect(ctx, s, -s + 1, 5, s * 2 - 2, 2); ctx.fill(); + // Gentle edge highlight (steady ice-blue, barely pulsing) + ctx.strokeStyle = `rgba(125, 249, 255, ${0.25 + breathe * 0.1})`; + ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(-s - 5, -s + 1); ctx.lineTo(-s - 5, s - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-s, -s + 1); ctx.lineTo(-s, s - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 5, -s + 1); ctx.lineTo(s + 5, s - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s, -s + 1); ctx.lineTo(s, s - 1); ctx.stroke(); + // Track crystal segments + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 0.5; + for (let ty = -s + 4; ty < s - 2; ty += 4) { + ctx.beginPath(); ctx.moveTo(-s - 5, ty); ctx.lineTo(-s, ty); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s, ty); ctx.lineTo(s + 5, ty); ctx.stroke(); + } + + // ── BODY — 💎 Brilliant-cut gem shape (12-sided) ── + ctx.fillStyle = bc; + ctx.beginPath(); + ctx.moveTo(-s + 4, -s - 2); + ctx.lineTo(s - 4, -s - 2); + ctx.lineTo(s, -s + 2); + ctx.lineTo(s + 2, -s + 6); + ctx.lineTo(s + 2, s - 6); + ctx.lineTo(s, s - 2); + ctx.lineTo(s - 4, s + 2); + ctx.lineTo(-s + 4, s + 2); + ctx.lineTo(-s, s - 2); + ctx.lineTo(-s - 2, s - 6); + ctx.lineTo(-s - 2, -s + 6); + ctx.lineTo(-s, -s + 2); + ctx.closePath(); + ctx.fill(); + + // ── FACET STRUCTURE (static crystal lines — no animation) ── + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 0.5; + // Girdle line + ctx.beginPath(); ctx.moveTo(-s - 2, 0); ctx.lineTo(s + 2, 0); ctx.stroke(); + // Crown facets + ctx.beginPath(); ctx.moveTo(-s + 4, -s - 2); ctx.lineTo(0, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s - 4, -s - 2); ctx.lineTo(0, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-s - 2, -s + 6); ctx.lineTo(0, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 2, -s + 6); ctx.lineTo(0, 0); ctx.stroke(); + // Pavilion facets + ctx.beginPath(); ctx.moveTo(-s + 4, s + 2); ctx.lineTo(0, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s - 4, s + 2); ctx.lineTo(0, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-s - 2, s - 6); ctx.lineTo(0, 0); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s + 2, s - 6); ctx.lineTo(0, 0); ctx.stroke(); + // Secondary facets + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.beginPath(); ctx.moveTo(-s, -s + 2); ctx.lineTo(0, -4); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s, -s + 2); ctx.lineTo(0, -4); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-s, s - 2); ctx.lineTo(0, 4); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(s, s - 2); ctx.lineTo(0, 4); ctx.stroke(); + + // ── FACET COLOR FILLS (very slow spectral drift — like real diamond fire) ── + ctx.save(); + const facetData = [ + { pts: [[-s + 4, -s - 2], [s - 4, -s - 2], [0, 0]], hueBase: 200 }, + { pts: [[-s + 4, -s - 2], [-s, -s + 2], [0, 0]], hueBase: 220 }, + { pts: [[s - 4, -s - 2], [s, -s + 2], [0, 0]], hueBase: 180 }, + { pts: [[-s, -s + 2], [-s - 2, -s + 6], [0, 0]], hueBase: 240 }, + { pts: [[s, -s + 2], [s + 2, -s + 6], [0, 0]], hueBase: 160 }, + { pts: [[-s + 4, s + 2], [s - 4, s + 2], [0, 0]], hueBase: 260 }, + { pts: [[-s + 4, s + 2], [-s, s - 2], [0, 0]], hueBase: 280 }, + { pts: [[s - 4, s + 2], [s, s - 2], [0, 0]], hueBase: 300 }, + { pts: [[-s, s - 2], [-s - 2, s - 6], [0, 0]], hueBase: 320 }, + { pts: [[s, s - 2], [s + 2, s - 6], [0, 0]], hueBase: 340 }, + ]; + for (const facet of facetData) { + // ★ Very slow hue drift: t * 8 (was t * 40) + const hue = (facet.hueBase + t * 8) % 360; + // ★ Very slow alpha pulse: t * 0.6 (was t * 3) + const facetAlpha = 0.05 + Math.sin(t * 0.6 + facet.hueBase * 0.01) * 0.03; + ctx.globalAlpha = facetAlpha; + ctx.fillStyle = `hsla(${hue}, 70%, 75%, 1)`; + ctx.beginPath(); + ctx.moveTo(facet.pts[0][0], facet.pts[0][1]); + ctx.lineTo(facet.pts[1][0], facet.pts[1][1]); + ctx.lineTo(facet.pts[2][0], facet.pts[2][1]); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); + + // ── TOP CROWN HIGHLIGHT (the "table" — gentle breathing) ── + ctx.save(); + ctx.globalAlpha = 0.15 + breathe * 0.1; + ctx.fillStyle = '#FFFFFF'; + ctx.beginPath(); + ctx.moveTo(-s + 4, -s - 2); + ctx.lineTo(s - 4, -s - 2); + ctx.lineTo(s * 0.3, -s * 0.3); + ctx.lineTo(-s * 0.3, -s * 0.3); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + + // ── DIAMOND DUST PARTICLES (slow, sparse twinkle) ── + ctx.save(); + const dustParticles = [ + { x: -8, y: -9, size: 0.7, speed: 1.5, phase: 0 }, + { x: 9, y: -6, size: 0.5, speed: 1.2, phase: 1.5 }, + { x: -6, y: 7, size: 0.6, speed: 1.0, phase: 3.0 }, + { x: 7, y: 8, size: 0.5, speed: 1.3, phase: 4.5 }, + { x: -10, y: 0, size: 0.5, speed: 0.8, phase: 6.0 }, + { x: 10, y: -2, size: 0.6, speed: 1.1, phase: 2.0 }, + ]; + for (const d of dustParticles) { + // ★ Slow twinkle with long visible duration + const twinkle = Math.max(0, Math.sin(t * d.speed + d.phase)); + if (twinkle > 0.4) { + ctx.globalAlpha = (twinkle - 0.4) * 1.2; + ctx.fillStyle = '#FFFFFF'; + ctx.beginPath(); ctx.arc(d.x, d.y, d.size, 0, Math.PI * 2); ctx.fill(); + } + } + ctx.restore(); + + // ── SPARKLE STARS (slow, elegant cross flares — only 4, staggered) ── + ctx.save(); + const flares = [ + { x: -5, y: -7, phase: 0, len: 2.5 }, + { x: 7, y: -4, phase: 2.5, len: 2 }, + { x: -3, y: 6, phase: 5.0, len: 2.2 }, + { x: 6, y: 5, phase: 7.5, len: 1.8 }, + ]; + for (const fl of flares) { + // ★ Very slow twinkle: t * 0.8 (was t * 4) + const twinkle = Math.max(0, Math.sin(t * 0.8 + fl.phase)); + if (twinkle > 0.6) { + const alpha = (twinkle - 0.6) * 2.5; + const color = `rgba(200, 240, 255, ${alpha * 0.8})`; + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 0.5; + // 4-point star cross + ctx.beginPath(); ctx.moveTo(fl.x - fl.len, fl.y); ctx.lineTo(fl.x + fl.len, fl.y); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(fl.x, fl.y - fl.len); ctx.lineTo(fl.x, fl.y + fl.len); ctx.stroke(); + // Center dot + ctx.beginPath(); ctx.arc(fl.x, fl.y, 0.6, 0, Math.PI * 2); ctx.fill(); + } + } + ctx.restore(); + + // ── BODY EDGE GLOW (gentle ice-blue border) ── + ctx.save(); + ctx.shadowColor = '#7DF9FF'; + ctx.shadowBlur = 6 + breathe * 4; + ctx.strokeStyle = `rgba(125, 249, 255, ${0.2 + breathe * 0.15})`; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(-s + 4, -s - 2); ctx.lineTo(s - 4, -s - 2); + ctx.lineTo(s, -s + 2); ctx.lineTo(s + 2, -s + 6); + ctx.lineTo(s + 2, s - 6); ctx.lineTo(s, s - 2); + ctx.lineTo(s - 4, s + 2); ctx.lineTo(-s + 4, s + 2); + ctx.lineTo(-s, s - 2); ctx.lineTo(-s - 2, s - 6); + ctx.lineTo(-s - 2, -s + 6); ctx.lineTo(-s, -s + 2); + ctx.closePath(); + ctx.stroke(); + ctx.restore(); + // Inner crisp edge + ctx.strokeStyle = `rgba(255, 255, 255, ${0.1 + shimmer * 0.05})`; + ctx.lineWidth = 0.4; + ctx.beginPath(); + ctx.moveTo(-s + 4, -s - 2); ctx.lineTo(s - 4, -s - 2); + ctx.lineTo(s, -s + 2); ctx.lineTo(s + 2, -s + 6); + ctx.lineTo(s + 2, s - 6); ctx.lineTo(s, s - 2); + ctx.lineTo(s - 4, s + 2); ctx.lineTo(-s + 4, s + 2); + ctx.lineTo(-s, s - 2); ctx.lineTo(-s - 2, s - 6); + ctx.lineTo(-s - 2, -s + 6); ctx.lineTo(-s, -s + 2); + ctx.closePath(); + ctx.stroke(); + + // ── TURRET — 💎 Gem top view (10-pointed star) ── + ctx.save(); + ctx.shadowColor = '#7DF9FF'; + ctx.shadowBlur = 5 + breathe * 3; + ctx.fillStyle = tc; + ctx.beginPath(); + for (let i = 0; i < 10; i++) { + const angle = (i / 10) * Math.PI * 2 - Math.PI / 2; + const r = i % 2 === 0 ? 7 : 4; + const hx = Math.cos(angle) * r; + const hy = Math.sin(angle) * r; + if (i === 0) ctx.moveTo(hx, hy); + else ctx.lineTo(hx, hy); + } + ctx.closePath(); + ctx.fill(); + ctx.restore(); + + // Turret facet lines + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; + ctx.lineWidth = 0.4; + for (let i = 0; i < 10; i += 2) { + const angle = (i / 10) * Math.PI * 2 - Math.PI / 2; + const hx = Math.cos(angle) * 7; + const hy = Math.sin(angle) * 7; + ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(hx, hy); ctx.stroke(); + } + + // Turret edge — gentle ice-blue tint (steady, not rainbow) + ctx.save(); + ctx.strokeStyle = `rgba(125, 249, 255, ${0.2 + breathe * 0.1})`; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i < 10; i++) { + const angle = (i / 10) * Math.PI * 2 - Math.PI / 2; + const r = i % 2 === 0 ? 7 : 4; + const hx = Math.cos(angle) * r; + const hy = Math.sin(angle) * r; + if (i === 0) ctx.moveTo(hx, hy); + else ctx.lineTo(hx, hy); + } + ctx.closePath(); + ctx.stroke(); + ctx.restore(); + + // Turret center — white core + ctx.save(); + ctx.shadowColor = '#FFFFFF'; + ctx.shadowBlur = 4 + breathe * 2; + ctx.fillStyle = `rgba(255,255,255,${0.5 + breathe * 0.2})`; + ctx.beginPath(); ctx.arc(0, 0, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + ctx.fillStyle = '#FFFFFF'; + ctx.beginPath(); ctx.arc(0, 0, 1.2, 0, Math.PI * 2); ctx.fill(); + + // ── BARREL — Clean crystalline cannon ── + ctx.fillStyle = tc; + ctx.beginPath(); + ctx.moveTo(-3.5, -7); ctx.lineTo(3.5, -7); + ctx.lineTo(2.5, -s - 11); ctx.lineTo(-2.5, -s - 11); + ctx.closePath(); + ctx.fill(); + + // Barrel center line + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); ctx.moveTo(0, -7); ctx.lineTo(0, -s - 11); ctx.stroke(); + + // Barrel rings (steady ice-blue, gentle pulse) + const ringPositions = [-s + 1, -s - 3, -s - 7]; + for (let ri = 0; ri < ringPositions.length; ri++) { + const ry = ringPositions[ri]; + // ★ Slow pulse: t * 1.0 (was t * 5) + const ringAlpha = 0.3 + Math.sin(t * 1.0 + ri * 2.0) * 0.15; + ctx.strokeStyle = `rgba(125, 249, 255, ${ringAlpha})`; + ctx.lineWidth = 1; + const halfW = 3.5 - ri * 0.3; + ctx.beginPath(); ctx.moveTo(-halfW, ry); ctx.lineTo(halfW, ry); ctx.stroke(); + } + + // ── BARREL TIP — 💎 Diamond shape muzzle ── + ctx.save(); + ctx.shadowColor = '#7DF9FF'; + ctx.shadowBlur = 8 + breathe * 4; + ctx.fillStyle = `rgba(125, 249, 255, ${0.6 + breathe * 0.2})`; + ctx.beginPath(); + ctx.moveTo(0, -s - 15); ctx.lineTo(4, -s - 11); + ctx.lineTo(0, -s - 7); ctx.lineTo(-4, -s - 11); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + + // Tip facet lines + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 0.4; + ctx.beginPath(); ctx.moveTo(0, -s - 15); ctx.lineTo(0, -s - 7); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(-4, -s - 11); ctx.lineTo(4, -s - 11); ctx.stroke(); + + // Tip center — white core + ctx.save(); + ctx.shadowColor = '#FFFFFF'; + ctx.shadowBlur = 4 + shimmer * 2; + ctx.fillStyle = '#FFFFFF'; + ctx.beginPath(); ctx.arc(0, -s - 11, 1.2, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + + // ── SUBTLE SPECTRAL BEAMS from tip (very faint) ── + ctx.save(); + ctx.globalAlpha = 0.06 + breathe * 0.04; + const beamColors = ['#FF9AA2', '#CAFFBF', '#9BF6FF', '#BDB2FF', '#FFC6FF']; + for (let bi = 0; bi < beamColors.length; bi++) { + const spread = (bi - 2) * 2; + ctx.strokeStyle = beamColors[bi]; + ctx.lineWidth = 0.6; + ctx.beginPath(); + ctx.moveTo(0, -s - 15); + ctx.lineTo(spread, -s - 19); + ctx.stroke(); + } + ctx.restore(); + }, + + // ---- Back Button ---- + _drawBackButton(ctx) { + const btn = this._buttons.back; + if (!btn) return; + + const r = btn.h / 2; + + // Shadow + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + this._roundRect(ctx, btn.x + 1, btn.y + 2, btn.w, btn.h, r); + ctx.fill(); + + // Button body + ctx.fillStyle = C.BACK_BTN; + ctx.strokeStyle = C.BACK_BTN_BORDER; + ctx.lineWidth = 1.5; + this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r); + ctx.fill(); ctx.stroke(); - ctx.fillStyle = '#FFFFFF'; - ctx.font = 'bold 14px Arial'; + // Label + ctx.fillStyle = C.TEXT_SECONDARY; + ctx.font = 'bold 13px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2); + ctx.fillText( + t('common.back') || '← Back', + btn.x + btn.w / 2, + btn.y + btn.h / 2 + ); }, + // ---- Toast ---- + _drawToast(ctx) { + if (!this._message) return; + + // Fade calculation + const alpha = Math.min(1, this._messageTimer / 0.3); + ctx.globalAlpha = alpha; + + const msgW = 200; + const msgH = 32; + const msgX = (SCREEN_WIDTH - msgW) / 2; + const msgY = SCREEN_HEIGHT * 0.9; + + // Toast background + ctx.fillStyle = C.TOAST_BG; + this._roundRect(ctx, msgX, msgY, msgW, msgH, msgH / 2); + ctx.fill(); + + // Toast border + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; + ctx.lineWidth = 1; + this._roundRect(ctx, msgX, msgY, msgW, msgH, msgH / 2); + ctx.stroke(); + + // Toast text + ctx.fillStyle = '#FFFFFF'; + ctx.font = '12px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(this._message, SCREEN_WIDTH / 2, msgY + msgH / 2); + + ctx.globalAlpha = 1; + }, + + // ---- Utility: Rounded Rect ---- + _roundRect(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(); + }, + + // ============================================================ + // Touch + // ============================================================ _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; @@ -254,45 +1521,96 @@ const SkinScene = { }, handleTouch(eventType, e) { - if (eventType !== 'touchstart') return; - - const touch = e.touches[0]; - const tx = touch.clientX; - const ty = touch.clientY; const sm = GameGlobal.skinManager; if (!sm) return; - // Check skin cards - for (const card of this._skinCards) { - if (this._hitTest(tx, ty, card.rect)) { - if (sm.isUnlocked(card.id)) { - // Equip - if (sm.getEquippedSkinId() !== card.id) { - const result = sm.equipSkin(card.id); - if (result.success) { - this._showMessage(t('skin.equipSuccess') || '✓ Skin equipped!'); - } - } - } else { - // Purchase - const result = sm.purchaseSkin(card.id); - if (result.success) { - this._showMessage(t('skin.purchaseSuccess') || '✓ Skin unlocked!'); - // Auto-equip after purchase - sm.equipSkin(card.id); - } else if (result.error === 'Insufficient gold') { - this._showMessage(t('currency.insufficient') || 'Insufficient Gold'); - } - } - return; - } + if (eventType === 'touchstart') { + const touch = e.touches[0]; + this._touchStartY = touch.clientY; + this._touchLastY = touch.clientY; + this._isDragging = false; + this._scrollVelocity = 0; + this._touchStartTime = Date.now(); + return; } - // Back button - if (this._hitTest(tx, ty, this._buttons.back)) { - GameGlobal.sceneManager.switchTo(SCENE.MENU); + if (eventType === 'touchmove') { + const touch = e.touches[0]; + const dy = this._touchLastY - touch.clientY; + const totalDy = Math.abs(touch.clientY - this._touchStartY); + + // Start dragging if moved more than 5px + if (totalDy > 5) { + this._isDragging = true; + } + + if (this._isDragging) { + this._scrollY += dy; + // Clamp with elastic overscroll feel + if (this._scrollY < 0) this._scrollY = 0; + if (this._scrollY > this._maxScrollY) this._scrollY = this._maxScrollY; + + // Track velocity for inertia + const now = Date.now(); + const elapsed = now - (this._touchMoveTime || now); + if (elapsed > 0) { + this._scrollVelocity = (dy / elapsed) * 1000; // px per second + } + this._touchMoveTime = now; + } + + this._touchLastY = touch.clientY; return; } + + if (eventType === 'touchend') { + // If was dragging, just release with inertia + if (this._isDragging) { + this._isDragging = false; + // Velocity is already set from touchmove + return; + } + + // Not dragging — treat as tap + this._isDragging = false; + const touch = e.changedTouches[0]; + const tx = touch.clientX; + const rawTy = touch.clientY; + + // Back button uses screen coordinates (fixed position) + if (this._hitTest(tx, rawTy, this._buttons.back)) { + GameGlobal.sceneManager.switchTo(SCENE.MENU); + return; + } + + // Adjust tap Y by scroll offset for scrollable content + const ty = rawTy + this._scrollY; + + // Check skin cards + for (const card of this._skinCards) { + if (this._hitTest(tx, ty, card.rect)) { + if (sm.isUnlocked(card.id)) { + // Equip + if (sm.getEquippedSkinId() !== card.id) { + const result = sm.equipSkin(card.id); + if (result.success) { + this._showMessage(t('skin.equipSuccess') || '✓ Skin equipped!'); + } + } + } else { + // Purchase + const result = sm.purchaseSkin(card.id); + if (result.success) { + this._showMessage(t('skin.purchaseSuccess') || '✓ Skin unlocked!'); + sm.equipSkin(card.id); + } else if (result.error === 'Insufficient gold') { + this._showMessage(t('currency.insufficient') || 'Insufficient Gold'); + } + } + return; + } + } + } }, }; diff --git a/js/scenes/TeamGameScene.js b/js/scenes/TeamGameScene.js index e6cbd2c..3bee356 100644 --- a/js/scenes/TeamGameScene.js +++ b/js/scenes/TeamGameScene.js @@ -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; diff --git a/js/scenes/TeamResultScene.js b/js/scenes/TeamResultScene.js index 298844b..545f9d1 100644 --- a/js/scenes/TeamResultScene.js +++ b/js/scenes/TeamResultScene.js @@ -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; diff --git a/js/scenes/TeamRoomScene.js b/js/scenes/TeamRoomScene.js index 055a8c4..4aa3598 100644 --- a/js/scenes/TeamRoomScene.js +++ b/js/scenes/TeamRoomScene.js @@ -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; diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..09d6184 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.DS_Store +.git +.gitignore +Dockerfile +.dockerignore +*.md diff --git a/server/DEPLOYMENT_GUIDE.md b/server/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..32c28fa --- /dev/null +++ b/server/DEPLOYMENT_GUIDE.md @@ -0,0 +1,256 @@ +# Tank War Server - 完整部署指南 + +## 概述 + +本指南详细说明如何将Tank War Server部署到由3台CVM组成的Kubernetes集群中。 + +## 集群信息 + +目标K8s集群由以下3台CVM组成: +- 43.139.80.61 (host_172.16.16.16) +- 43.138.255.42 (host_172.16.16.17) +- 159.75.104.221 (host_172.16.16.8) + +SSH连接:`ssh root@host_172.16.16.16` + +## 部署前准备 + +### 1. 环境检查 + +```bash +# 在server目录下运行测试脚本 +cd /Users/hanchengxi/workspace/tankwar_proj/server +./test-deployment.sh +``` + +### 2. 配置Kubernetes访问 + +确保kubectl已配置连接到目标集群: + +```bash +# 检查集群连接 +kubectl cluster-info + +# 查看当前上下文 +kubectl config current-context + +# 如果未配置,需要获取集群的kubeconfig文件 +# 通常从集群管理员处获取或通过云平台控制台下载 +``` + +## 部署步骤 + +### 步骤1:构建Docker镜像 + +```bash +# 在server目录下构建镜像 +docker build -t tankwar-server:latest . + +# 验证镜像构建成功 +docker images | grep tankwar-server +``` + +### 步骤2:部署到Kubernetes + +```bash +# 方法1:使用部署脚本(推荐) +chmod +x deploy.sh +./deploy.sh + +# 方法2:手动部署 +kubectl create namespace tankwar --dry-run=client -o yaml | kubectl apply -f - +kubectl apply -f k8s-deployment.yaml -n tankwar +``` + +### 步骤3:验证部署 + +```bash +# 运行验证脚本 +chmod +x verify-deployment.sh +./verify-deployment.sh + +# 或手动验证 +kubectl get all -n tankwar +kubectl logs -l app=tankwar-server -n tankwar +``` + +## 配置文件说明 + +### Dockerfile +- 基于Node.js 18 Alpine镜像 +- 暴露端口3000 +- 生产环境配置 + +### k8s-deployment.yaml +包含以下Kubernetes资源: + +1. **ConfigMap**: 环境变量配置 +2. **Deployment**: + - 3个副本 + - 资源限制:内存512Mi,CPU 500m + - 健康检查探针 +3. **Service**: + - LoadBalancer类型 + - 端口3000 + +## 网络配置 + +### 服务暴露 + +服务使用LoadBalancer类型,将通过云平台的负载均衡器暴露: + +```bash +# 获取外部IP +kubectl get svc tankwar-server-service -n tankwar + +# WebSocket连接地址 +ws://:3000 +``` + +### 端口映射 + +- 容器端口:3000 +- 服务端口:3000 +- 外部访问端口:3000 + +## 健康检查 + +服务器提供HTTP健康检查端点: + +```bash +# 健康检查URL +http://:3000/health + +# 返回JSON格式的健康状态 +{ + "status": "healthy", + "timestamp": "2024-01-01T00:00:00.000Z", + "activeConnections": 0, + "activeRooms": 0, + "activeTeamRooms": 0 +} +``` + +## 监控和维护 + +### 查看状态 + +```bash +# 查看Pod状态 +kubectl get pods -n tankwar -w + +# 查看服务状态 +kubectl get svc -n tankwar + +# 查看日志 +kubectl logs -l app=tankwar-server -n tankwar --tail=50 + +# 查看资源使用 +kubectl top pods -n tankwar +``` + +### 扩展和伸缩 + +```bash +# 扩展副本数量 +kubectl scale deployment/tankwar-server --replicas=5 -n tankwar + +# 自动伸缩(如果配置了HPA) +kubectl autoscale deployment/tankwar-server --min=3 --max=10 --cpu-percent=80 -n tankwar +``` + +### 更新部署 + +```bash +# 重新构建镜像 +docker build -t tankwar-server:latest . + +# 滚动更新 +kubectl rollout restart deployment/tankwar-server -n tankwar + +# 查看更新状态 +kubectl rollout status deployment/tankwar-server -n tankwar +``` + +## 故障排除 + +### 常见问题 + +1. **镜像构建失败** + ```bash + # 检查Docker守护进程 + docker info + + # 检查Dockerfile语法 + docker build --no-cache -t tankwar-server:latest . + ``` + +2. **Pod无法启动** + ```bash + # 查看Pod详情 + kubectl describe pod -n tankwar + + # 查看事件 + kubectl get events -n tankwar + ``` + +3. **服务无法访问** + ```bash + # 检查服务端点 + kubectl get endpoints tankwar-server-service -n tankwar + + # 检查网络策略 + kubectl get networkpolicies -n tankwar + ``` + +4. **健康检查失败** + ```bash + # 检查Pod内部 + kubectl exec -it -n tankwar -- wget -qO- http://localhost:3000/health + ``` + +### 调试命令 + +```bash +# 进入Pod调试 +kubectl exec -it -n tankwar -- /bin/sh + +# 端口转发本地调试 +kubectl port-forward svc/tankwar-server-service 3000:3000 -n tankwar + +# 然后访问:http://localhost:3000/health +``` + +## 清理部署 + +```bash +# 删除部署 +kubectl delete -f k8s-deployment.yaml -n tankwar + +# 删除命名空间 +kubectl delete namespace tankwar + +# 清理镜像 +docker rmi tankwar-server:latest +``` + +## 安全考虑 + +1. **网络策略**: 配置适当的网络策略限制访问 +2. **资源限制**: 设置合理的资源限制防止资源耗尽 +3. **镜像安全**: 定期更新基础镜像修复安全漏洞 +4. **访问控制**: 配置RBAC权限控制 + +## 性能优化建议 + +1. **副本数量**: 根据负载调整副本数量 +2. **资源分配**: 根据实际使用情况调整资源限制 +3. **连接池**: 考虑使用连接池优化WebSocket连接 +4. **监控告警**: 配置监控和告警系统 + +## 支持信息 + +如有问题,请检查: +- 服务器日志:`kubectl logs -l app=tankwar-server -n tankwar` +- 部署状态:`kubectl get all -n tankwar` +- 集群状态:`kubectl cluster-info` \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..ebf3715 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,25 @@ +FROM node:18-alpine + +LABEL maintainer="tankwar" \ + description="Tank War PVP WebSocket Server" + +WORKDIR /app + +# Install dependencies first (leverage Docker layer cache) +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev --no-audit --no-fund \ + && npm cache clean --force + +# Copy the rest of the source +COPY . . + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3000 + +EXPOSE 3000 + +# Graceful shutdown & init for PID 1 +RUN apk add --no-cache tini +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "index.js"] \ No newline at end of file diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..652cb58 --- /dev/null +++ b/server/README.md @@ -0,0 +1,183 @@ +# Tank War Server - Kubernetes部署指南 + +## 概述 + +Tank War Server是一个基于WebSocket的多人坦克对战游戏服务器,支持1v1和3v3对战模式。本文档说明如何将服务器部署到Kubernetes集群中。 + +## 前置要求 + +- Docker +- Kubernetes集群访问权限 +- kubectl命令行工具 +- 对目标K8s集群的访问配置 + +## 部署步骤 + +### 1. 准备环境 + +确保您已配置好对目标Kubernetes集群的访问: + +```bash +# 检查集群连接 +kubectl cluster-info + +# 查看当前上下文 +kubectl config current-context +``` + +### 2. 构建Docker镜像 + +```bash +# 在server目录下构建镜像 +cd server +docker build -t tankwar-server:latest . +``` + +### 3. 部署到Kubernetes + +使用提供的部署脚本: + +```bash +# 给脚本执行权限 +chmod +x deploy.sh + +# 执行部署 +./deploy.sh +``` + +或者手动部署: + +```bash +# 创建命名空间 +kubectl create namespace tankwar + +# 部署应用 +kubectl apply -f k8s-deployment.yaml -n tankwar + +# 等待部署完成 +kubectl rollout status deployment/tankwar-server -n tankwar +``` + +### 4. 验证部署 + +```bash +# 查看Pod状态 +kubectl get pods -n tankwar + +# 查看服务信息 +kubectl get svc -n tankwar + +# 查看日志 +kubectl logs -l app=tankwar-server -n tankwar +``` + +## 配置说明 + +### 环境变量 + +- `PORT`: 服务器端口(默认:3000) +- `HOST`: 绑定地址(默认:0.0.0.0) +- `NODE_ENV`: 运行环境(默认:production) + +### 资源限制 + +- 内存请求:256Mi,限制:512Mi +- CPU请求:250m,限制:500m + +### 健康检查 + +服务器提供健康检查端点: +- URL: `/health` +- 返回JSON格式的健康状态信息 + +## 网络配置 + +服务使用LoadBalancer类型暴露,可以通过外部IP访问。WebSocket连接地址格式: + +``` +ws://:3000 +``` + +## 监控和日志 + +### 查看日志 + +```bash +# 查看所有Pod日志 +kubectl logs -l app=tankwar-server -n tankwar + +# 查看特定Pod日志 +kubectl logs -n tankwar +``` + +### 扩展和伸缩 + +```bash +# 扩展副本数量 +kubectl scale deployment/tankwar-server --replicas=5 -n tankwar + +# 查看资源使用情况 +kubectl top pods -n tankwar +``` + +## 故障排除 + +### 常见问题 + +1. **镜像构建失败** + - 检查Docker守护进程是否运行 + - 确认Dockerfile语法正确 + +2. **部署失败** + - 检查kubectl集群连接 + - 验证k8s-deployment.yaml文件语法 + +3. **Pod无法启动** + - 查看Pod事件:`kubectl describe pod -n tankwar` + - 检查资源配额是否足够 + +4. **连接问题** + - 确认服务已分配外部IP + - 检查防火墙规则 + +### 调试命令 + +```bash +# 查看Pod详细信息 +kubectl describe pod -l app=tankwar-server -n tankwar + +# 进入Pod调试 +kubectl exec -it -n tankwar -- /bin/sh + +# 查看服务端点 +kubectl get endpoints tankwar-server-service -n tankwar +``` + +## 维护操作 + +### 更新部署 + +```bash +# 重新构建镜像 +docker build -t tankwar-server:latest . + +# 更新部署 +kubectl rollout restart deployment/tankwar-server -n tankwar +``` + +### 清理部署 + +```bash +# 删除部署 +kubectl delete -f k8s-deployment.yaml -n tankwar + +# 删除命名空间 +kubectl delete namespace tankwar +``` + +## 安全考虑 + +- 在生产环境中考虑使用Ingress控制器 +- 配置适当的网络策略 +- 定期更新镜像以修复安全漏洞 +- 监控资源使用情况防止资源耗尽 \ No newline at end of file diff --git a/server/deploy.sh b/server/deploy.sh new file mode 100755 index 0000000..832b78c --- /dev/null +++ b/server/deploy.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Tank War Server K8s部署脚本 +set -e + +# 配置变量 +IMAGE_NAME="tankwar-server" +K8S_NAMESPACE="tankwar" +K8S_CONFIG="k8s-deployment.yaml" + +# 检查Docker是否运行 +if ! docker info > /dev/null 2>&1; then + echo "错误: Docker守护进程未运行" + exit 1 +fi + +# 构建Docker镜像 +echo "构建Docker镜像..." +docker build -t $IMAGE_NAME:latest . + +# 登录到目标K8s集群(假设已配置kubectl) +echo "检查Kubernetes集群连接..." +kubectl cluster-info + +# 创建命名空间(如果不存在) +echo "创建命名空间..." +kubectl create namespace $K8S_NAMESPACE --dry-run=client -o yaml | kubectl apply -f - + +# 部署应用到K8s +echo "部署应用到Kubernetes..." +kubectl apply -f $K8S_CONFIG -n $K8S_NAMESPACE + +# 等待部署完成 +echo "等待部署完成..." +kubectl rollout status deployment/tankwar-server -n $K8S_NAMESPACE --timeout=300s + +# 获取服务信息 +echo "获取服务信息..." +kubectl get svc tankwar-server-service -n $K8S_NAMESPACE + +echo "部署完成!" +echo "使用以下命令查看Pod状态:" +echo "kubectl get pods -n $K8S_NAMESPACE" +echo "" +echo "使用以下命令查看日志:" +echo "kubectl logs -l app=tankwar-server -n $K8S_NAMESPACE" \ No newline at end of file diff --git a/server/index.js b/server/index.js index 05ce11d..f92213b 100644 --- a/server/index.js +++ b/server/index.js @@ -2,18 +2,48 @@ * Tank War PVP Server * WebSocket server for online 1v1 multiplayer. * Handles room management, message relay, and basic game state authority. + * + * Deployment note: + * - /health → HTTP health check (used by K8s livenessProbe/readinessProbe) + * - /tankwar/ws → WebSocket upgrade path (exposed publicly via Nginx) + * Both share the same HTTP server on PORT. */ const { WebSocketServer } = require('ws'); +const http = require('http'); // ============================================================ // Configuration // ============================================================ const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; +const WS_PATH = process.env.WS_PATH || '/tankwar/ws'; const HEARTBEAT_INTERVAL = 10000; // ms const ROOM_TIMEOUT = 300000; // 5 minutes room idle timeout +// ============================================================ +// HTTP Health Check Server +// ============================================================ +const healthServer = http.createServer((req, res) => { + if (req.url === '/health' || req.url === '/tankwar/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString(), + activeConnections: players.size, + activeRooms: rooms.size, + activeTeamRooms: teamRooms.size + })); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } +}); + +healthServer.listen(PORT, HOST, () => { + console.log(`[Health Server] Running on ${HOST}:${PORT}`); +}); + // ============================================================ // Message Types (must match client NET_MSG) // ============================================================ @@ -135,6 +165,7 @@ class PlayerInfo { constructor(ws, playerId) { this.ws = ws; this.playerId = playerId; + this.nickname = ''; this.roomId = null; this.teamId = null; this.isAlive = true; @@ -152,8 +183,9 @@ class TeamRoom { * @param {WebSocket} leaderWs - WebSocket of the team leader * @param {string} leaderId - Player id of the leader * @param {string} [battleMode='3v3'] - Battle mode ('1v1' or '3v3') + * @param {string} [leaderNickname=''] - Display nickname of the leader */ - constructor(id, leaderWs, leaderId, battleMode = '3v3') { + constructor(id, leaderWs, leaderId, battleMode = '3v3', leaderNickname = '') { this.id = id; this.state = 'forming'; // forming | matching | playing | finished this.createdAt = Date.now(); @@ -164,8 +196,8 @@ class TeamRoom { this.teamSize = config.teamSize; this.fillWithBotsEnabled = config.fillWithBots; - // Team A members: { ws, playerId, ready, isBot, disconnectedAt } - this.teamA = [{ ws: leaderWs, playerId: leaderId, ready: true, isBot: false, disconnectedAt: null }]; + // Team A members: { ws, playerId, nickname, ready, isBot, disconnectedAt } + this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', ready: true, isBot: false, disconnectedAt: null }]; // Team B members this.teamB = []; this.leaderId = leaderId; @@ -233,16 +265,16 @@ class TeamRoom { } /** Add a player to team A */ - addToTeamA(ws, playerId) { + addToTeamA(ws, playerId, nickname = '') { if (this.isTeamAFull()) return false; - this.teamA.push({ ws, playerId, ready: false, isBot: false, disconnectedAt: null }); + this.teamA.push({ ws, playerId, nickname, ready: false, isBot: false, disconnectedAt: null }); return true; } /** Add a player to team B */ - addToTeamB(ws, playerId) { + addToTeamB(ws, playerId, nickname = '') { if (this.teamB.length >= this.teamSize) return false; - this.teamB.push({ ws, playerId, ready: false, isBot: false, disconnectedAt: null }); + this.teamB.push({ ws, playerId, nickname, ready: false, isBot: false, disconnectedAt: null }); return true; } @@ -260,6 +292,7 @@ class TeamRoom { this.teamA.push({ ws: null, playerId: `bot_a_${botCounter}_${this.id}`, + nickname: '', ready: true, isBot: true, disconnectedAt: null, @@ -270,6 +303,7 @@ class TeamRoom { this.teamB.push({ ws: null, playerId: `bot_b_${botCounter}_${this.id}`, + nickname: '', ready: true, isBot: true, disconnectedAt: null, @@ -335,6 +369,7 @@ class TeamRoom { teamSize: this.teamSize, teamA: this.teamA.map(m => ({ playerId: m.playerId, + nickname: m.nickname || '', ready: m.ready, isBot: m.isBot, isLeader: m.playerId === this.leaderId, @@ -342,6 +377,7 @@ class TeamRoom { })), teamB: this.teamB.map(m => ({ playerId: m.playerId, + nickname: m.nickname || '', ready: m.ready, isBot: m.isBot, connected: m.isBot || (m.ws && m.ws.readyState === 1), @@ -449,7 +485,7 @@ function handleCreateRoom(ws, data) { const roomCode = generateRoomCode(); // Create a TeamRoom in 1v1 mode instead of a legacy Room - const teamRoom = new TeamRoom(roomCode, ws, playerInfo.playerId, '1v1'); + const teamRoom = new TeamRoom(roomCode, ws, playerInfo.playerId, '1v1', playerInfo.nickname || ''); teamRooms.set(roomCode, teamRoom); playerInfo.teamId = roomCode; @@ -498,7 +534,7 @@ function handleJoinRoom(ws, data) { } // Join as team B - teamRoom.addToTeamB(ws, playerInfo.playerId); + teamRoom.addToTeamB(ws, playerInfo.playerId, playerInfo.nickname || ''); playerInfo.teamId = roomId; console.log(`[Server] Player ${playerInfo.playerId} joined 1v1 room ${roomId} (TeamRoom)`); @@ -568,7 +604,7 @@ function handleCreateTeam(ws, data) { } const teamId = generateTeamId(); - const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); + const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); teamRooms.set(teamId, teamRoom); playerInfo.teamId = teamId; @@ -592,7 +628,7 @@ function handleJoinTeam(ws, data) { // Team was cleaned up (e.g. leader disconnected during dev-tool reload). // Auto-create a new room with the same ID so the invite link still works. console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`); - teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); + teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); teamRooms.set(teamId, teamRoom); playerInfo.teamId = teamId; sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); @@ -614,7 +650,7 @@ function handleJoinTeam(ws, data) { handleLeaveTeam(ws, {}); } - teamRoom.addToTeamA(ws, playerInfo.playerId); + teamRoom.addToTeamA(ws, playerInfo.playerId, playerInfo.nickname || ''); playerInfo.teamId = teamId; console.log(`[Server] Player ${playerInfo.playerId} joined team ${teamId}`); @@ -815,7 +851,7 @@ function handleSoloMatch(ws, data) { // Create a solo team room for this player const teamId = generateTeamId(); - const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); + const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); teamRoom.state = 'matching'; teamRoom.matchStartTime = Date.now(); teamRooms.set(teamId, teamRoom); @@ -888,7 +924,7 @@ function tryMatchTeams() { // Merge team B members into team A room as opponents for (const member of teamB_room.teamA) { - teamA_room.addToTeamB(member.ws, member.playerId); + teamA_room.addToTeamB(member.ws, member.playerId, member.nickname || ''); if (member.ws) { const info = players.get(member.ws); if (info) info.teamId = teamA_room.id; @@ -953,9 +989,9 @@ function tryMatchTeams() { // Alternate: odd index -> team A, even index -> team B if (i % 2 === 1 && !gameRoom.isTeamAFull()) { - gameRoom.addToTeamA(ws, info.playerId); + gameRoom.addToTeamA(ws, info.playerId, info.nickname || ''); } else { - gameRoom.addToTeamB(ws, info.playerId); + gameRoom.addToTeamB(ws, info.playerId, info.nickname || ''); } } @@ -986,8 +1022,8 @@ function startTeamGame(teamRoom) { const gameData = { mapId: teamRoom.mapId, - teamA: teamRoom.teamA.map(m => ({ playerId: m.playerId, isBot: m.isBot })), - teamB: teamRoom.teamB.map(m => ({ playerId: m.playerId, isBot: m.isBot })), + teamA: teamRoom.teamA.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', isBot: m.isBot })), + teamB: teamRoom.teamB.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', isBot: m.isBot })), teamABaseHp: teamRoom.teamABaseHp, teamBBaseHp: teamRoom.teamBBaseHp, battleMode: teamRoom.battleMode, @@ -1306,7 +1342,7 @@ function handleMessage(ws, rawData) { return; } - const { type, data, playerId } = msg; + const { type, data, playerId, nickname } = msg; // Update player info const playerInfo = players.get(ws); @@ -1315,6 +1351,26 @@ function handleMessage(ws, rawData) { if (playerId && !playerInfo.playerId) { playerInfo.playerId = playerId; } + // Refresh nickname on every message (it may be granted mid-session). + if (typeof nickname === 'string' && nickname) { + if (playerInfo.nickname !== nickname) { + playerInfo.nickname = nickname; + // Also propagate into any active team room member entry. + if (playerInfo.teamId) { + const tr = teamRooms.get(playerInfo.teamId); + if (tr) { + const member = tr.getMemberByWs(ws); + if (member && member.nickname !== nickname) { + member.nickname = nickname; + // Broadcast regardless of room state (forming / matching / playing) + // so that peers always render the latest display name — in 3v3 a + // player may only tap the UserInfoButton AFTER the match starts. + tr.broadcast(NET_MSG.TEAM_STATE, tr.getTeamState()); + } + } + } + } + } } switch (type) { @@ -1538,9 +1594,26 @@ setInterval(() => { }, 300000); // Every 5 minutes // ============================================================ -// WebSocket Server +// WebSocket Server (noServer mode, shares HTTP server with health check) // ============================================================ -const wss = new WebSocketServer({ host: HOST, port: PORT }); +// Use noServer mode so the WS upgrade only fires on the configured path. +// This lets /health stay as plain HTTP on the same port. +const wss = new WebSocketServer({ noServer: true }); + +healthServer.on('upgrade', (req, socket, head) => { + // Only upgrade on the configured WebSocket path; reject any other path. + // We compare by pathname so query strings are tolerated. + const pathname = (req.url || '').split('?')[0]; + if (pathname !== WS_PATH) { + console.warn(`[Server] Rejected WebSocket upgrade on unexpected path: ${req.url}`); + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req); + }); +}); wss.on('connection', (ws, req) => { const ip = req.socket.remoteAddress; @@ -1604,4 +1677,5 @@ setInterval(() => { // Startup // ============================================================ console.log(`[Tank War Server] Running on ${HOST}:${PORT}`); -console.log(`[Tank War Server] WebSocket URL: ws://${HOST}:${PORT}`); +console.log(`[Tank War Server] WebSocket path: ${WS_PATH}`); +console.log(`[Tank War Server] Health check paths: /health, /tankwar/health`); diff --git a/server/k8s-deployment.yaml b/server/k8s-deployment.yaml new file mode 100644 index 0000000..6767ad3 --- /dev/null +++ b/server/k8s-deployment.yaml @@ -0,0 +1,138 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: tankwar-server-config +data: + NODE_ENV: "production" + PORT: "3000" + HOST: "0.0.0.0" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tankwar-server + labels: + app: tankwar-server +spec: + replicas: 3 + selector: + matchLabels: + app: tankwar-server + template: + metadata: + labels: + app: tankwar-server + spec: + containers: + - name: tankwar-server + image: tankwar-server:latest + ports: + - containerPort: 3000 + envFrom: + - configMapRef: + name: tankwar-server-config + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: tankwar + labels: + app.kubernetes.io/part-of: tankwar +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: tankwar-server-config + namespace: tankwar +data: + NODE_ENV: "production" + PORT: "3000" + HOST: "0.0.0.0" + # WebSocket path must match Nginx location and client SERVER_URL + WS_PATH: "/tankwar/ws" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tankwar-server + namespace: tankwar + labels: + app: tankwar-server +spec: + replicas: 3 + selector: + matchLabels: + app: tankwar-server + template: + metadata: + labels: + app: tankwar-server + spec: + containers: + - name: tankwar-server + image: tankwar/tankwar-server:latest + imagePullPolicy: Never + ports: + - containerPort: 3000 + name: http-ws + envFrom: + - configMapRef: + name: tankwar-server-config + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: tankwar-server + namespace: tankwar + labels: + app: tankwar-server +spec: + # ClusterIP: only exposed internally, external traffic comes through + # warmcheck-namespace Nginx at game.igeek.site -> /tankwar/ws + type: ClusterIP + selector: + app: tankwar-server + ports: + - name: http-ws + port: 3000 + targetPort: 3000 + protocol: TCP + type: LoadBalancer \ No newline at end of file diff --git a/server/run-deploy.sh b/server/run-deploy.sh new file mode 100755 index 0000000..9e47e11 --- /dev/null +++ b/server/run-deploy.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# ============================================================ +# Tankwar server K8s deploy script +# Syncs server source -> Master node -> builds Docker image -> +# distributes image to Worker nodes via ctr -> applies K8s resources +# -> rolls out the tankwar-server deployment. +# ============================================================ +set -e + +LOG="/tmp/tankwar-k8s-deploy.log" +> "$LOG" +exec > >(tee -a "$LOG") 2>&1 + +SERVER_DIR="/Users/hanchengxi/workspace/tankwar_proj/server" +MASTER="root@host_172.16.16.16" +WORKERS_IP=("172.16.16.17" "172.16.16.8") +REMOTE_BUILD_DIR="/tmp/tankwar-build" +IMAGE_NAME="tankwar/tankwar-server:latest" + +ts() { echo "[$(date '+%H:%M:%S')]"; } + +# ------------------------------------------------------------ +# Step 0: Sync server source to master node +# ------------------------------------------------------------ +echo "$(ts) ===== Syncing tankwar server source to master node =====" +ssh -o StrictHostKeyChecking=no "$MASTER" "mkdir -p $REMOTE_BUILD_DIR" +rsync -az --delete --exclude='.git' --exclude='node_modules' \ + -e "ssh -o StrictHostKeyChecking=no" \ + "$SERVER_DIR/" "${MASTER}:${REMOTE_BUILD_DIR}/server/" +echo "$(ts) ✓ Source synced" + +# ------------------------------------------------------------ +# Step 1: Ensure docker is available on master +# ------------------------------------------------------------ +echo "$(ts) ===== Checking docker on master =====" +if ! ssh -o StrictHostKeyChecking=no "$MASTER" "which docker >/dev/null 2>&1"; then + echo "$(ts) Docker not found on master. Installing..." + ssh -o StrictHostKeyChecking=no "$MASTER" "curl -fsSL https://get.docker.com | sh" +fi +ssh -o StrictHostKeyChecking=no "$MASTER" "docker version --format '{{.Server.Version}}' 2>/dev/null || systemctl start docker" +echo "$(ts) ✓ Docker ready on master" + +# ------------------------------------------------------------ +# Step 2: Build image on master +# ------------------------------------------------------------ +echo "$(ts) ===== Building $IMAGE_NAME on master =====" +ssh -o StrictHostKeyChecking=no "$MASTER" \ + "cd $REMOTE_BUILD_DIR/server && docker build -t $IMAGE_NAME -f Dockerfile ." +echo "$(ts) ✓ Image built" + +# ------------------------------------------------------------ +# Step 3: Distribute image to workers (containerd / ctr) +# ------------------------------------------------------------ +echo "$(ts) ===== Distributing $IMAGE_NAME to workers =====" +# Master itself may also be a worker; import locally first so master pods can use it. +ssh -o StrictHostKeyChecking=no "$MASTER" \ + "docker save $IMAGE_NAME | ctr -n k8s.io images import -" +for w in "${WORKERS_IP[@]}"; do + echo "$(ts) -> $w" + ssh -o StrictHostKeyChecking=no "$MASTER" \ + "docker save $IMAGE_NAME | ssh -o StrictHostKeyChecking=no root@$w 'ctr -n k8s.io images import -'" +done +echo "$(ts) ✓ Image distributed" + +# ------------------------------------------------------------ +# Step 4: Apply K8s manifests (namespace + configmap + deploy + svc) +# ------------------------------------------------------------ +echo "$(ts) ===== Applying K8s manifests =====" +cat "$SERVER_DIR/k8s-deployment.yaml" | \ + ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl apply -f -" +echo "$(ts) ✓ Manifests applied" + +# ------------------------------------------------------------ +# Step 5: Restart deployment to pick up the new image +# ------------------------------------------------------------ +echo "$(ts) ===== Restarting tankwar-server deployment =====" +ssh -o StrictHostKeyChecking=no "$MASTER" \ + "kubectl -n tankwar rollout restart deployment/tankwar-server" || true +ssh -o StrictHostKeyChecking=no "$MASTER" \ + "kubectl -n tankwar rollout status deployment/tankwar-server --timeout=120s" || true + +# ------------------------------------------------------------ +# Step 6: Show final status +# ------------------------------------------------------------ +echo "$(ts) ===== Final Status =====" +ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n tankwar get pods -o wide" +echo "" +ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n tankwar get svc" + +echo "" +echo "$(ts) ===== ALL DONE =====" +echo "$(ts) Public endpoint (via warmcheck Nginx): wss://www.igeek.site/games/wx/tankwar/ws" +echo "$(ts) Internal endpoint: tankwar-server.tankwar.svc.cluster.local:3000" +echo "$(ts) Remember to redeploy warmcheck Nginx too (run WarmCheck_proj/backend/deploy/k8s/scripts/run-deploy.sh)" + +# Cleanup +ssh -o StrictHostKeyChecking=no "$MASTER" "rm -rf $REMOTE_BUILD_DIR" 2>/dev/null || true diff --git a/server/test-deployment.sh b/server/test-deployment.sh new file mode 100755 index 0000000..4148993 --- /dev/null +++ b/server/test-deployment.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Tank War Server部署测试脚本 +set -e + +echo "=== Tank War Server部署测试 ===" +echo "" + +# 检查必需文件是否存在 +echo "1. 检查必需文件..." +files=("Dockerfile" "k8s-deployment.yaml" "index.js" "package.json" "deploy.sh") +for file in "${files[@]}"; do + if [ -f "$file" ]; then + echo "✓ $file 存在" + else + echo "✗ $file 缺失" + exit 1 + fi +done + +echo "" +echo "2. 检查Dockerfile语法..." +if grep -q "FROM node" Dockerfile && grep -q "EXPOSE 3000" Dockerfile; then + echo "✓ Dockerfile语法正确" +else + echo "✗ Dockerfile语法错误" + exit 1 +fi + +echo "" +echo "3. 检查K8s配置文件..." +# 检查基本的YAML语法 +if python3 -c "import yaml; yaml.safe_load(open('k8s-deployment.yaml'))" > /dev/null 2>&1; then + echo "✓ K8s配置文件语法正确" +else + echo "⚠ 无法验证K8s配置,需要连接到Kubernetes集群" + echo " 运行 'kubectl cluster-info' 检查连接状态" +fi + +echo "" +echo "4. 检查Node.js依赖..." +if node -e "require('./package.json')" > /dev/null 2>&1; then + echo "✓ package.json语法正确" +else + echo "✗ package.json语法错误" + exit 1 +fi + +echo "" +echo "5. 检查服务器代码..." +if node -c index.js > /dev/null 2>&1; then + echo "✓ index.js语法正确" +else + echo "✗ index.js语法错误" + exit 1 +fi + +echo "" +echo "6. 检查部署脚本权限..." +chmod +x deploy.sh +if [ -x "deploy.sh" ]; then + echo "✓ 部署脚本可执行" +else + echo "✗ 部署脚本权限错误" + exit 1 +fi + +echo "" +echo "=== 所有检查通过! ===" +echo "" +echo "下一步:" +echo "1. 确保Docker守护进程正在运行" +echo "2. 确保kubectl已配置正确的集群上下文" +echo "3. 运行 ./deploy.sh 开始部署" +echo "" \ No newline at end of file diff --git a/server/verify-deployment.sh b/server/verify-deployment.sh new file mode 100644 index 0000000..aba8c15 --- /dev/null +++ b/server/verify-deployment.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# Tank War Server部署验证脚本 +set -e + +echo "=== Tank War Server部署验证 ===" +echo "" + +# 检查Kubernetes集群连接 +echo "1. 检查Kubernetes集群连接..." +if kubectl cluster-info > /dev/null 2>&1; then + echo "✓ Kubernetes集群连接正常" + + # 检查命名空间 + echo "" + echo "2. 检查命名空间..." + if kubectl get namespace tankwar > /dev/null 2>&1; then + echo "✓ tankwar命名空间存在" + else + echo "✗ tankwar命名空间不存在" + echo " 运行: kubectl create namespace tankwar" + exit 1 + fi + + # 检查部署状态 + echo "" + echo "3. 检查部署状态..." + kubectl get deployment tankwar-server -n tankwar 2>/dev/null || { + echo "✗ tankwar-server部署不存在" + echo " 运行: kubectl apply -f k8s-deployment.yaml -n tankwar" + exit 1 + } + + # 检查Pod状态 + echo "" + echo "4. 检查Pod状态..." + kubectl get pods -l app=tankwar-server -n tankwar + + # 检查服务状态 + echo "" + echo "5. 检查服务状态..." + kubectl get svc tankwar-server-service -n tankwar + + # 检查服务端点 + echo "" + echo "6. 检查服务端点..." + kubectl get endpoints tankwar-server-service -n tankwar + + # 检查Pod日志 + echo "" + echo "7. 检查Pod日志(最近10行)..." + kubectl logs -l app=tankwar-server -n tankwar --tail=10 + + echo "" + echo "=== 部署验证完成 ===" + echo "" + echo "如果所有检查都通过,服务应该正在运行。" + echo "WebSocket连接地址格式:ws://:3000" + echo "" + echo "获取外部IP:" + echo "kubectl get svc tankwar-server-service -n tankwar -o jsonpath='{.status.loadBalancer.ingress[0].ip}'" + +else + echo "✗ Kubernetes集群连接失败" + echo "请配置kubectl连接到正确的集群" + echo "" + echo "配置方法:" + echo "1. 获取集群kubeconfig文件" + echo "2. 设置KUBECONFIG环境变量" + echo "3. 或复制到 ~/.kube/config" + exit 1 +fi \ No newline at end of file