2 Commits

Author SHA1 Message Date
jakciehan 38294c040c chore: adjust player tank's size 2026-05-02 13:50:52 +08:00
jakciehan 0e321bcea6 add skin manager 2026-04-10 23:05:26 +08:00
36 changed files with 5991 additions and 197 deletions
+109
View File
@@ -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;
}
}
}
+62
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: tankwar
labels:
name: tankwar
app: tankwar
+165
View File
@@ -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 <<EOF
------------------------------------------------------------
Public endpoints (after DNS on www.igeek.site takes effect):
wss://www.igeek.site:30081/ (via NodePort on any of the 3 CVMs)
Direct CVM access for smoke test:
wscat -c ws://43.139.80.61:30081/
wscat -c ws://43.138.255.42:30081/
wscat -c ws://159.75.104.221:30081/
Remember to allow TCP/30081 in the CVM security group.
------------------------------------------------------------
EOF
}
main() {
log "Tank War Server deploy — tag=${IMAGE_TAG}"
step_sync
step_build
step_distribute
step_apply
step_verify
}
main "$@"
+48
View File
@@ -0,0 +1,48 @@
---
# ClusterIP service — consumed by Nginx reverse-proxy inside the cluster.
apiVersion: v1
kind: Service
metadata:
name: tankwar-server
namespace: tankwar
labels:
app: tankwar-server
spec:
type: ClusterIP
selector:
app: tankwar-server
ports:
- name: ws
port: 3000
targetPort: 3000
protocol: TCP
# Sticky sessions keep a given client pinned to the same pod while
# the (future) multi-replica version is considered.
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800
---
# NodePort service — exposes the WebSocket server directly on every node
# at port 30081, so the public domain can be pointed here once DNS resolves.
apiVersion: v1
kind: Service
metadata:
name: tankwar-server-nodeport
namespace: tankwar
labels:
app: tankwar-server
spec:
type: NodePort
selector:
app: tankwar-server
ports:
- name: ws
port: 3000
targetPort: 3000
nodePort: 30081
protocol: TCP
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Convenience Nginx snippet for the cluster-internal nginx (warmcheck ns).
# Drop this into warmcheck's nginx configmap under `location /games/wx/tankwar`
# once the DNS for www.igeek.site points to the 3 CVMs.
#
# Paste this whole block inside the existing `server { ... }` block that
# serves www.igeek.site with TLS.
# server { ... listen 443 ssl; server_name www.igeek.site; ... }
# Health endpoint (handy for uptime checks; optional)
location = /games/wx/tankwar/health {
proxy_pass http://tankwar-server.tankwar.svc.cluster.local:3000/health;
proxy_http_version 1.1;
}
# WebSocket endpoint that the game actually uses
location /games/wx/tankwar/ws {
proxy_pass http://tankwar-server.tankwar.svc.cluster.local:3000;
proxy_http_version 1.1;
# WebSocket upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long-lived WS — well past server HEARTBEAT_INTERVAL (10s)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
proxy_buffering off;
}
+408
View File
@@ -0,0 +1,408 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
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;
keepalive_timeout 65;
gzip on;
gzip_types text/plain application/json application/javascript;
client_max_body_size 10m;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s;
# Upstream definitions - using K8s service DNS names
# keepalive enables persistent connections to reduce latency
upstream user_service {
server user-service.warmcheck.svc.cluster.local:8081;
keepalive 16;
}
upstream interaction_service {
server interaction-service.warmcheck.svc.cluster.local:8082;
keepalive 16;
}
upstream social_service {
server social-service.warmcheck.svc.cluster.local:8083;
keepalive 8;
}
upstream push_service {
server push-service.warmcheck.svc.cluster.local:8084;
keepalive 8;
}
upstream ws_gateway {
server gateway.warmcheck.svc.cluster.local:8085;
}
upstream admin_service {
server admin-service.warmcheck.svc.cluster.local:8086;
keepalive 4;
}
# Tank War WebSocket server (separate namespace)
upstream tankwar_server {
server tankwar-server.tankwar.svc.cluster.local:3000;
keepalive 16;
}
# HTTPS server - for external access via HK CVM nginx
server {
listen 443 ssl;
server_name api.warmcheck.app;
ssl_certificate /etc/nginx/ssl/tls.crt;
ssl_certificate_key /etc/nginx/ssl/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Health check
location /health {
return 200 '{"status":"ok","gateway":"nginx","ssl":true}';
add_header Content-Type application/json;
}
# Auth routes (stricter rate limit)
location /auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# User service routes
location ~ ^/api/v1/(user|sign|growth|museum|feedback)(/|$) {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# User service routes (emergency contact)
location ~ ^/api/v1/users/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Interaction service routes
location ~ ^/api/v1/(story-card|care-card|urgent-care)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://interaction_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Social service routes
location ~ ^/api/v1/(magnet|chat)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://social_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Push service routes
location ~ ^/api/v1/notification/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://push_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Admin service routes (report & feedback submission from app)
location ~ ^/api/v1/(report|feedback)$ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://admin_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# WebSocket gateway
location /ws {
proxy_pass http://ws_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# AI service routes
location /ai/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://ai-service.warmcheck.svc.cluster.local:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Analytics service routes
location /analytics/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://analytics-service.warmcheck.svc.cluster.local:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (UI + API)
location /admin {
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP server - for internal health checks and backward compatibility
server {
listen 80;
server_name api.warmcheck.app;
# Health check
location /health {
return 200 '{"status":"ok","gateway":"nginx"}';
add_header Content-Type application/json;
}
# Auth routes (stricter rate limit)
location /auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# User service routes (feedback uses exact path without trailing slash)
location ~ ^/api/v1/(user|sign|growth|museum|feedback)(/|$) {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# User service routes (emergency contact)
location ~ ^/api/v1/users/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Interaction service routes
location ~ ^/api/v1/(story-card|care-card|urgent-care)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://interaction_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Social service routes
location ~ ^/api/v1/(magnet|chat)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://social_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Push service routes
location ~ ^/api/v1/notification/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://push_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (report & feedback submission from app)
location ~ ^/api/v1/(report|feedback)$ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket gateway
location /ws {
proxy_pass http://ws_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# AI service routes
location /ai/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://ai-service.warmcheck.svc.cluster.local:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Analytics service routes
location /analytics/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://analytics-service.warmcheck.svc.cluster.local:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (UI + API)
location /admin {
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# =========================================================
# www.igeek.site — Tank War game WebSocket entry
# Reuses the existing nginx-tls-secret (CN=igeek.site,
# SAN includes www.igeek.site).
# =========================================================
server {
listen 443 ssl;
server_name www.igeek.site igeek.site;
ssl_certificate /etc/nginx/ssl/tls.crt;
ssl_certificate_key /etc/nginx/ssl/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Gateway self health
location = /health {
return 200 '{"status":"ok","gateway":"nginx","site":"igeek"}';
add_header Content-Type application/json;
}
# Tank War health passthrough
location = /games/wx/tankwar/health {
proxy_pass http://tankwar_server/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
}
# Tank War WebSocket endpoint
location /games/wx/tankwar/ws {
proxy_pass http://tankwar_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long-lived WS — well past server HEARTBEAT_INTERVAL (10s)
proxy_connect_timeout 10s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
# HTTP for www.igeek.site — redirect to HTTPS
server {
listen 80;
server_name www.igeek.site igeek.site;
location = /health {
return 200 '{"status":"ok","gateway":"nginx","site":"igeek","tls":false}';
add_header Content-Type application/json;
}
location / {
return 301 https://$host$request_uri;
}
}
}
@@ -0,0 +1,331 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
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;
keepalive_timeout 65;
gzip on;
gzip_types text/plain application/json application/javascript;
client_max_body_size 10m;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s;
# Upstream definitions - using K8s service DNS names
# keepalive enables persistent connections to reduce latency
upstream user_service {
server user-service.warmcheck.svc.cluster.local:8081;
keepalive 16;
}
upstream interaction_service {
server interaction-service.warmcheck.svc.cluster.local:8082;
keepalive 16;
}
upstream social_service {
server social-service.warmcheck.svc.cluster.local:8083;
keepalive 8;
}
upstream push_service {
server push-service.warmcheck.svc.cluster.local:8084;
keepalive 8;
}
upstream ws_gateway {
server gateway.warmcheck.svc.cluster.local:8085;
}
upstream admin_service {
server admin-service.warmcheck.svc.cluster.local:8086;
keepalive 4;
}
# HTTPS server - for external access via HK CVM nginx
server {
listen 443 ssl;
server_name api.warmcheck.app;
ssl_certificate /etc/nginx/ssl/tls.crt;
ssl_certificate_key /etc/nginx/ssl/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Health check
location /health {
return 200 '{"status":"ok","gateway":"nginx","ssl":true}';
add_header Content-Type application/json;
}
# Auth routes (stricter rate limit)
location /auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# User service routes
location ~ ^/api/v1/(user|sign|growth|museum|feedback)(/|$) {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# User service routes (emergency contact)
location ~ ^/api/v1/users/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Interaction service routes
location ~ ^/api/v1/(story-card|care-card|urgent-care)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://interaction_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Social service routes
location ~ ^/api/v1/(magnet|chat)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://social_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Push service routes
location ~ ^/api/v1/notification/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://push_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Admin service routes (report & feedback submission from app)
location ~ ^/api/v1/(report|feedback)$ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://admin_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# WebSocket gateway
location /ws {
proxy_pass http://ws_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# AI service routes
location /ai/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://ai-service.warmcheck.svc.cluster.local:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Analytics service routes
location /analytics/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://analytics-service.warmcheck.svc.cluster.local:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (UI + API)
location /admin {
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP server - for internal health checks and backward compatibility
server {
listen 80;
server_name api.warmcheck.app;
# Health check
location /health {
return 200 '{"status":"ok","gateway":"nginx"}';
add_header Content-Type application/json;
}
# Auth routes (stricter rate limit)
location /auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# User service routes (feedback uses exact path without trailing slash)
location ~ ^/api/v1/(user|sign|growth|museum|feedback)(/|$) {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# User service routes (emergency contact)
location ~ ^/api/v1/users/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Interaction service routes
location ~ ^/api/v1/(story-card|care-card|urgent-care)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://interaction_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Social service routes
location ~ ^/api/v1/(magnet|chat)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://social_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Push service routes
location ~ ^/api/v1/notification/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://push_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (report & feedback submission from app)
location ~ ^/api/v1/(report|feedback)$ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket gateway
location /ws {
proxy_pass http://ws_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# AI service routes
location /ai/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://ai-service.warmcheck.svc.cluster.local:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Analytics service routes
location /analytics/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://analytics-service.warmcheck.svc.cluster.local:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (UI + API)
location /admin {
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
+19
View File
@@ -4,6 +4,19 @@
* Initializes canvas, sets up the game loop, and manages scene lifecycle.
*/
// Ensure timer globals exist (some WeChat base library versions may not
// inject them early enough, causing WAGame.js internal error-reporting to
// crash with "Can't find variable: setTimeout").
if (typeof setTimeout === 'undefined') {
GameGlobal.setTimeout = GameGlobal.setTimeout || function (fn, ms) {
// Fallback: execute synchronously when real timer is unavailable
fn();
};
GameGlobal.setInterval = GameGlobal.setInterval || function () {};
GameGlobal.clearTimeout = GameGlobal.clearTimeout || function () {};
GameGlobal.clearInterval = GameGlobal.clearInterval || function () {};
}
const SceneManager = require('./js/managers/SceneManager');
const ResourceManager = require('./js/managers/ResourceManager');
const StorageManager = require('./js/managers/StorageManager');
@@ -15,6 +28,8 @@ const CurrencyManager = require('./js/managers/CurrencyManager');
const PaymentManager = require('./js/managers/PaymentManager');
const ComplianceManager = require('./js/managers/ComplianceManager');
const BuffManager = require('./js/managers/BuffManager');
const SkinManager = require('./js/managers/SkinManager');
const PlayerProfile = require('./js/managers/PlayerProfile');
const EventBus = require('./js/base/EventBus');
const {
SCREEN_WIDTH,
@@ -62,12 +77,16 @@ const currencyManager = new CurrencyManager();
const paymentManager = new PaymentManager();
const complianceManager = new ComplianceManager();
const buffManager = new BuffManager();
const skinManager = new SkinManager();
const playerProfile = new PlayerProfile();
GameGlobal.adManager = adManager;
GameGlobal.shareManager = shareManager;
GameGlobal.currencyManager = currencyManager;
GameGlobal.paymentManager = paymentManager;
GameGlobal.complianceManager = complianceManager;
GameGlobal.buffManager = buffManager;
GameGlobal.skinManager = skinManager;
GameGlobal.playerProfile = playerProfile;
// ============================================================
// Game State
+2 -1
View File
@@ -164,6 +164,7 @@ const SCENE = {
RANKING: 'ranking',
SETTINGS: 'settings',
SHOP: 'shop',
SKIN: 'skin',
BUFF_SELECT: 'buff_select',
PVP_ROOM: 'pvp_room',
PVP_GAME: 'pvp_game',
@@ -196,7 +197,7 @@ const PVP_BASE_HP = 5; // base hit points for 1v1 PVP mode
// Server Configuration
// ============================================================
// const SERVER_URL = 'ws://192.168.1.103:3000'; // local testing server URL, replace with actual server URL in production
const SERVER_URL = 'wss://www.igeek.site/games/wx/tankwar';
const SERVER_URL = 'wss://game.igeek.site/tankwar/ws';
// ============================================================
+1
View File
@@ -45,6 +45,7 @@ class PlayerTank extends Tank {
// Skin colors (reserved for future use)
this._skinColors = null;
this._skinId = 'default';
// Fire level system
this.fireLevel = FIRE_LEVEL.LV1;
+24 -2
View File
@@ -13,6 +13,7 @@ const {
DIRECTION,
DIR_VECTORS,
} = require('../base/GameGlobal');
const { drawTankSkin, DESIGN_HALF_SIZE } = require('./TankSkinRenderer');
class Tank {
/**
@@ -288,9 +289,30 @@ class Tank {
};
ctx.rotate(angles[this.direction]);
const hs = this.halfSize;
// ★ Unified skin path — any tank with a skin id uses the SAME drawing
// code as the SkinScene preview. Scale to match the actual tank size.
// Clip laterally to the collision box so wide tracks / decorations
// don't make the tank look wider than non-skinned tanks. Leave the
// top/bottom un-clipped so the barrel can extend naturally (same as
// legacy rendering).
if (this._skinId) {
const t = (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
const k = this.halfSize / DESIGN_HALF_SIZE;
// Clip: lateral bounds = collision box; vertical = generous to allow barrel
const barrelExtra = this.size * 0.55; // same as legacy barrelH
ctx.beginPath();
ctx.rect(-this.halfSize, -this.halfSize - barrelExtra, this.size, this.size + barrelExtra * 2);
ctx.clip();
ctx.save();
ctx.scale(k, k);
drawTankSkin(ctx, this._skinId, this._skinColors, t);
ctx.restore();
ctx.restore();
return;
}
// Determine colors: use skin colors if this is a player tank with a skin
// ── Legacy fallback for tanks without a skin id (enemy AI, etc.) ──
const hs = this.halfSize;
let bodyColor = this.color;
let turretColor = this._darkenColor(this.color, 0.3);
let trackColor = this._darkenColor(this.color, 0.4);
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -20,12 +20,14 @@ module.exports = {
// Menu Scene
// ============================================================
'menu.title': 'Tank Adventure',
'profile.welcome': 'Welcome {name}!',
'menu.subtitle': 'TANK WAR',
'menu.classic': 'Classic',
'menu.endless': 'Endless',
'menu.pvp': 'PVP',
'menu.team3v3': '3v3 Battle',
'menu.shop': 'Shop',
'menu.skin': 'Skins',
'menu.ranking': 'Ranking',
'menu.settings': 'Settings',
@@ -204,6 +206,7 @@ module.exports = {
'settings.sound': 'Sound',
'settings.music': 'Music',
'settings.vibration': 'Vibration',
'settings.nickname': 'Display Name',
// ============================================================
// Shop Scene (Simplified)
@@ -229,7 +232,7 @@ module.exports = {
'ad.reviveTitle': 'Revive Chance',
'ad.reviveDesc': 'Choose how to revive and continue',
'ad.watchAd': '📺 Watch Ad (Free)',
'ad.goldRevive': '🪙 Gold Revive (200)',
'ad.goldRevive': '🪙 Gold Revive',
'ad.giveUp': 'Give Up',
'ad.doubleReward': '🎬 Watch Ad for 2x Reward',
'ad.unavailable': 'Ad temporarily unavailable',
@@ -269,4 +272,24 @@ module.exports = {
'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': 'Come back tomorrow',
'dailyGold.reward': '+100 Gold!',
// ============================================================
// Skin System
// ============================================================
'skin.title': 'Tank Skins',
'skin.default': 'Classic',
'skin.arctic': 'Arctic',
'skin.inferno': 'Inferno',
'skin.phantom': 'Phantom',
'skin.jungle': 'Jungle',
'skin.neon': 'Neon',
'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!',
'skin.purchaseSuccess': '✓ Skin unlocked!',
};
+24 -1
View File
@@ -20,12 +20,14 @@ module.exports = {
// Menu Scene
// ============================================================
'menu.title': '坦克探险',
'profile.welcome': '欢迎 {name}',
'menu.subtitle': '经典坦克对战',
'menu.classic': '经典模式',
'menu.endless': '无尽模式',
'menu.pvp': '双人对战',
'menu.team3v3': '3v3 对战',
'menu.shop': '商店',
'menu.skin': '皮肤',
'menu.ranking': '排行榜',
'menu.settings': '设置',
@@ -204,6 +206,7 @@ module.exports = {
'settings.sound': '音效',
'settings.music': '音乐',
'settings.vibration': '振动',
'settings.nickname': '显示名字',
// ============================================================
// Shop Scene (Simplified)
@@ -229,7 +232,7 @@ module.exports = {
'ad.reviveTitle': '复活机会',
'ad.reviveDesc': '选择复活方式继续游戏',
'ad.watchAd': '📺 观看广告(免费)',
'ad.goldRevive': '🪙 金币复活200',
'ad.goldRevive': '🪙 金币复活',
'ad.giveUp': '放弃',
'ad.doubleReward': '🎬 看广告双倍奖励',
'ad.unavailable': '广告暂时不可用',
@@ -269,4 +272,24 @@ module.exports = {
'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': '明日再来',
'dailyGold.reward': '+100 金币!',
// ============================================================
// Skin System
// ============================================================
'skin.title': '坦克皮肤',
'skin.default': '经典',
'skin.arctic': '极地',
'skin.inferno': '烈焰',
'skin.phantom': '幻影',
'skin.jungle': '丛林',
'skin.neon': '霓虹',
'skin.nebula': '星云',
'skin.royal': '皇家',
'skin.sakura': '樱花',
'skin.thunder': '雷电',
'skin.diamond': '钻石',
'skin.equipped': '✓ 使用中',
'skin.owned': '已拥有',
'skin.equipSuccess': '✓ 已装备!',
'skin.purchaseSuccess': '✓ 已解锁!',
};
+75 -11
View File
@@ -51,9 +51,10 @@ class NetworkManager {
/**
* Connect to the WebSocket server.
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws').
* @param {number} [timeoutMs=10000] - Connect timeout in milliseconds.
* @returns {Promise<boolean>} Whether connection succeeded.
*/
connect(serverUrl) {
connect(serverUrl, timeoutMs = 10000) {
return new Promise((resolve) => {
if (this._connected || this._connecting) {
resolve(this._connected);
@@ -64,20 +65,53 @@ class NetworkManager {
this._connecting = true;
this._shouldReconnect = true;
// Guard: make sure resolve is called exactly once.
let settled = false;
const finish = (ok, reason) => {
if (settled) return;
settled = true;
if (connectTimer) {
clearTimeout(connectTimer);
connectTimer = null;
}
if (!ok) {
// Tear down broken socket so next connect() starts clean.
this._connecting = false;
this._shouldReconnect = false; // a first-time failure should NOT auto-reconnect
if (this._ws) {
try { this._ws.close({}); } catch (e) { /* ignore */ }
this._ws = null;
}
console.warn('[NetworkManager] connect() failed:', reason || 'unknown');
}
resolve(ok);
};
// Connection timeout guard (e.g. DNS/TLS hang on cellular).
let connectTimer = setTimeout(() => {
finish(false, `connect timeout after ${timeoutMs}ms, url=${serverUrl}`);
}, timeoutMs);
try {
this._ws = wx.connectSocket({
url: serverUrl,
header: { 'content-type': 'application/json' },
// Surface wx.connectSocket API-level failures (invalid url / domain not whitelisted / etc.)
success: (res) => { console.log('[NetworkManager] wx.connectSocket invoked:', res && res.errMsg); },
fail: (err) => {
console.error('[NetworkManager] wx.connectSocket API failed:', JSON.stringify(err));
finish(false, `wx.connectSocket fail: ${err && err.errMsg}`);
},
});
this._ws.onOpen(() => {
console.log('[NetworkManager] Connected to server');
console.log('[NetworkManager] Connected to server:', serverUrl);
this._connected = true;
this._connecting = false;
this._reconnectAttempts = 0;
this._startHeartbeat();
this._emit('connected');
resolve(true);
finish(true);
});
this._ws.onMessage((res) => {
@@ -85,28 +119,45 @@ class NetworkManager {
});
this._ws.onError((err) => {
console.error('[NetworkManager] WebSocket error:', err);
this._connecting = false;
// Log as much context as possible; wx error objects vary across platforms.
console.error('[NetworkManager] WebSocket error:',
(err && (err.errMsg || err.message)) || err,
'url=', serverUrl);
this._emit('error', err);
resolve(false);
// If the error arrives before we ever got onOpen, treat it as a connect failure.
if (!this._connected) {
finish(false, `onError before open: ${err && (err.errMsg || err.message)}`);
} else {
// Runtime error on an established connection — let onClose handle reconnection.
this._connecting = false;
}
});
this._ws.onClose((res) => {
console.log('[NetworkManager] Connection closed:', res.code, res.reason);
const code = res && res.code;
const reason = res && res.reason;
console.log('[NetworkManager] Connection closed:', code, reason, 'url=', serverUrl);
const wasConnected = this._connected;
this._connected = false;
this._connecting = false;
this._stopHeartbeat();
this._emit('disconnected', { code: res.code, reason: res.reason });
this._emit('disconnected', { code, reason });
// Auto-reconnect if needed
// If onClose arrives before onOpen, this is a connect failure.
if (!wasConnected) {
finish(false, `onClose before open: code=${code} reason=${reason}`);
return;
}
// Auto-reconnect only for drops on an already-established connection.
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
this._attemptReconnect();
}
});
} catch (e) {
console.error('[NetworkManager] Failed to create WebSocket:', e);
this._connecting = false;
resolve(false);
finish(false, `exception: ${e && e.message}`);
}
});
}
@@ -145,10 +196,17 @@ class NetworkManager {
return;
}
// Always include the player's current nickname (if any) so the server
// can propagate it to other clients. Falls back silently when the
// profile is not yet available.
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
const nickname = (profile && profile.nickname) ? profile.nickname : '';
const message = JSON.stringify({
type,
data,
playerId: this._playerId,
nickname,
roomId: this._roomId,
timestamp: Date.now(),
});
@@ -514,6 +572,12 @@ class NetworkManager {
return this._playerId;
}
/** Player display nickname (may be empty until profile is fetched). */
get nickname() {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
return (profile && profile.nickname) ? profile.nickname : '';
}
/** Current latency in ms. */
get latency() {
return this._latency;
+293
View File
@@ -0,0 +1,293 @@
/**
* PlayerProfile.js
* Manages the player's public profile (nickname + avatar url) for the game.
*
* Acquisition strategy (WeChat mini-game, compliant, 2024+):
* 1. Try to read nickname from local storage cache (`playerProfile`).
* 2. If absent, try `wx.getUserInfo({ withCredentials: false })` — this does NOT
* require an authorization popup since 2022 and returns an anonymous
* "微信用户" placeholder + default avatar. That is acceptable as the
* silent fallback.
* 3. The caller (MenuScene) can additionally create a `UserInfoButton`
* and register a click handler via `bindUserInfoButton()` below to
* upgrade the placeholder into the real nickname when the user taps
* the overlay button.
*
* Display helpers:
* - `getDisplayName()` — returns a "safe" display string (nickname if set,
* otherwise "坦克手_XXXX" derived from playerId).
* - `truncate(name, n)` — truncate at n Chinese-equivalent characters.
*/
const STORAGE_KEY = 'playerProfile';
class PlayerProfile {
constructor() {
/** @type {string} Real WeChat nickname, '' if not yet granted. */
this._nickname = '';
/** @type {string} Avatar URL, '' if not yet granted. */
this._avatarUrl = '';
/** @type {boolean} Whether we have attempted a silent fetch at least once. */
this._silentFetched = false;
/** @type {boolean} Whether the real (non-anonymous) nickname has been granted. */
this._granted = false;
this._loadFromCache();
}
// ============================================================
// Public API
// ============================================================
/** @returns {string} the cached nickname, or '' if never set. */
get nickname() {
return this._nickname;
}
/** @returns {string} avatar URL or '' */
get avatarUrl() {
return this._avatarUrl;
}
/** @returns {boolean} whether the user has granted the real nickname. */
get granted() {
return this._granted;
}
/**
* Build a safe display name for UI rendering.
* Order of preference: real nickname > anonymous from last silent fetch
* > deterministic "Tanker_XXXX" fallback based on playerId.
* @param {string} [playerIdFallback] - optional player id for deterministic fallback.
* @returns {string}
*/
getDisplayName(playerIdFallback) {
if (this._nickname) return this._nickname;
if (playerIdFallback && typeof playerIdFallback === 'string') {
// Use the last 4 chars of playerId for a stable anonymous tag.
const tail = playerIdFallback.slice(-4).toUpperCase();
return `Tanker_${tail}`;
}
return 'Tanker';
}
/**
* Truncate a display name to at most `maxChineseChars` Chinese-equivalent chars.
* A Chinese char counts as 1; a latin char counts as 0.5.
* @param {string} name
* @param {number} [maxChineseChars=4]
* @returns {string}
*/
truncate(name, maxChineseChars = 4) {
if (!name) return '';
let widthBudget = maxChineseChars * 2; // in half-width units
let out = '';
for (let i = 0; i < name.length; i++) {
const ch = name.charAt(i);
const code = name.charCodeAt(i);
// Treat CJK + Full-width chars as 2 half-width units, others as 1.
const w = code > 0x7f ? 2 : 1;
if (widthBudget - w < 0) {
return out + '..';
}
widthBudget -= w;
out += ch;
}
return out;
}
/**
* Try to fetch the anonymous nickname silently.
* In modern WeChat this resolves with a placeholder user ("微信用户") but
* without popping any authorization UI. Safe to call on MenuScene enter.
* @returns {Promise<boolean>} true if something was set.
*/
fetchSilent() {
return new Promise((resolve) => {
if (this._granted || this._silentFetched) {
resolve(!!this._nickname);
return;
}
this._silentFetched = true;
try {
if (typeof wx === 'undefined' || typeof wx.getUserInfo !== 'function') {
resolve(false);
return;
}
wx.getUserInfo({
withCredentials: false,
success: (res) => {
const u = res && res.userInfo;
if (u && u.nickName) {
// NOTE: Since WeChat base library 2.27+, wx.getUserInfo returns
// an anonymous placeholder ("微信用户") on real devices, but the
// devtools environment may still return the real nickname — we
// MUST NOT promote it to "granted" here, otherwise the cached
// granted=true would prevent the UserInfoButton from ever being
// created on real devices. The only source of truth for a real,
// granted nickname is `applyUserInfoResult` (button tap).
if (!this._nickname) {
this._nickname = u.nickName;
this._avatarUrl = u.avatarUrl || '';
this._saveToCache();
}
resolve(true);
return;
}
resolve(false);
},
fail: () => resolve(false),
});
} catch (e) {
console.warn('[PlayerProfile] fetchSilent error:', e && e.message);
resolve(false);
}
});
}
/**
* Detect the well-known WeChat anonymous placeholder. Since 2022-10,
* `wx.getUserInfo` / `UserInfoButton` return this string for any user who
* has not explicitly granted profile access — it must NOT be promoted to
* the "granted" state.
* @param {string} name
* @returns {boolean}
*/
isPlaceholderName(name) {
if (!name) return true;
return name === '微信用户' || name === 'WeChat User' || name === 'Weixin User';
}
/**
* Handle the result of a `UserInfoButton` tap. Should be wired in by
* whichever scene created the button (usually MenuScene).
* @param {object} res - the `res` payload passed into the button's onTap callback.
* @returns {boolean} true if a REAL (non-placeholder) nickname was stored.
*/
applyUserInfoResult(res) {
const u = res && res.userInfo;
if (!u || !u.nickName) return false;
// Reject WeChat's anonymous placeholder — it's what `getUserInfo` now
// returns for non-granted users and we must not mark that as "granted".
if (this.isPlaceholderName(u.nickName)) {
console.log('[PlayerProfile] Ignored placeholder nickname from UserInfoButton.');
return false;
}
this._nickname = u.nickName;
this._avatarUrl = u.avatarUrl || '';
this._granted = true;
this._saveToCache();
console.log('[PlayerProfile] Nickname granted:', this._nickname);
return true;
}
/**
* Active authorization flow for WeChat mini-GAMES.
*
* Since 2022-10 `wx.createUserInfoButton` silently returns the "微信用户"
* placeholder, so the ONLY API that still pops a real authorization UI and
* returns the user's actual WeChat nickname on small-game runtimes is
* `wx.getUserProfile`. This call MUST be triggered directly by a user tap
* (a touchend / click handler) — not by any async continuation — or WeChat
* will reject it with `fail api scope is not declared in the privacy agreement`.
*
* @returns {Promise<boolean>} true iff a real nickname was granted.
*/
requestUserProfile() {
return new Promise((resolve) => {
try {
if (typeof wx === 'undefined' || typeof wx.getUserProfile !== 'function') {
resolve(false);
return;
}
wx.getUserProfile({
desc: '用于在对战中展示你的昵称',
lang: 'zh_CN',
success: (res) => {
const u = res && res.userInfo;
if (!u || !u.nickName || this.isPlaceholderName(u.nickName)) {
console.log('[PlayerProfile] getUserProfile returned placeholder.');
resolve(false);
return;
}
this._nickname = u.nickName;
this._avatarUrl = u.avatarUrl || '';
this._granted = true;
this._saveToCache();
console.log('[PlayerProfile] Nickname granted via getUserProfile:', this._nickname);
resolve(true);
},
fail: (err) => {
console.log('[PlayerProfile] getUserProfile fail:', err && err.errMsg);
resolve(false);
},
});
} catch (e) {
console.warn('[PlayerProfile] requestUserProfile error:', e && e.message);
resolve(false);
}
});
}
/**
* Manual nickname fallback — used when `getUserProfile` is unavailable or
* denied (e.g. user refused, API deprecated). Stores the user-typed name
* and marks the profile as "granted" so the button stops prompting.
* @param {string} name
* @returns {boolean}
*/
setManualNickname(name) {
if (!name || typeof name !== 'string') return false;
const trimmed = name.trim();
if (!trimmed || this.isPlaceholderName(trimmed)) return false;
this._nickname = trimmed.slice(0, 16); // hard cap to 16 chars
this._granted = true;
this._saveToCache();
console.log('[PlayerProfile] Nickname set manually:', this._nickname);
return true;
}
/**
* Clear the cached profile (e.g. user wants to re-auth).
*/
reset() {
this._nickname = '';
this._avatarUrl = '';
this._granted = false;
try { wx.removeStorageSync(STORAGE_KEY); } catch (e) { /* ignore */ }
}
// ============================================================
// Private
// ============================================================
_loadFromCache() {
try {
const raw = wx.getStorageSync(STORAGE_KEY);
if (raw && typeof raw === 'object') {
this._nickname = raw.nickname || '';
this._avatarUrl = raw.avatarUrl || '';
this._granted = !!raw.granted;
}
} catch (e) {
// Ignore storage errors.
}
}
_saveToCache() {
try {
wx.setStorageSync(STORAGE_KEY, {
nickname: this._nickname,
avatarUrl: this._avatarUrl,
granted: this._granted,
});
} catch (e) {
// Ignore storage errors.
}
}
}
module.exports = PlayerProfile;
+50 -9
View File
@@ -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' };
}
+31 -14
View File
@@ -79,6 +79,7 @@ const GameScene = {
// Revive ad state
_reviveAdUsed: false,
_reviveCount: 0, // Track revive count for escalating cost
_showingReviveDialog: false,
_reviveDialogButtons: null,
@@ -97,6 +98,7 @@ const GameScene = {
this._gameOverDelay = 0;
this._cachedBasePos = null;
this._reviveAdUsed = false;
this._reviveCount = 0;
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
@@ -138,6 +140,12 @@ const GameScene = {
});
this._playerTank.activateShield(3000); // spawn protection
// 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
this._clearSpawnArea(levelData.playerSpawn.col, levelData.playerSpawn.row);
@@ -471,14 +479,15 @@ const GameScene = {
ctx.fillText(t('ad.watchAd') || '📺 Watch Ad (Free)', btns.watchAd.x + btns.watchAd.w / 2, btns.watchAd.y + btns.watchAd.h / 2);
}
// Gold Revive button (orange)
// Gold Revive button (orange) - show escalating cost
if (btns.goldRevive) {
const hasGold = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(200);
const reviveCost = this._getReviveCost();
const hasGold = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(reviveCost);
ctx.fillStyle = hasGold ? '#FF9800' : '#555555';
ctx.fillRect(btns.goldRevive.x, btns.goldRevive.y, btns.goldRevive.w, btns.goldRevive.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('ad.goldRevive') || '🪙 Gold Revive (200)', btns.goldRevive.x + btns.goldRevive.w / 2, btns.goldRevive.y + btns.goldRevive.h / 2);
ctx.fillText(`🪙 ${t('ad.goldRevive') || 'Gold Revive'} (${reviveCost})`, btns.goldRevive.x + btns.goldRevive.w / 2, btns.goldRevive.y + btns.goldRevive.h / 2);
}
// Give Up button (gray)
@@ -680,13 +689,8 @@ const GameScene = {
const hasLives = this._playerTank.die();
if (!hasLives) {
// Check if revive ad is available and not yet used this level
if (!this._reviveAdUsed) {
// Always show revive dialog (with ad and/or gold options)
// Always show revive dialog (escalating cost each time)
this._showReviveAdDialog();
} else {
this._triggerGameOver();
}
}
},
@@ -701,6 +705,18 @@ const GameScene = {
return GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.REVIVE);
},
/**
* Get the current revive gold cost based on revive count (escalating).
* 1st revive: 200, 2nd: 400, 3rd+: 800
* @returns {number}
* @private
*/
_getReviveCost() {
if (this._reviveCount === 0) return 200;
if (this._reviveCount === 1) return 400;
return 800;
},
/**
* Show the revive dialog overlay with dual options.
* Pauses the game and presents watch-ad / gold-revive / give-up options.
@@ -736,17 +752,18 @@ const GameScene = {
},
/**
* Handle the player choosing to revive with gold (200 gold).
* Handle the player choosing to revive with gold (escalating cost).
* @private
*/
_onGoldRevive() {
const cost = this._getReviveCost();
const cm = GameGlobal.currencyManager;
if (cm && cm.spendGold(200)) {
if (cm && cm.spendGold(cost)) {
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
this._reviveAdUsed = true;
this._reviveCount++;
this._revivePlayer();
console.log('[GameScene] Player revived via gold (200)');
console.log(`[GameScene] Player revived via gold (${cost}), revive #${this._reviveCount}`);
}
},
@@ -762,7 +779,7 @@ const GameScene = {
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
if (completed) {
this._reviveAdUsed = true;
this._reviveCount++;
this._revivePlayer();
} else {
this._triggerGameOver();
+339 -92
View File
@@ -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,8 +42,8 @@ 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 (shop, battle pass, ranking, settings)
const HALF_BTN_WIDTH = (BTN_WIDTH - BTN_GAP) / 2;
const THIRD_BTN_WIDTH = (BTN_WIDTH - BTN_GAP * 2) / 3;
// Main game mode buttons (full width, vertical)
const MAIN_BUTTONS = [
@@ -33,10 +53,11 @@ const MAIN_BUTTONS = [
{ labelKey: 'menu.team3v3', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM },
];
// Utility buttons: shop, daily gold, ranking, settings (2x2 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 },
{ labelKey: 'menu.settings', mode: null, scene: SCENE.SETTINGS },
];
@@ -50,11 +71,11 @@ const mainBtnRects = MAIN_BUTTONS.map((btn, i) => ({
...btn,
}));
// Pre-calculate button rects for utility buttons (2x2 grid)
// 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) => {
const col = i % 2;
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),
@@ -64,7 +85,6 @@ const utilBtnRects = UTIL_BUTTONS.map((btn, i) => {
};
});
// Combined list for unified iteration
const buttonRects = [...mainBtnRects, ...utilBtnRects];
// ============================================================
@@ -72,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;
@@ -96,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.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('menu.title'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.15);
// Subtitle
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22);
// Animated tank icon (simple oscillating triangle)
this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30);
// Main game mode buttons (full width)
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.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 = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 18px Arial';
ctx.fillStyle = MC.GOLD;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t(btn.labelKey), btn.x + btn.w / 2, btn.y + btn.h / 2);
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 = MC.SUBTITLE;
ctx.font = '13px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22);
// ---- Animated Tank Icon ----
this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30);
// ---- Main Buttons ----
for (let i = 0; i < mainBtnRects.length; i++) {
const btn = mainBtnRects[i];
const isPressed = this._pressedIndex === i;
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;
this._drawMenuButton(ctx, btn, label, isPressed, 'bold 13px Arial', 6, customBg);
}
// ---- Footer ----
ctx.fillStyle = MC.FOOTER;
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 18);
},
// ---- 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, 6);
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r);
ctx.fill();
ctx.stroke();
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 14px Arial';
// 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);
}
// Footer
ctx.fillStyle = '#555555';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20);
},
/**
* Draw a simple animated tank icon.
*/
// ---- 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);
@@ -244,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];
@@ -262,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) => {
@@ -289,6 +530,12 @@ const MenuScene = {
sm.register(SCENE.SHOP, ShopScene);
}
sm.switchTo(SCENE.SHOP);
} else if (btn.scene === SCENE.SKIN) {
if (!sm._scenes.has(SCENE.SKIN)) {
const SkinScene = require('./SkinScene');
sm.register(SCENE.SKIN, SkinScene);
}
sm.switchTo(SCENE.SKIN);
} else if (btn.scene === SCENE.SETTINGS) {
if (!sm._scenes.has(SCENE.SETTINGS)) {
const SettingsScene = require('./SettingsScene');
+12 -12
View File
@@ -82,27 +82,27 @@ const ResultScene = {
*/
_calculateGoldReward() {
const stats = this._stats;
let gold = 50; // Base reward per requirements
let gold = 30; // Base reward (reduced from 50)
// Bonus per kill type
gold += (stats.kills.normal || 0) * 5;
gold += (stats.kills.fast || 0) * 10;
gold += (stats.kills.armor || 0) * 15;
gold += (stats.kills.boss || 0) * 25;
// Bonus per kill type (reduced)
gold += (stats.kills.normal || 0) * 3;
gold += (stats.kills.fast || 0) * 5;
gold += (stats.kills.armor || 0) * 8;
gold += (stats.kills.boss || 0) * 15;
// Victory bonus
// Victory bonus (reduced from 50)
if (this._victory) {
gold += 50;
gold += 30;
}
// Time bonus (faster = more gold, max 30 gold for under 60s)
// Time bonus (faster = more gold, max 20 gold for under 60s, reduced from 30)
if (this._victory && stats.timeElapsed < 300) {
gold += Math.max(0, Math.floor((300 - stats.timeElapsed) / 10));
gold += Math.max(0, Math.floor((300 - stats.timeElapsed) / 15));
}
// Base alive bonus
// Base alive bonus (reduced from 20)
if (stats.baseAlive) {
gold += 20;
gold += 10;
}
return gold;
+138 -13
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+94 -7
View File
@@ -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) {
// Name & team indicator above the tank
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';
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';
const label = player.isBot ? '🤖' : (player.team === this._myTeam ? '▲' : '▼');
ctx.fillText(label, tx, ty);
// 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;
+34 -8
View File
@@ -151,19 +151,19 @@ const TeamResultScene = {
* @private
*/
_calculateAndAwardGold() {
let gold = 50; // Base reward per requirements
let gold = 30; // Base reward (reduced from 50)
// Find local player stats
const localPlayer = this._players.find(p => p.isLocal);
if (localPlayer) {
const stats = this._stats[localPlayer.playerId] || {};
gold += (stats.kills || 0) * 10;
gold += (stats.assists || 0) * 5;
gold += (stats.kills || 0) * 5;
gold += (stats.assists || 0) * 3;
}
// Victory bonus
// Victory bonus (reduced from 50)
if (this._didWin) {
gold += 50;
gold += 30;
}
this._goldReward = gold;
@@ -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;
+25 -2
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.DS_Store
.git
.gitignore
Dockerfile
.dockerignore
*.md
+256
View File
@@ -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个副本
- 资源限制:内存512MiCPU 500m
- 健康检查探针
3. **Service**:
- LoadBalancer类型
- 端口3000
## 网络配置
### 服务暴露
服务使用LoadBalancer类型,将通过云平台的负载均衡器暴露:
```bash
# 获取外部IP
kubectl get svc tankwar-server-service -n tankwar
# WebSocket连接地址
ws://<external-ip>:3000
```
### 端口映射
- 容器端口:3000
- 服务端口:3000
- 外部访问端口:3000
## 健康检查
服务器提供HTTP健康检查端点:
```bash
# 健康检查URL
http://<external-ip>: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 <pod-name> -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 <pod-name> -n tankwar -- wget -qO- http://localhost:3000/health
```
### 调试命令
```bash
# 进入Pod调试
kubectl exec -it <pod-name> -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`
+25
View File
@@ -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"]
+183
View File
@@ -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://<external-ip>:3000
```
## 监控和日志
### 查看日志
```bash
# 查看所有Pod日志
kubectl logs -l app=tankwar-server -n tankwar
# 查看特定Pod日志
kubectl logs <pod-name> -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 <pod-name> -n tankwar`
- 检查资源配额是否足够
4. **连接问题**
- 确认服务已分配外部IP
- 检查防火墙规则
### 调试命令
```bash
# 查看Pod详细信息
kubectl describe pod -l app=tankwar-server -n tankwar
# 进入Pod调试
kubectl exec -it <pod-name> -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控制器
- 配置适当的网络策略
- 定期更新镜像以修复安全漏洞
- 监控资源使用情况防止资源耗尽
+46
View File
@@ -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"
+96 -22
View File
@@ -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`);
+138
View File
@@ -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
+97
View File
@@ -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
+75
View File
@@ -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 ""
+72
View File
@@ -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://<external-ip>: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