Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38294c040c | |||
| 0e321bcea6 |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: tankwar
|
||||
labels:
|
||||
name: tankwar
|
||||
app: tankwar
|
||||
Executable
+165
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -45,6 +45,7 @@ class PlayerTank extends Tank {
|
||||
|
||||
// Skin colors (reserved for future use)
|
||||
this._skinColors = null;
|
||||
this._skinId = 'default';
|
||||
|
||||
// Fire level system
|
||||
this.fireLevel = FIRE_LEVEL.LV1;
|
||||
|
||||
+24
-2
@@ -13,6 +13,7 @@ const {
|
||||
DIRECTION,
|
||||
DIR_VECTORS,
|
||||
} = require('../base/GameGlobal');
|
||||
const { drawTankSkin, DESIGN_HALF_SIZE } = require('./TankSkinRenderer');
|
||||
|
||||
class Tank {
|
||||
/**
|
||||
@@ -288,9 +289,30 @@ class Tank {
|
||||
};
|
||||
ctx.rotate(angles[this.direction]);
|
||||
|
||||
const hs = this.halfSize;
|
||||
// ★ Unified skin path — any tank with a skin id uses the SAME drawing
|
||||
// code as the SkinScene preview. Scale to match the actual tank size.
|
||||
// Clip laterally to the collision box so wide tracks / decorations
|
||||
// don't make the tank look wider than non-skinned tanks. Leave the
|
||||
// top/bottom un-clipped so the barrel can extend naturally (same as
|
||||
// legacy rendering).
|
||||
if (this._skinId) {
|
||||
const t = (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
|
||||
const k = this.halfSize / DESIGN_HALF_SIZE;
|
||||
// Clip: lateral bounds = collision box; vertical = generous to allow barrel
|
||||
const barrelExtra = this.size * 0.55; // same as legacy barrelH
|
||||
ctx.beginPath();
|
||||
ctx.rect(-this.halfSize, -this.halfSize - barrelExtra, this.size, this.size + barrelExtra * 2);
|
||||
ctx.clip();
|
||||
ctx.save();
|
||||
ctx.scale(k, k);
|
||||
drawTankSkin(ctx, this._skinId, this._skinColors, t);
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine colors: use skin colors if this is a player tank with a skin
|
||||
// ── Legacy fallback for tanks without a skin id (enemy AI, etc.) ──
|
||||
const hs = this.halfSize;
|
||||
let bodyColor = this.color;
|
||||
let turretColor = this._darkenColor(this.color, 0.3);
|
||||
let trackColor = this._darkenColor(this.color, 0.4);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+24
-1
@@ -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
@@ -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': '✓ 已解锁!',
|
||||
};
|
||||
|
||||
@@ -51,9 +51,10 @@ class NetworkManager {
|
||||
/**
|
||||
* Connect to the WebSocket server.
|
||||
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws').
|
||||
* @param {number} [timeoutMs=10000] - Connect timeout in milliseconds.
|
||||
* @returns {Promise<boolean>} Whether connection succeeded.
|
||||
*/
|
||||
connect(serverUrl) {
|
||||
connect(serverUrl, timeoutMs = 10000) {
|
||||
return new Promise((resolve) => {
|
||||
if (this._connected || this._connecting) {
|
||||
resolve(this._connected);
|
||||
@@ -64,20 +65,53 @@ class NetworkManager {
|
||||
this._connecting = true;
|
||||
this._shouldReconnect = true;
|
||||
|
||||
// Guard: make sure resolve is called exactly once.
|
||||
let settled = false;
|
||||
const finish = (ok, reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
}
|
||||
if (!ok) {
|
||||
// Tear down broken socket so next connect() starts clean.
|
||||
this._connecting = false;
|
||||
this._shouldReconnect = false; // a first-time failure should NOT auto-reconnect
|
||||
if (this._ws) {
|
||||
try { this._ws.close({}); } catch (e) { /* ignore */ }
|
||||
this._ws = null;
|
||||
}
|
||||
console.warn('[NetworkManager] connect() failed:', reason || 'unknown');
|
||||
}
|
||||
resolve(ok);
|
||||
};
|
||||
|
||||
// Connection timeout guard (e.g. DNS/TLS hang on cellular).
|
||||
let connectTimer = setTimeout(() => {
|
||||
finish(false, `connect timeout after ${timeoutMs}ms, url=${serverUrl}`);
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
this._ws = wx.connectSocket({
|
||||
url: serverUrl,
|
||||
header: { 'content-type': 'application/json' },
|
||||
// Surface wx.connectSocket API-level failures (invalid url / domain not whitelisted / etc.)
|
||||
success: (res) => { console.log('[NetworkManager] wx.connectSocket invoked:', res && res.errMsg); },
|
||||
fail: (err) => {
|
||||
console.error('[NetworkManager] wx.connectSocket API failed:', JSON.stringify(err));
|
||||
finish(false, `wx.connectSocket fail: ${err && err.errMsg}`);
|
||||
},
|
||||
});
|
||||
|
||||
this._ws.onOpen(() => {
|
||||
console.log('[NetworkManager] Connected to server');
|
||||
console.log('[NetworkManager] Connected to server:', serverUrl);
|
||||
this._connected = true;
|
||||
this._connecting = false;
|
||||
this._reconnectAttempts = 0;
|
||||
this._startHeartbeat();
|
||||
this._emit('connected');
|
||||
resolve(true);
|
||||
finish(true);
|
||||
});
|
||||
|
||||
this._ws.onMessage((res) => {
|
||||
@@ -85,28 +119,45 @@ class NetworkManager {
|
||||
});
|
||||
|
||||
this._ws.onError((err) => {
|
||||
console.error('[NetworkManager] WebSocket error:', err);
|
||||
this._connecting = false;
|
||||
// Log as much context as possible; wx error objects vary across platforms.
|
||||
console.error('[NetworkManager] WebSocket error:',
|
||||
(err && (err.errMsg || err.message)) || err,
|
||||
'url=', serverUrl);
|
||||
this._emit('error', err);
|
||||
resolve(false);
|
||||
// If the error arrives before we ever got onOpen, treat it as a connect failure.
|
||||
if (!this._connected) {
|
||||
finish(false, `onError before open: ${err && (err.errMsg || err.message)}`);
|
||||
} else {
|
||||
// Runtime error on an established connection — let onClose handle reconnection.
|
||||
this._connecting = false;
|
||||
}
|
||||
});
|
||||
|
||||
this._ws.onClose((res) => {
|
||||
console.log('[NetworkManager] Connection closed:', res.code, res.reason);
|
||||
const code = res && res.code;
|
||||
const reason = res && res.reason;
|
||||
console.log('[NetworkManager] Connection closed:', code, reason, 'url=', serverUrl);
|
||||
|
||||
const wasConnected = this._connected;
|
||||
this._connected = false;
|
||||
this._connecting = false;
|
||||
this._stopHeartbeat();
|
||||
this._emit('disconnected', { code: res.code, reason: res.reason });
|
||||
this._emit('disconnected', { code, reason });
|
||||
|
||||
// Auto-reconnect if needed
|
||||
// If onClose arrives before onOpen, this is a connect failure.
|
||||
if (!wasConnected) {
|
||||
finish(false, `onClose before open: code=${code} reason=${reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-reconnect only for drops on an already-established connection.
|
||||
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
|
||||
this._attemptReconnect();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[NetworkManager] Failed to create WebSocket:', e);
|
||||
this._connecting = false;
|
||||
resolve(false);
|
||||
finish(false, `exception: ${e && e.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -145,10 +196,17 @@ class NetworkManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always include the player's current nickname (if any) so the server
|
||||
// can propagate it to other clients. Falls back silently when the
|
||||
// profile is not yet available.
|
||||
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
|
||||
const nickname = (profile && profile.nickname) ? profile.nickname : '';
|
||||
|
||||
const message = JSON.stringify({
|
||||
type,
|
||||
data,
|
||||
playerId: this._playerId,
|
||||
nickname,
|
||||
roomId: this._roomId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -514,6 +572,12 @@ class NetworkManager {
|
||||
return this._playerId;
|
||||
}
|
||||
|
||||
/** Player display nickname (may be empty until profile is fetched). */
|
||||
get nickname() {
|
||||
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
|
||||
return (profile && profile.nickname) ? profile.nickname : '';
|
||||
}
|
||||
|
||||
/** Current latency in ms. */
|
||||
get latency() {
|
||||
return this._latency;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* PlayerProfile.js
|
||||
* Manages the player's public profile (nickname + avatar url) for the game.
|
||||
*
|
||||
* Acquisition strategy (WeChat mini-game, compliant, 2024+):
|
||||
* 1. Try to read nickname from local storage cache (`playerProfile`).
|
||||
* 2. If absent, try `wx.getUserInfo({ withCredentials: false })` — this does NOT
|
||||
* require an authorization popup since 2022 and returns an anonymous
|
||||
* "微信用户" placeholder + default avatar. That is acceptable as the
|
||||
* silent fallback.
|
||||
* 3. The caller (MenuScene) can additionally create a `UserInfoButton`
|
||||
* and register a click handler via `bindUserInfoButton()` below to
|
||||
* upgrade the placeholder into the real nickname when the user taps
|
||||
* the overlay button.
|
||||
*
|
||||
* Display helpers:
|
||||
* - `getDisplayName()` — returns a "safe" display string (nickname if set,
|
||||
* otherwise "坦克手_XXXX" derived from playerId).
|
||||
* - `truncate(name, n)` — truncate at n Chinese-equivalent characters.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'playerProfile';
|
||||
|
||||
class PlayerProfile {
|
||||
constructor() {
|
||||
/** @type {string} Real WeChat nickname, '' if not yet granted. */
|
||||
this._nickname = '';
|
||||
/** @type {string} Avatar URL, '' if not yet granted. */
|
||||
this._avatarUrl = '';
|
||||
/** @type {boolean} Whether we have attempted a silent fetch at least once. */
|
||||
this._silentFetched = false;
|
||||
/** @type {boolean} Whether the real (non-anonymous) nickname has been granted. */
|
||||
this._granted = false;
|
||||
|
||||
this._loadFromCache();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/** @returns {string} the cached nickname, or '' if never set. */
|
||||
get nickname() {
|
||||
return this._nickname;
|
||||
}
|
||||
|
||||
/** @returns {string} avatar URL or '' */
|
||||
get avatarUrl() {
|
||||
return this._avatarUrl;
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether the user has granted the real nickname. */
|
||||
get granted() {
|
||||
return this._granted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a safe display name for UI rendering.
|
||||
* Order of preference: real nickname > anonymous from last silent fetch
|
||||
* > deterministic "Tanker_XXXX" fallback based on playerId.
|
||||
* @param {string} [playerIdFallback] - optional player id for deterministic fallback.
|
||||
* @returns {string}
|
||||
*/
|
||||
getDisplayName(playerIdFallback) {
|
||||
if (this._nickname) return this._nickname;
|
||||
if (playerIdFallback && typeof playerIdFallback === 'string') {
|
||||
// Use the last 4 chars of playerId for a stable anonymous tag.
|
||||
const tail = playerIdFallback.slice(-4).toUpperCase();
|
||||
return `Tanker_${tail}`;
|
||||
}
|
||||
return 'Tanker';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a display name to at most `maxChineseChars` Chinese-equivalent chars.
|
||||
* A Chinese char counts as 1; a latin char counts as 0.5.
|
||||
* @param {string} name
|
||||
* @param {number} [maxChineseChars=4]
|
||||
* @returns {string}
|
||||
*/
|
||||
truncate(name, maxChineseChars = 4) {
|
||||
if (!name) return '';
|
||||
let widthBudget = maxChineseChars * 2; // in half-width units
|
||||
let out = '';
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
const ch = name.charAt(i);
|
||||
const code = name.charCodeAt(i);
|
||||
// Treat CJK + Full-width chars as 2 half-width units, others as 1.
|
||||
const w = code > 0x7f ? 2 : 1;
|
||||
if (widthBudget - w < 0) {
|
||||
return out + '..';
|
||||
}
|
||||
widthBudget -= w;
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch the anonymous nickname silently.
|
||||
* In modern WeChat this resolves with a placeholder user ("微信用户") but
|
||||
* without popping any authorization UI. Safe to call on MenuScene enter.
|
||||
* @returns {Promise<boolean>} true if something was set.
|
||||
*/
|
||||
fetchSilent() {
|
||||
return new Promise((resolve) => {
|
||||
if (this._granted || this._silentFetched) {
|
||||
resolve(!!this._nickname);
|
||||
return;
|
||||
}
|
||||
this._silentFetched = true;
|
||||
|
||||
try {
|
||||
if (typeof wx === 'undefined' || typeof wx.getUserInfo !== 'function') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
wx.getUserInfo({
|
||||
withCredentials: false,
|
||||
success: (res) => {
|
||||
const u = res && res.userInfo;
|
||||
if (u && u.nickName) {
|
||||
// NOTE: Since WeChat base library 2.27+, wx.getUserInfo returns
|
||||
// an anonymous placeholder ("微信用户") on real devices, but the
|
||||
// devtools environment may still return the real nickname — we
|
||||
// MUST NOT promote it to "granted" here, otherwise the cached
|
||||
// granted=true would prevent the UserInfoButton from ever being
|
||||
// created on real devices. The only source of truth for a real,
|
||||
// granted nickname is `applyUserInfoResult` (button tap).
|
||||
if (!this._nickname) {
|
||||
this._nickname = u.nickName;
|
||||
this._avatarUrl = u.avatarUrl || '';
|
||||
this._saveToCache();
|
||||
}
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(false);
|
||||
},
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[PlayerProfile] fetchSilent error:', e && e.message);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the well-known WeChat anonymous placeholder. Since 2022-10,
|
||||
* `wx.getUserInfo` / `UserInfoButton` return this string for any user who
|
||||
* has not explicitly granted profile access — it must NOT be promoted to
|
||||
* the "granted" state.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPlaceholderName(name) {
|
||||
if (!name) return true;
|
||||
return name === '微信用户' || name === 'WeChat User' || name === 'Weixin User';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the result of a `UserInfoButton` tap. Should be wired in by
|
||||
* whichever scene created the button (usually MenuScene).
|
||||
* @param {object} res - the `res` payload passed into the button's onTap callback.
|
||||
* @returns {boolean} true if a REAL (non-placeholder) nickname was stored.
|
||||
*/
|
||||
applyUserInfoResult(res) {
|
||||
const u = res && res.userInfo;
|
||||
if (!u || !u.nickName) return false;
|
||||
|
||||
// Reject WeChat's anonymous placeholder — it's what `getUserInfo` now
|
||||
// returns for non-granted users and we must not mark that as "granted".
|
||||
if (this.isPlaceholderName(u.nickName)) {
|
||||
console.log('[PlayerProfile] Ignored placeholder nickname from UserInfoButton.');
|
||||
return false;
|
||||
}
|
||||
|
||||
this._nickname = u.nickName;
|
||||
this._avatarUrl = u.avatarUrl || '';
|
||||
this._granted = true;
|
||||
this._saveToCache();
|
||||
console.log('[PlayerProfile] Nickname granted:', this._nickname);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active authorization flow for WeChat mini-GAMES.
|
||||
*
|
||||
* Since 2022-10 `wx.createUserInfoButton` silently returns the "微信用户"
|
||||
* placeholder, so the ONLY API that still pops a real authorization UI and
|
||||
* returns the user's actual WeChat nickname on small-game runtimes is
|
||||
* `wx.getUserProfile`. This call MUST be triggered directly by a user tap
|
||||
* (a touchend / click handler) — not by any async continuation — or WeChat
|
||||
* will reject it with `fail api scope is not declared in the privacy agreement`.
|
||||
*
|
||||
* @returns {Promise<boolean>} true iff a real nickname was granted.
|
||||
*/
|
||||
requestUserProfile() {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
if (typeof wx === 'undefined' || typeof wx.getUserProfile !== 'function') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
wx.getUserProfile({
|
||||
desc: '用于在对战中展示你的昵称',
|
||||
lang: 'zh_CN',
|
||||
success: (res) => {
|
||||
const u = res && res.userInfo;
|
||||
if (!u || !u.nickName || this.isPlaceholderName(u.nickName)) {
|
||||
console.log('[PlayerProfile] getUserProfile returned placeholder.');
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
this._nickname = u.nickName;
|
||||
this._avatarUrl = u.avatarUrl || '';
|
||||
this._granted = true;
|
||||
this._saveToCache();
|
||||
console.log('[PlayerProfile] Nickname granted via getUserProfile:', this._nickname);
|
||||
resolve(true);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log('[PlayerProfile] getUserProfile fail:', err && err.errMsg);
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[PlayerProfile] requestUserProfile error:', e && e.message);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual nickname fallback — used when `getUserProfile` is unavailable or
|
||||
* denied (e.g. user refused, API deprecated). Stores the user-typed name
|
||||
* and marks the profile as "granted" so the button stops prompting.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
setManualNickname(name) {
|
||||
if (!name || typeof name !== 'string') return false;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || this.isPlaceholderName(trimmed)) return false;
|
||||
this._nickname = trimmed.slice(0, 16); // hard cap to 16 chars
|
||||
this._granted = true;
|
||||
this._saveToCache();
|
||||
console.log('[PlayerProfile] Nickname set manually:', this._nickname);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached profile (e.g. user wants to re-auth).
|
||||
*/
|
||||
reset() {
|
||||
this._nickname = '';
|
||||
this._avatarUrl = '';
|
||||
this._granted = false;
|
||||
try { wx.removeStorageSync(STORAGE_KEY); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private
|
||||
// ============================================================
|
||||
|
||||
_loadFromCache() {
|
||||
try {
|
||||
const raw = wx.getStorageSync(STORAGE_KEY);
|
||||
if (raw && typeof raw === 'object') {
|
||||
this._nickname = raw.nickname || '';
|
||||
this._avatarUrl = raw.avatarUrl || '';
|
||||
this._granted = !!raw.granted;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
_saveToCache() {
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY, {
|
||||
nickname: this._nickname,
|
||||
avatarUrl: this._avatarUrl,
|
||||
granted: this._granted,
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlayerProfile;
|
||||
@@ -4,6 +4,12 @@
|
||||
* Skins are cosmetic-only color schemes purchased with gold.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DEV MODE: Set to true to unlock all skins for testing.
|
||||
* ⚠️ MUST be set to false before publishing!
|
||||
*/
|
||||
const DEV_UNLOCK_ALL = false;
|
||||
|
||||
/** Skin definitions with id, name, cost, and color scheme. */
|
||||
const SKINS = {
|
||||
default: {
|
||||
@@ -45,15 +51,15 @@ const SKINS = {
|
||||
id: 'neon',
|
||||
nameKey: 'skin.neon',
|
||||
cost: 2000,
|
||||
colors: { body: '#00FF7F', turret: '#00CED1', track: '#008B8B' },
|
||||
preview: '#00FF7F',
|
||||
colors: { body: '#FF1493', turret: '#FF6EC7', track: '#C71585' },
|
||||
preview: '#FF1493',
|
||||
},
|
||||
shadow: {
|
||||
id: 'shadow',
|
||||
nameKey: 'skin.shadow',
|
||||
nebula: {
|
||||
id: 'nebula',
|
||||
nameKey: 'skin.nebula',
|
||||
cost: 3000,
|
||||
colors: { body: '#2C2C2C', turret: '#1A1A1A', track: '#0D0D0D' },
|
||||
preview: '#2C2C2C',
|
||||
colors: { body: '#6A0DAD', turret: '#FF00FF', track: '#3D0066' },
|
||||
preview: '#6A0DAD',
|
||||
},
|
||||
royal: {
|
||||
id: 'royal',
|
||||
@@ -62,10 +68,31 @@ const SKINS = {
|
||||
colors: { body: '#FFD700', turret: '#DAA520', track: '#B8860B' },
|
||||
preview: '#FFD700',
|
||||
},
|
||||
sakura: {
|
||||
id: 'sakura',
|
||||
nameKey: 'skin.sakura',
|
||||
cost: 3500,
|
||||
colors: { body: '#FFB7C5', turret: '#FF69B4', track: '#C44D78' },
|
||||
preview: '#FFB7C5',
|
||||
},
|
||||
thunder: {
|
||||
id: 'thunder',
|
||||
nameKey: 'skin.thunder',
|
||||
cost: 4000,
|
||||
colors: { body: '#1E90FF', turret: '#00BFFF', track: '#0A5E9C' },
|
||||
preview: '#1E90FF',
|
||||
},
|
||||
diamond: {
|
||||
id: 'diamond',
|
||||
nameKey: 'skin.diamond',
|
||||
cost: 8000,
|
||||
colors: { body: '#E0F7FF', turret: '#7DF9FF', track: '#5B8FA8' },
|
||||
preview: '#7DF9FF',
|
||||
},
|
||||
};
|
||||
|
||||
/** Ordered list of skin IDs for display. */
|
||||
const SKIN_ORDER = ['default', 'arctic', 'inferno', 'jungle', 'phantom', 'neon', 'shadow', 'royal'];
|
||||
const SKIN_ORDER = ['default', 'arctic', 'inferno', 'jungle', 'phantom', 'neon', 'nebula', 'royal', 'sakura', 'thunder', 'diamond'];
|
||||
|
||||
class SkinManager {
|
||||
constructor() {
|
||||
@@ -131,6 +158,7 @@ class SkinManager {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isUnlocked(skinId) {
|
||||
if (DEV_UNLOCK_ALL) return true;
|
||||
return this._unlocked.has(skinId);
|
||||
}
|
||||
|
||||
@@ -180,6 +208,19 @@ class SkinManager {
|
||||
return { success: false, error: 'Already unlocked' };
|
||||
}
|
||||
|
||||
// Dev mode: unlock for free without spending gold
|
||||
if (DEV_UNLOCK_ALL) {
|
||||
this._unlocked.add(skinId);
|
||||
this._save();
|
||||
console.log(`[SkinManager][DEV] Free unlock skin: ${skinId}`);
|
||||
try {
|
||||
if (GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('skin:purchased', { id: skinId, cost: 0 });
|
||||
}
|
||||
} catch (e) {}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const cm = GameGlobal.currencyManager;
|
||||
if (!cm || !cm.hasGold(skin.cost)) {
|
||||
return { success: false, error: 'Insufficient gold' };
|
||||
@@ -215,7 +256,7 @@ class SkinManager {
|
||||
return { success: false, error: 'Invalid skin' };
|
||||
}
|
||||
|
||||
if (!this._unlocked.has(skinId)) {
|
||||
if (!DEV_UNLOCK_ALL && !this._unlocked.has(skinId)) {
|
||||
return { success: false, error: 'Not unlocked' };
|
||||
}
|
||||
|
||||
|
||||
+32
-15
@@ -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)
|
||||
this._showReviveAdDialog();
|
||||
} else {
|
||||
this._triggerGameOver();
|
||||
}
|
||||
// Always show revive dialog (escalating cost each time)
|
||||
this._showReviveAdDialog();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
+337
-90
@@ -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.fillStyle = 'rgba(255, 215, 0, 0.08)';
|
||||
ctx.strokeStyle = 'rgba(255, 215, 0, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
this._roundRect(ctx, pillX, pillY, pillW, pillH, pillH / 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = MC.GOLD;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(goldText, pillX + pillW / 2, pillY + pillH / 2);
|
||||
|
||||
// ---- Title with glow ----
|
||||
ctx.save();
|
||||
ctx.shadowColor = MC.GOLD;
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.fillStyle = MC.TITLE;
|
||||
ctx.font = 'bold 34px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('menu.title'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.15);
|
||||
ctx.restore();
|
||||
|
||||
// Subtitle
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
// ---- Subtitle ----
|
||||
ctx.fillStyle = MC.SUBTITLE;
|
||||
ctx.font = '13px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22);
|
||||
|
||||
// Animated tank icon (simple oscillating triangle)
|
||||
// ---- Animated Tank Icon ----
|
||||
this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30);
|
||||
|
||||
// Main game mode buttons (full width)
|
||||
// ---- Main Buttons ----
|
||||
for (let i = 0; i < mainBtnRects.length; i++) {
|
||||
const btn = mainBtnRects[i];
|
||||
const isPressed = this._pressedIndex === i;
|
||||
|
||||
ctx.fillStyle = isPressed ? '#0f3460' : COLORS.MENU_BTN;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 18px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t(btn.labelKey), btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||||
this._drawMenuButton(ctx, btn, t(btn.labelKey), isPressed, 'bold 16px Arial', 8);
|
||||
}
|
||||
|
||||
// Utility buttons (2x2 grid, smaller font)
|
||||
// ---- Utility Buttons ----
|
||||
for (let i = 0; i < utilBtnRects.length; i++) {
|
||||
const btn = utilBtnRects[i];
|
||||
const globalIdx = mainBtnRects.length + i;
|
||||
const isPressed = this._pressedIndex === globalIdx;
|
||||
|
||||
// Special rendering for daily gold button
|
||||
const isDailyGold = btn.scene === 'DAILY_GOLD';
|
||||
let label = t(btn.labelKey);
|
||||
let btnColor = COLORS.MENU_BTN;
|
||||
let customBg = null;
|
||||
|
||||
if (isDailyGold) {
|
||||
const remaining = GameGlobal.adManager ? GameGlobal.adManager.getDailyGoldRemaining() : 0;
|
||||
if (remaining > 0) {
|
||||
label = `${t('dailyGold.btn')} ${remaining}/3`;
|
||||
btnColor = '#2E7D32'; // green tint
|
||||
customBg = '#1a3a2a';
|
||||
} else {
|
||||
label = t('dailyGold.exhausted') || 'Come back tomorrow';
|
||||
btnColor = '#555555';
|
||||
customBg = '#2a2a2a';
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = isPressed ? '#0f3460' : btnColor;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 6);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||||
this._drawMenuButton(ctx, btn, label, isPressed, 'bold 13px Arial', 6, customBg);
|
||||
}
|
||||
|
||||
// Footer
|
||||
ctx.fillStyle = '#555555';
|
||||
ctx.font = '11px Arial';
|
||||
// ---- Footer ----
|
||||
ctx.fillStyle = MC.FOOTER;
|
||||
ctx.font = '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20);
|
||||
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 18);
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw a simple animated tank icon.
|
||||
*/
|
||||
// ---- Menu Button ----
|
||||
_drawMenuButton(ctx, btn, label, isPressed, font, radius, customBg) {
|
||||
const r = radius || 8;
|
||||
|
||||
// Shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
this._roundRect(ctx, btn.x + 1, btn.y + 2, btn.w, btn.h, r);
|
||||
ctx.fill();
|
||||
|
||||
// Body
|
||||
ctx.fillStyle = isPressed ? MC.BTN_HOVER : (customBg || MC.BTN_BG);
|
||||
ctx.strokeStyle = MC.BTN_BORDER;
|
||||
ctx.lineWidth = 1.5;
|
||||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = isPressed ? MC.TITLE : MC.BTN_TEXT;
|
||||
ctx.font = font || 'bold 16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||||
},
|
||||
|
||||
// ---- Tank Icon ----
|
||||
_drawTankIcon(ctx, cx, cy) {
|
||||
const bounce = Math.sin(this._tankAnim * 3) * 3;
|
||||
const size = 20;
|
||||
const bounce = Math.sin(this._tankAnim * 3) * 2;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(this._tankAnim * 2);
|
||||
const s = 15; // body half-size (square tank: 2s × 2s)
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy + bounce);
|
||||
|
||||
// Tank body
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.fillRect(-size, -size / 2, size * 2, size);
|
||||
// ── 1. OUTER GLOW HALO (breathing golden aura) ──
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.15 + pulse * 0.12;
|
||||
ctx.fillStyle = MC.GOLD;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, s * 1.55, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Tank turret
|
||||
ctx.fillRect(-3, -size / 2 - 14, 6, 14);
|
||||
// ── 2. GROUND SHADOW (soft ellipse underneath) ──
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, s + 6, s * 1.1, 3, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Tank tracks
|
||||
ctx.fillStyle = '#B8860B';
|
||||
ctx.fillRect(-size - 4, -size / 2, 4, size);
|
||||
ctx.fillRect(size, -size / 2, 4, size);
|
||||
// ── 3. TRACKS (left & right, with segment pattern) ──
|
||||
const trackW = 5;
|
||||
const trackX = s;
|
||||
// Left track
|
||||
ctx.fillStyle = '#4A3508';
|
||||
this._roundRect(ctx, -trackX - trackW, -s, trackW, s * 2, 1.5);
|
||||
ctx.fill();
|
||||
// Right track
|
||||
this._roundRect(ctx, trackX, -s, trackW, s * 2, 1.5);
|
||||
ctx.fill();
|
||||
// Track top highlight
|
||||
ctx.fillStyle = 'rgba(255, 220, 120, 0.35)';
|
||||
ctx.fillRect(-trackX - trackW + 0.8, -s + 0.8, trackW - 1.6, 1);
|
||||
ctx.fillRect(trackX + 0.8, -s + 0.8, trackW - 1.6, 1);
|
||||
// Track segment lines (metallic plate pattern)
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
ctx.lineWidth = 0.8;
|
||||
for (let ty = -s + 4; ty < s - 1; ty += 4) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-trackX - trackW, ty);
|
||||
ctx.lineTo(-trackX, ty);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(trackX, ty);
|
||||
ctx.lineTo(trackX + trackW, ty);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── 4. BODY (square, with vertical metallic gradient) ──
|
||||
ctx.save();
|
||||
ctx.shadowColor = MC.GOLD;
|
||||
ctx.shadowBlur = 12;
|
||||
const bodyGrad = ctx.createLinearGradient(0, -s, 0, s);
|
||||
bodyGrad.addColorStop(0, '#FFF3A8'); // top highlight
|
||||
bodyGrad.addColorStop(0.3, MC.GOLD); // main gold
|
||||
bodyGrad.addColorStop(0.75, '#C89A1C');
|
||||
bodyGrad.addColorStop(1, '#7A5A0A'); // bottom shadow
|
||||
ctx.fillStyle = bodyGrad;
|
||||
this._roundRect(ctx, -s, -s, s * 2, s * 2, 3);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// ── 5. BODY EDGE OUTLINE ──
|
||||
ctx.strokeStyle = 'rgba(255, 235, 150, 0.7)';
|
||||
ctx.lineWidth = 1;
|
||||
this._roundRect(ctx, -s, -s, s * 2, s * 2, 3);
|
||||
ctx.stroke();
|
||||
|
||||
// ── 6. TOP HIGHLIGHT STRIP (bright metallic sheen) ──
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
|
||||
this._roundRect(ctx, -s + 2, -s + 2, s * 2 - 4, 3, 1.5);
|
||||
ctx.fill();
|
||||
|
||||
// ── 7. ARMOR PLATE DETAIL (X-cross rivets in 4 corners + center crosshair) ──
|
||||
const rivetOffset = s * 0.6;
|
||||
ctx.fillStyle = '#6B4A08';
|
||||
for (const [rx, ry] of [[-rivetOffset, -rivetOffset], [rivetOffset, -rivetOffset],
|
||||
[-rivetOffset, rivetOffset], [rivetOffset, rivetOffset]]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(rx, ry, 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Tiny rivet highlight
|
||||
ctx.fillStyle = 'rgba(255, 240, 180, 0.8)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(rx - 0.3, ry - 0.3, 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#6B4A08';
|
||||
}
|
||||
|
||||
// ── 8. TURRET (diamond-shaped center cap with gradient) ──
|
||||
const turretR = s * 0.42;
|
||||
ctx.save();
|
||||
ctx.shadowColor = MC.GOLD;
|
||||
ctx.shadowBlur = 6;
|
||||
const turretGrad = ctx.createRadialGradient(-turretR * 0.3, -turretR * 0.3, 0, 0, 0, turretR);
|
||||
turretGrad.addColorStop(0, '#FFF3A8');
|
||||
turretGrad.addColorStop(0.5, '#E0B020');
|
||||
turretGrad.addColorStop(1, '#8B6914');
|
||||
ctx.fillStyle = turretGrad;
|
||||
// Diamond (rotated square) for "military hatch" feel
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -turretR);
|
||||
ctx.lineTo(turretR, 0);
|
||||
ctx.lineTo(0, turretR);
|
||||
ctx.lineTo(-turretR, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Turret edge
|
||||
ctx.strokeStyle = 'rgba(255, 235, 150, 0.8)';
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -turretR);
|
||||
ctx.lineTo(turretR, 0);
|
||||
ctx.lineTo(0, turretR);
|
||||
ctx.lineTo(-turretR, 0);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
|
||||
// Turret internal cross
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -turretR); ctx.lineTo(0, turretR);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-turretR, 0); ctx.lineTo(turretR, 0);
|
||||
ctx.stroke();
|
||||
|
||||
// Turret center hatch
|
||||
ctx.fillStyle = '#FFF3A8';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// ── 9. BARREL (thick, with metallic gradient) ──
|
||||
const barrelW = 6;
|
||||
const barrelH = 16;
|
||||
const barrelY = -s - barrelH;
|
||||
ctx.save();
|
||||
const barrelGrad = ctx.createLinearGradient(-barrelW / 2, 0, barrelW / 2, 0);
|
||||
barrelGrad.addColorStop(0, '#6B4A08');
|
||||
barrelGrad.addColorStop(0.3, '#B8860B');
|
||||
barrelGrad.addColorStop(0.5, '#FFF3A8');
|
||||
barrelGrad.addColorStop(0.7, '#B8860B');
|
||||
barrelGrad.addColorStop(1, '#6B4A08');
|
||||
ctx.fillStyle = barrelGrad;
|
||||
this._roundRect(ctx, -barrelW / 2, barrelY, barrelW, barrelH, 1.5);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
// Barrel outline
|
||||
ctx.strokeStyle = 'rgba(255, 235, 150, 0.55)';
|
||||
ctx.lineWidth = 0.6;
|
||||
this._roundRect(ctx, -barrelW / 2, barrelY, barrelW, barrelH, 1.5);
|
||||
ctx.stroke();
|
||||
|
||||
// ── 10. MUZZLE TIP (flared end with glow) ──
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#FFF3A8';
|
||||
ctx.shadowBlur = 5 + pulse * 3;
|
||||
ctx.fillStyle = '#2A1D05';
|
||||
this._roundRect(ctx, -barrelW / 2 - 1, barrelY - 2, barrelW + 2, 3, 1);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
// Muzzle inner bright dot
|
||||
ctx.fillStyle = `rgba(255, 240, 150, ${0.6 + pulse * 0.4})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, barrelY - 0.5, 1, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// ── 11. HEADLIGHTS (front of tank — 2 small glowing dots) ──
|
||||
ctx.fillStyle = `rgba(255, 255, 200, ${0.7 + pulse * 0.3})`;
|
||||
ctx.shadowColor = '#FFF3A8';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(-s * 0.55, -s + 2.5, 1.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(s * 0.55, -s + 2.5, 1.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw a rounded rectangle path.
|
||||
*/
|
||||
// ---- Utility ----
|
||||
_roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
@@ -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
@@ -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
@@ -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
+97
-10
@@ -240,6 +240,11 @@ const TeamGameScene = {
|
||||
tank.color = tankColor;
|
||||
// Unlimited lives for 3v3
|
||||
tank.lives = 999;
|
||||
// Apply equipped skin (only for the LOCAL player — other players keep team color)
|
||||
if (GameGlobal.skinManager && isLocal) {
|
||||
tank._skinColors = GameGlobal.skinManager.getCurrentSkinColors();
|
||||
tank._skinId = GameGlobal.skinManager.getEquippedSkinId();
|
||||
}
|
||||
}
|
||||
|
||||
tank.activateShield(3000);
|
||||
@@ -256,6 +261,7 @@ const TeamGameScene = {
|
||||
|
||||
const playerData = {
|
||||
playerId: member.playerId,
|
||||
nickname: member.nickname || '',
|
||||
tank,
|
||||
isBot,
|
||||
team,
|
||||
@@ -441,6 +447,29 @@ const TeamGameScene = {
|
||||
}
|
||||
}));
|
||||
|
||||
// Receive live team roster updates — keeps every tank's overhead label in
|
||||
// sync with the real WeChat nickname, which may be granted AFTER the match
|
||||
// has already started (via MenuScene's UserInfoButton).
|
||||
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
|
||||
if (!data) return;
|
||||
const rosterA = Array.isArray(data.teamA) ? data.teamA : [];
|
||||
const rosterB = Array.isArray(data.teamB) ? data.teamB : [];
|
||||
const byId = Object.create(null);
|
||||
for (const m of rosterA) if (m && m.playerId) byId[m.playerId] = m.nickname || '';
|
||||
for (const m of rosterB) if (m && m.playerId) byId[m.playerId] = m.nickname || '';
|
||||
let changed = false;
|
||||
for (const p of this._players) {
|
||||
const nn = byId[p.playerId];
|
||||
if (nn && p.nickname !== nn) {
|
||||
p.nickname = nn;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
console.log('[TeamGameScene] Roster nicknames refreshed.');
|
||||
}
|
||||
}));
|
||||
|
||||
this._unsubscribers = unsubs;
|
||||
},
|
||||
|
||||
@@ -1126,6 +1155,7 @@ const TeamGameScene = {
|
||||
stats: this._stats,
|
||||
players: this._players.map(p => ({
|
||||
playerId: p.playerId,
|
||||
nickname: p.nickname || '',
|
||||
team: p.team,
|
||||
isBot: p.isBot,
|
||||
isLocal: p.isLocal,
|
||||
@@ -1154,16 +1184,41 @@ const TeamGameScene = {
|
||||
if (player.tank.alive && !player.isRespawning) {
|
||||
player.tank.render(ctx);
|
||||
|
||||
// Draw team indicator above tank
|
||||
if (!player.isLocal) {
|
||||
const tx = player.tank.x;
|
||||
const ty = player.tank.y - player.tank.halfSize - 8;
|
||||
ctx.fillStyle = player.team === this._myTeam ? TEAM_A_COLOR : TEAM_B_COLOR;
|
||||
ctx.font = 'bold 8px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const label = player.isBot ? '🤖' : (player.team === this._myTeam ? '▲' : '▼');
|
||||
ctx.fillText(label, tx, ty);
|
||||
// Name & team indicator above the tank
|
||||
const tx = player.tank.x;
|
||||
const labelY = player.tank.y - player.tank.halfSize - 4;
|
||||
const nameY = labelY - 10;
|
||||
|
||||
// Per-tank team color:
|
||||
// - local player → gold
|
||||
// - ally (not me) → blue
|
||||
// - enemy → red
|
||||
let labelColor;
|
||||
if (player.isLocal) labelColor = LOCAL_PLAYER_COLOR;
|
||||
else if (player.team === this._myTeam) labelColor = TEAM_A_COLOR;
|
||||
else labelColor = TEAM_B_COLOR;
|
||||
|
||||
ctx.fillStyle = labelColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Arrow / bot tag
|
||||
ctx.font = 'bold 8px Arial';
|
||||
let marker;
|
||||
if (player.isLocal) marker = '★';
|
||||
else if (player.isBot) marker = '🤖';
|
||||
else marker = (player.team === this._myTeam) ? '▲' : '▼';
|
||||
ctx.fillText(marker, tx, labelY);
|
||||
|
||||
// Nickname (truncated to 4 Chinese-equivalent chars)
|
||||
const name = this._getTankLabel(player);
|
||||
if (name) {
|
||||
ctx.font = 'bold 9px Arial';
|
||||
// Outline for readability on busy backgrounds
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.strokeText(name, tx, nameY);
|
||||
ctx.fillText(name, tx, nameY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1220,6 +1275,38 @@ const TeamGameScene = {
|
||||
return { kills, deaths };
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute a short label (≤ 4 Chinese-equivalent chars) to draw above a tank.
|
||||
* Uses real WeChat nickname if available, otherwise a stable fallback.
|
||||
* @private
|
||||
*/
|
||||
_getTankLabel(player) {
|
||||
if (!player) return '';
|
||||
const profile = GameGlobal.playerProfile;
|
||||
let raw = '';
|
||||
if (player.isLocal) {
|
||||
// For local player prefer the freshest profile nickname if granted.
|
||||
if (profile && profile.nickname) raw = profile.nickname;
|
||||
else raw = player.nickname || '';
|
||||
} else {
|
||||
raw = player.nickname || '';
|
||||
}
|
||||
if (!raw) {
|
||||
if (player.isBot) {
|
||||
raw = ''; // bot — we already draw the 🤖 marker, skip name
|
||||
} else if (profile && typeof profile.getDisplayName === 'function') {
|
||||
raw = profile.getDisplayName(player.playerId);
|
||||
} else {
|
||||
raw = player.playerId || '';
|
||||
}
|
||||
}
|
||||
if (!raw) return '';
|
||||
if (profile && typeof profile.truncate === 'function') {
|
||||
return profile.truncate(raw, 4);
|
||||
}
|
||||
return raw.length > 8 ? raw.substring(0, 8) + '..' : raw;
|
||||
},
|
||||
|
||||
_renderHUD(ctx) {
|
||||
const hudY = 4;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
*.md
|
||||
@@ -0,0 +1,256 @@
|
||||
# Tank War Server - 完整部署指南
|
||||
|
||||
## 概述
|
||||
|
||||
本指南详细说明如何将Tank War Server部署到由3台CVM组成的Kubernetes集群中。
|
||||
|
||||
## 集群信息
|
||||
|
||||
目标K8s集群由以下3台CVM组成:
|
||||
- 43.139.80.61 (host_172.16.16.16)
|
||||
- 43.138.255.42 (host_172.16.16.17)
|
||||
- 159.75.104.221 (host_172.16.16.8)
|
||||
|
||||
SSH连接:`ssh root@host_172.16.16.16`
|
||||
|
||||
## 部署前准备
|
||||
|
||||
### 1. 环境检查
|
||||
|
||||
```bash
|
||||
# 在server目录下运行测试脚本
|
||||
cd /Users/hanchengxi/workspace/tankwar_proj/server
|
||||
./test-deployment.sh
|
||||
```
|
||||
|
||||
### 2. 配置Kubernetes访问
|
||||
|
||||
确保kubectl已配置连接到目标集群:
|
||||
|
||||
```bash
|
||||
# 检查集群连接
|
||||
kubectl cluster-info
|
||||
|
||||
# 查看当前上下文
|
||||
kubectl config current-context
|
||||
|
||||
# 如果未配置,需要获取集群的kubeconfig文件
|
||||
# 通常从集群管理员处获取或通过云平台控制台下载
|
||||
```
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 步骤1:构建Docker镜像
|
||||
|
||||
```bash
|
||||
# 在server目录下构建镜像
|
||||
docker build -t tankwar-server:latest .
|
||||
|
||||
# 验证镜像构建成功
|
||||
docker images | grep tankwar-server
|
||||
```
|
||||
|
||||
### 步骤2:部署到Kubernetes
|
||||
|
||||
```bash
|
||||
# 方法1:使用部署脚本(推荐)
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh
|
||||
|
||||
# 方法2:手动部署
|
||||
kubectl create namespace tankwar --dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl apply -f k8s-deployment.yaml -n tankwar
|
||||
```
|
||||
|
||||
### 步骤3:验证部署
|
||||
|
||||
```bash
|
||||
# 运行验证脚本
|
||||
chmod +x verify-deployment.sh
|
||||
./verify-deployment.sh
|
||||
|
||||
# 或手动验证
|
||||
kubectl get all -n tankwar
|
||||
kubectl logs -l app=tankwar-server -n tankwar
|
||||
```
|
||||
|
||||
## 配置文件说明
|
||||
|
||||
### Dockerfile
|
||||
- 基于Node.js 18 Alpine镜像
|
||||
- 暴露端口3000
|
||||
- 生产环境配置
|
||||
|
||||
### k8s-deployment.yaml
|
||||
包含以下Kubernetes资源:
|
||||
|
||||
1. **ConfigMap**: 环境变量配置
|
||||
2. **Deployment**:
|
||||
- 3个副本
|
||||
- 资源限制:内存512Mi,CPU 500m
|
||||
- 健康检查探针
|
||||
3. **Service**:
|
||||
- LoadBalancer类型
|
||||
- 端口3000
|
||||
|
||||
## 网络配置
|
||||
|
||||
### 服务暴露
|
||||
|
||||
服务使用LoadBalancer类型,将通过云平台的负载均衡器暴露:
|
||||
|
||||
```bash
|
||||
# 获取外部IP
|
||||
kubectl get svc tankwar-server-service -n tankwar
|
||||
|
||||
# WebSocket连接地址
|
||||
ws://<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`
|
||||
@@ -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"]
|
||||
@@ -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控制器
|
||||
- 配置适当的网络策略
|
||||
- 定期更新镜像以修复安全漏洞
|
||||
- 监控资源使用情况防止资源耗尽
|
||||
Executable
+46
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
Executable
+97
@@ -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
|
||||
Executable
+75
@@ -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 ""
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user