فيما يلي تسليم عملي باسم «Mace Duel Mobile» يعمل في متصفح الهاتف والكمبيوتر، ويستخدم: TypeScript + Vite + Three.js في العميل، وNode.js + TypeScript + ws في الخادم. النسخة تدعم لاعبين من جهازين مختلفين عبر الإنترنت، غرفًا خاصة، تحكمًا لمسيًا، محاكاة خادمية مرجعية بسرعة 30 Hz، شحنات الرياح، ضربات السقوط، الجولات، إعاد...
نشر بواسطةتم إنشاء الصور باستخدام GPT Image 2
إجابة البحث

Create a landscape editorial hero image for this Studio Global article: Selected skill instructions for this user request: 1. اريد منك دائما التحدث عبر كود يعني ان تتجدث عبر صندوق الكود وليس عبر الدردش Apply thes. Article summary: فيما يلي تسليم عملي باسم «Mace Duel Mobile» يعمل في متصفح الهاتف والكمبيوتر، ويستخدم: TypeScript + Vite + Three.js في العميل، وNode.js + TypeScript + ws في الخادم.. Topic tags: deepresearch, general web, workflow, code, typescript. Style: premium digital editorial illustration, source-backed research mood, clean composition, high detail, modern web publication hero. Use reference image context only for broad subject, composition, and topical grounding; do not copy the exact image. Avoid: logos, brand marks, copyrighted characters, real person likenesses, fake screenshots, UI text, readable text, watermarks, charts with fake numbers, clickbait thumbnails, icons,
نعم. فيما يلي تسليم عملي باسم «Mace Duel Mobile» يعمل في متصفح الهاتف والكمبيوتر، ويستخدم:
TypeScript + Vite + Three.js في العميل، وNode.js + TypeScript + ws في الخادم.
النسخة تدعم لاعبين من جهازين مختلفين عبر الإنترنت، غرفًا خاصة، تحكمًا لمسيًا، محاكاة خادمية مرجعية بسرعة 30 Hz، شحنات الرياح، ضربات السقوط، الجولات، إعادة المباراة، والتحقق الأساسي من الرسائل.
اخترت Node.js 24 LTS بدل فرع Node.js 26 Current للإنتاج؛ Node.js 24 موثق حاليًا كفرع LTS. [web:33][web:43]
ويجب استخدام HTTPS مع WSS عند النشر العام. [web:24]
في Nginx يجب تمرير Upgrade وConnection صراحةً، كما ينبغي ضبط مهلة مناسبة أو إرسال heartbeat دوري. [web:16]
يعتمد المشروع على WebSocket ثنائي الاتجاه، وws يرد تلقائيًا على Ping بإطار Pong. [web:23][web:57]
أما التحكم الهاتفي فيستخدم Pointer Events متعددة اللمس، وهو مناسب لعصا الحركة والنظر وزري الهجوم المتزامنين. [web:1][web:15]
واستخدام InstancedMesh للأرضية يقلل استدعاءات الرسم، بينما يمكن تعطيل shadowMap من الإعدادات. [web:2][web:4]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. المعمارية
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
client/
العرض ثلاثي الأبعاد، UI عربية RTL، الماوس واللمس، الصوت، التنبؤ المحلي،
المصالحة، واستيفاء الخصم.
server/
غرف معزولة، محاكاة ثابتة 30 Hz، حركة وقتال ومقذوفات وصحة ونتائج
مرجعية، heartbeat، rate limiting، والتحقق من الرسائل.
shared/
الثوابت، أنواع البروتوكول، ودوال التحقق وحساب ضرر الميس.
لا يرسل العميل صحة أو ضررًا أو موضعًا موثوقًا؛ يرسل فقط الإدخال واتجاه النظر
وأوامر الهجوم. الخادم هو الذي يحسب الحركة والإصابات والنتيجة.
دعم الهاتف:
• عصا يسارية للحركة.
• سحب الجهة اليمنى للنظر.
• زر قفز.
• زر ركض.
• زر ميس.
• زر شحنة رياح.
• الوضع الأفقي Landscape موصى به.
• دعم لوحة المفاتيح والماوس محفوظ أيضًا.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2. شجرة المشروع
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mace-duel-mobile/
├── .env.example
├── .gitignore
├── Dockerfile
├── README.md
├── index.html
├── nginx.example.conf
├── package.json
├── playwright.config.ts
├── render.yaml
├── tsconfig.json
├── tsconfig.server.json
├── vite.config.ts
├── shared/
│ ├── protocol.ts
│ └── rules.ts
├── server/
│ ├── game.ts
│ └── index.ts
├── src/
│ ├── main.ts
│ └── style.css
└── tests/
├── game.test.ts
├── integration.test.ts
└── ui.spec.ts
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3. إنشاء جميع الملفات
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
احفظ الملف التالي باسم bootstrap.mjs داخل مجلد فارغ، ثم نفّذ:
node bootstrap.mjs
npm install
npm run test
npm run dev
سينشئ npm install ملف package-lock.json الفعلي. يجب الاحتفاظ به داخل Git
واستخدام npm ci في البناء والنشر؛ لا أنصح بكتابة lockfile يدويًا لأنه يتضمن
سلامة integrity وشجرة الاعتماديات الكاملة الخاصة بإصدارات npm.
──────────────────────────────────────────────────────────────────────
bootstrap.mjs
──────────────────────────────────────────────────────────────────────
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const files = {
"package.json": String.raw`
{
"name": "mace-duel-mobile",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=24 <25" },
"scripts": {
"dev": "concurrently -k \"vite\" \"tsx watch server/index.ts\"",
"build": "vite build && tsc -p tsconfig.server.json",
"start": "node build/server/index.js",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "playwright test",
"check": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit"
},
"dependencies": {
"three": "0.179.1",
"ws": "8.18.3"
},
"devDependencies": {
"@playwright/test": "1.55.0",
"@types/node": "24.3.0",
"@types/three": "0.179.0",
"@types/ws": "8.18.1",
"concurrently": "9.2.1",
"tsx": "4.20.5",
"typescript": "5.9.2",
"vite": "7.1.3",
"vitest": "3.2.4"
}
}
`,
"tsconfig.json": String.raw`
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src", "shared", "vite.config.ts"]
}
`,
"tsconfig.server.json": String.raw`
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "build",
"types": ["node"],
"sourceMap": true
},
"include": ["server", "shared"]
}
`,
"vite.config.ts": String.raw`
import { defineConfig } from "vite";
export default defineConfig({
server: {
host: true,
port: 5173,
proxy: {
"/ws": {
target: process.env.VITE_DEV_SERVER ?? "ws://localhost:8080",
ws: true
}
}
},
build: {
target: "es2022",
sourcemap: true
}
});
`,
".env.example": String.raw`
PORT=8080
HOST=0.0.0.0
# اتركه فارغًا لاستخدام نفس المضيف:
VITE_WS_URL=
`,
".gitignore": String.raw`
node_modules
dist
build
playwright-report
test-results
.env
.DS_Store
`,
"shared/rules.ts": String.raw`
export const RULES = {
tickRate: 30,
arenaHalf: 48,
playerRadius: 0.45,
playerHeight: 1.8,
walkSpeed: 6,
sprintSpeed: 8.4,
groundAcceleration: 40,
airAcceleration: 13,
friction: 12,
gravity: 22,
jumpSpeed: 8.2,
maxHealth: 100,
attackRange: 3.35,
attackConeCos: Math.cos(55 * Math.PI / 180),
attackCooldown: 0.65,
normalDamage: 16,
smashMinFall: 1.7,
smashDamagePerUnit: 7,
smashDamageCap: 52,
knockbackBase: 6,
knockbackCap: 14,
maxCharges: 3,
chargeRegenSeconds: 8,
windCooldown: 0.7,
projectileSpeed: 22,
projectileLife: 1.7,
explosionRadius: 4.5,
explosionLift: 11,
explosionPush: 8,
spawnProtection: 2,
countdownSeconds: 3,
roundSeconds: 120,
winsRequired: 3,
rewindMaxMs: 150
} as const;
export function maceDamage(fallDistance: number, descending: boolean) {
if (!descending || fallDistance < RULES.smashMinFall) {
return { damage: RULES.normalDamage, smash: false };
}
return {
damage: Math.min(
RULES.smashDamageCap,
RULES.normalDamage + fallDistance * RULES.smashDamagePerUnit
),
smash: true
};
}
export function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
`,
"shared/protocol.ts": String.raw`
export type Phase =
| "lobby"
| "countdown"
| "playing"
| "roundEnd"
| "matchEnd";
export type PlayerView = {
id: string;
name: string;
x: number;
y: number;
z: number;
vx: number;
vy: number;
vz: number;
yaw: number;
pitch: number;
hp: number;
charges: number;
ready: boolean;
protected: boolean;
score: number;
lastProcessedSeq: number;
};
export type ProjectileView = {
id: number;
owner: string;
x: number;
y: number;
z: number;
};
export type Snapshot = {
type: "snapshot";
serverTime: number;
room: string;
phase: Phase;
phaseEndsAt: number;
players: PlayerView[];
projectiles: ProjectileView[];
winner?: string;
event?: {
kind: "hit" | "smash" | "wind" | "round" | "leave";
source?: string;
target?: string;
};
};
export type ClientMessage =
| { type: "create"; name: string }
| { type: "join"; name: string; room: string }
| { type: "ready"; value: boolean }
| { type: "rematch"; value: boolean }
| {
type: "input";
seq: number;
moveX: number;
moveZ: number;
yaw: number;
pitch: number;
jump: boolean;
sprint: boolean;
}
| { type: "attack"; seq: number }
| { type: "wind"; seq: number }
| { type: "pong"; nonce: number };
export type ServerMessage =
| { type: "welcome"; id: string; room: string }
| { type: "error"; code: string; message: string }
| { type: "ping"; nonce: number }
| Snapshot;
const finite = (v: unknown) => typeof v === "number" && Number.isFinite(v);
const text = (v: unknown, max: number) =>
typeof v === "string" && v.trim().length > 0 && v.length <= max;
export function parseClientMessage(raw: string): ClientMessage | null {
if (raw.length > 2048) return null;
let m: Record<string, unknown>;
try {
m = JSON.parse(raw);
} catch {
return null;
}
if (m.type === "create" && text(m.name, 20))
return { type: "create", name: String(m.name).trim() };
if (
m.type === "join" &&
text(m.name, 20) &&
typeof m.room === "string" &&
/^[A-Z0-9]{6}$/.test(m.room)
) {
return {
type: "join",
name: String(m.name).trim(),
room: m.room
};
}
if (
(m.type === "ready" || m.type === "rematch") &&
typeof m.value === "boolean"
) {
return { type: m.type, value: m.value };
}
if (
m.type === "input" &&
Number.isInteger(m.seq) &&
finite(m.moveX) &&
finite(m.moveZ) &&
finite(m.yaw) &&
finite(m.pitch) &&
typeof m.jump === "boolean" &&
typeof m.sprint === "boolean"
) {
return {
type: "input",
seq: Number(m.seq),
moveX: Math.max(-1, Math.min(1, Number(m.moveX))),
moveZ: Math.max(-1, Math.min(1, Number(m.moveZ))),
yaw: Number(m.yaw),
pitch: Math.max(-1.45, Math.min(1.45, Number(m.pitch))),
jump: m.jump,
sprint: m.sprint
};
}
if (
(m.type === "attack" || m.type === "wind") &&
Number.isInteger(m.seq)
) {
return { type: m.type, seq: Number(m.seq) };
}
if (m.type === "pong" && finite(m.nonce))
return { type: "pong", nonce: Number(m.nonce) };
return null;
}
`,
"server/game.ts": String.raw`
import { randomBytes, randomUUID } from "node:crypto";
import type { ClientMessage, PlayerView, Snapshot } from "../shared/protocol.js";
import { RULES, clamp, maceDamage } from "../shared/rules.js";
export interface Peer {
id: string;
send(message: unknown): void;
close(code?: number, reason?: string): void;
rtt: number;
}
type Input = {
seq: number;
moveX: number;
moveZ: number;
yaw: number;
pitch: number;
jump: boolean;
sprint: boolean;
};
type History = { time: number; x: number; y: number; z: number };
type Player = {
peer: Peer;
name: string;
x: number;
y: number;
z: number;
vx: number;
vy: number;
vz: number;
hp: number;
charges: number;
chargeClock: number;
score: number;
ready: boolean;
rematch: boolean;
grounded: boolean;
fallStartY: number;
protectedUntil: number;
attackReadyAt: number;
windReadyAt: number;
input: Input;
history: History[];
};
type Projectile = {
id: number;
owner: string;
x: number;
y: number;
z: number;
vx: number;
vy: number;
vz: number;
age: number;
};
export class Room {
readonly code: string;
players = new Map<string, Player>();
projectiles: Projectile[] = [];
phase: Snapshot["phase"] = "lobby";
phaseEndsAt = 0;
winner?: string;
lastEvent?: Snapshot["event"];
private projectileId = 1;
private roundResolved = false;
constructor(code: string) {
this.code = code;
}
add(peer: Peer, name: string) {
if (this.players.size >= 2) throw new Error("ROOM_FULL");
const index = this.players.size;
const spawnX = index === 0 ? -15 : 15;
this.players.set(peer.id, {
peer,
name,
x: spawnX,
y: 0,
z: 0,
vx: 0,
vy: 0,
vz: 0,
hp: RULES.maxHealth,
charges: RULES.maxCharges,
chargeClock: 0,
score: 0,
ready: false,
rematch: false,
grounded: true,
fallStartY: 0,
protectedUntil: 0,
attackReadyAt: 0,
windReadyAt: 0,
input: {
seq: 0,
moveX: 0,
moveZ: 0,
yaw: index === 0 ? -Math.PI / 2 : Math.PI / 2,
pitch: 0,
jump: false,
sprint: false
},
history: []
});
}
remove(id: string) {
this.players.delete(id);
this.lastEvent = { kind: "leave", source: id };
if (this.phase !== "lobby" && this.players.size === 1) {
this.phase = "matchEnd";
this.winner = this.players.keys().next().value;
this.phaseEndsAt = 0;
}
}
handle(id: string, m: ClientMessage, now: number) {
const p = this.players.get(id);
if (!p) return;
if (m.type === "ready" && this.phase === "lobby") {
p.ready = m.value;
this.tryCountdown(now);
return;
}
if (m.type === "rematch" && this.phase === "matchEnd") {
p.rematch = m.value;
if (
this.players.size === 2 &&
[...this.players.values()].every((v) => v.rematch)
) {
for (const v of this.players.values()) {
v.score = 0;
v.ready = false;
v.rematch = false;
}
this.phase = "lobby";
this.winner = undefined;
this.resetRound(now);
}
return;
}
if (m.type === "input") {
if (m.seq <= p.input.seq || m.seq > p.input.seq + 5000) return;
p.input = { ...m };
return;
}
if (this.phase !== "playing") return;
if (m.type === "attack") this.attack(p, now);
if (m.type === "wind") this.wind(p, now);
}
private tryCountdown(now: number) {
if (
this.players.size === 2 &&
[...this.players.values()].every((p) => p.ready)
) {
this.resetRound(now);
this.phase = "countdown";
this.phaseEndsAt = now + RULES.countdownSeconds * 1000;
}
}
private resetRound(now: number) {
this.projectiles = [];
this.roundResolved = false;
let i = 0;
for (const p of this.players.values()) {
p.x = i === 0 ? -15 : 15;
p.y = 0;
p.z = 0;
p.vx = p.vy = p.vz = 0;
p.hp = RULES.maxHealth;
p.charges = RULES.maxCharges;
p.chargeClock = 0;
p.grounded = true;
p.fallStartY = 0;
p.attackReadyAt = now;
p.windReadyAt = now;
p.protectedUntil = now + RULES.spawnProtection * 1000;
p.history = [];
i++;
}
}
private attack(attacker: Player, now: number) {
if (now < attacker.attackReadyAt) return;
attacker.attackReadyAt = now + RULES.attackCooldown * 1000;
attacker.protectedUntil = 0;
const target = [...this.players.values()].find((p) => p !== attacker);
if (!target || target.hp <= 0) return;
const rewind = Math.min(RULES.rewindMaxMs, attacker.peer.rtt / 2);
const old =
[...target.history]
.reverse()
.find((h) => h.time <= now - rewind) ??
target.history ?? {
time: now,
x: target.x,
y: target.y,
z: target.z
};
const eyeX = attacker.x;
const eyeY = attacker.y + 1.55;
const eyeZ = attacker.z;
const dx = old.x - eyeX;
const dy = old.y + 0.9 - eyeY;
const dz = old.z - eyeZ;
const dist = Math.hypot(dx, dy, dz);
if (dist > RULES.attackRange || dist < 0.01) return;
const lookX = -Math.sin(attacker.input.yaw) * Math.cos(attacker.input.pitch);
const lookY = Math.sin(attacker.input.pitch);
const lookZ = -Math.cos(attacker.input.yaw) * Math.cos(attacker.input.pitch);
const dot = (dx * lookX + dy * lookY + dz * lookZ) / dist;
if (dot < RULES.attackConeCos) return;
if (now < target.protectedUntil) return;
const falling = !attacker.grounded && attacker.vy < -0.25;
const fallDistance = Math.max(0, attacker.fallStartY - attacker.y);
const result = maceDamage(fallDistance, falling);
target.hp = Math.max(0, target.hp - result.damage);
const horizontal = Math.max(0.01, Math.hypot(dx, dz));
const force = Math.min(
RULES.knockbackCap,
RULES.knockbackBase + (result.smash ? fallDistance * 0.9 : 0)
);
target.vx += (dx / horizontal) * force;
target.vz += (dz / horizontal) * force;
target.vy += result.smash ? 5 : 2;
if (result.smash) {
attacker.fallStartY = attacker.y;
attacker.vy = Math.max(attacker.vy, -1);
}
this.lastEvent = {
kind: result.smash ? "smash" : "hit",
source: attacker.peer.id,
target: target.peer.id
};
}
private wind(p: Player, now: number) {
if (now < p.windReadyAt || p.charges < 1) return;
p.windReadyAt = now + RULES.windCooldown * 1000;
p.charges--;
p.protectedUntil = 0;
const cp = Math.cos(p.input.pitch);
const dx = -Math.sin(p.input.yaw) * cp;
const dy = Math.sin(p.input.pitch);
const dz = -Math.cos(p.input.yaw) * cp;
this.projectiles.push({
id: this.projectileId++,
owner: p.peer.id,
x: p.x + dx * 0.8,
y: p.y + 1.25 + dy * 0.5,
z: p.z + dz * 0.8,
vx: dx * RULES.projectileSpeed,
vy: dy * RULES.projectileSpeed,
vz: dz * RULES.projectileSpeed,
age: 0
});
}
private explode(q: Projectile) {
for (const p of this.players.values()) {
const dx = p.x - q.x;
const dy = p.y + 0.4 - q.y;
const dz = p.z - q.z;
const d = Math.hypot(dx, dy, dz);
if (d > RULES.explosionRadius) continue;
const strength = 1 - d / RULES.explosionRadius;
const horizontal = Math.max(0.1, Math.hypot(dx, dz));
p.vx += (dx / horizontal) * RULES.explosionPush * strength;
p.vz += (dz / horizontal) * RULES.explosionPush * strength;
p.vy += RULES.explosionLift * (0.45 + strength);
p.grounded = false;
p.fallStartY = Math.max(p.fallStartY, p.y);
}
this.lastEvent = { kind: "wind", source: q.owner };
}
update(dt: number, now: number) {
if (this.phase === "countdown" && now >= this.phaseEndsAt) {
this.phase = "playing";
this.phaseEndsAt = now + RULES.roundSeconds * 1000;
}
if (this.phase === "roundEnd" && now >= this.phaseEndsAt) {
if ([...this.players.values()].some((p) => p.score >= RULES.winsRequired)) {
this.phase = "matchEnd";
this.winner = [...this.players.values()].find(
(p) => p.score >= RULES.winsRequired
)?.peer.id;
} else {
this.resetRound(now);
this.phase = "countdown";
this.phaseEndsAt = now + RULES.countdownSeconds * 1000;
}
}
if (this.phase !== "playing") return;
for (const p of this.players.values()) this.updatePlayer(p, dt, now);
this.updateProjectiles(dt);
if (now >= this.phaseEndsAt && !this.roundResolved) {
const ordered = [...this.players.values()].sort((a, b) => b.hp - a.hp);
this.endRound(ordered.hp === ordered[1].hp ? undefined : ordered, now);
}
const dead = [...this.players.values()].filter((p) => p.hp <= 0);
if (dead.length && !this.roundResolved) {
const alive = [...this.players.values()].filter((p) => p.hp > 0);
this.endRound(alive.length === 1 ? alive : undefined, now);
}
}
private updatePlayer(p: Player, dt: number, now: number) {
const inputLength = Math.hypot(p.input.moveX, p.input.moveZ);
const mx = inputLength > 1 ? p.input.moveX / inputLength : p.input.moveX;
const mz = inputLength > 1 ? p.input.moveZ / inputLength : p.input.moveZ;
const sin = Math.sin(p.input.yaw);
const cos = Math.cos(p.input.yaw);
const worldX = mx * cos + mz * sin;
const worldZ = -mx * sin + mz * cos;
const speed = p.input.sprint ? RULES.sprintSpeed : RULES.walkSpeed;
const accel = p.grounded ? RULES.groundAcceleration : RULES.airAcceleration;
p.vx += clamp(worldX * speed - p.vx, -accel * dt, accel * dt);
p.vz += clamp(worldZ * speed - p.vz, -accel * dt, accel * dt);
if (!inputLength && p.grounded) {
const k = Math.max(0, 1 - RULES.friction * dt);
p.vx *= k;
p.vz *= k;
}
if (p.input.jump && p.grounded) {
p.vy = RULES.jumpSpeed;
p.grounded = false;
p.fallStartY = p.y;
}
p.vy -= RULES.gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
const limit = RULES.arenaHalf - RULES.playerRadius;
p.x = clamp(p.x, -limit, limit);
p.z = clamp(p.z, -limit, limit);
if (p.y <= 0) {
p.y = 0;
p.vy = 0;
p.grounded = true;
p.fallStartY = 0;
} else {
if (p.vy > 0) p.fallStartY = Math.max(p.fallStartY, p.y);
p.grounded = false;
}
p.chargeClock += dt;
if (
p.charges < RULES.maxCharges &&
p.chargeClock >= RULES.chargeRegenSeconds
) {
p.charges++;
p.chargeClock -= RULES.chargeRegenSeconds;
}
p.history.push({ time: now, x: p.x, y: p.y, z: p.z });
while (p.history.length && p.history.time < now - 500)
p.history.shift();
}
private updateProjectiles(dt: number) {
const remaining: Projectile[] = [];
for (const q of this.projectiles) {
q.age += dt;
q.vy -= RULES.gravity * 0.3 * dt;
q.x += q.vx * dt;
q.y += q.vy * dt;
q.z += q.vz * dt;
const hitPlayer = [...this.players.values()].some(
(p) =>
p.peer.id !== q.owner &&
Math.hypot(p.x - q.x, p.y + 0.9 - q.y, p.z - q.z) < 0.8
);
if (
q.y <= 0.15 ||
Math.abs(q.x) >= RULES.arenaHalf ||
Math.abs(q.z) >= RULES.arenaHalf ||
q.age >= RULES.projectileLife ||
hitPlayer
) {
this.explode(q);
} else {
remaining.push(q);
}
}
this.projectiles = remaining;
}
private endRound(winner: Player | undefined, now: number) {
this.roundResolved = true;
if (winner) winner.score++;
this.phase = "roundEnd";
this.phaseEndsAt = now + 2500;
this.lastEvent = { kind: "round", source: winner?.peer.id };
}
snapshot(now: number): Snapshot {
const players: PlayerView[] = [...this.players.values()].map((p) => ({
id: p.peer.id,
name: p.name,
x: p.x,
y: p.y,
z: p.z,
vx: p.vx,
vy: p.vy,
vz: p.vz,
yaw: p.input.yaw,
pitch: p.input.pitch,
hp: p.hp,
charges: p.charges,
ready: p.ready,
protected: now < p.protectedUntil,
score: p.score,
lastProcessedSeq: p.input.seq
}));
const snapshot: Snapshot = {
type: "snapshot",
serverTime: now,
room: this.code,
phase: this.phase,
phaseEndsAt: this.phaseEndsAt,
players,
projectiles: this.projectiles.map((q) => ({
id: q.id,
owner: q.owner,
x: q.x,
y: q.y,
z: q.z
})),
winner: this.winner,
event: this.lastEvent
};
this.lastEvent = undefined;
return snapshot;
}
}
export class RoomManager {
rooms = new Map<string, Room>();
create() {
let code = "";
do {
code = randomBytes(4)
.toString("base64url")
.replace(/[^A-Z0-9]/gi, "")
.toUpperCase()
.slice(0, 6)
.padEnd(6, "X");
} while (this.rooms.has(code));
const room = new Room(code);
this.rooms.set(code, room);
return room;
}
removeEmpty() {
for (const [code, room] of this.rooms)
if (room.players.size === 0) this.rooms.delete(code);
}
newPeerId() {
return randomUUID();
}
}
`,
"server/index.ts": String.raw`
import { createServer } from "node:http";
import { existsSync, createReadStream, statSync } from "node:fs";
import { extname, join, normalize } from "node:path";
import { WebSocketServer, WebSocket } from "ws";
import { parseClientMessage } from "../shared/protocol.js";
import { RULES } from "../shared/rules.js";
import { RoomManager, type Peer } from "./game.js";
const PORT = Number(process.env.PORT ?? 8080);
const HOST = process.env.HOST ?? "0.0.0.0";
const publicRoot = join(process.cwd(), "dist");
const manager = new RoomManager();
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml"
};
const http = createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" })
Studio Global AI
تتضمن هذه الصفحة إجابة مدعومة بالمصدر يمكنك المتابعة داخل Studio Global.
فيما يلي تسليم عملي باسم «Mace Duel Mobile» يعمل في متصفح الهاتف والكمبيوتر، ويستخدم: TypeScript + Vite + Three.js في العميل، وNode.js + TypeScript + ws في الخادم.
فيما يلي تسليم عملي باسم «Mace Duel Mobile» يعمل في متصفح الهاتف والكمبيوتر، ويستخدم: TypeScript + Vite + Three.js في العميل، وNode.js + TypeScript + ws في الخادم. النسخة تدعم لاعبين من جهازين مختلفين عبر الإنترنت، غرفًا خاصة، تحكمًا لمسيًا، محاكاة خادمية مرجعية بسرعة 30 Hz، شحنات الرياح، ضربات السقوط، الجولات، إعادة المباراة، والتحقق الأساسي من الرسائل.
اخترت Node.js 24 LTS بدل فرع Node.js 26 Current للإنتاج؛ Node.js 24 موثق حاليًا كفرع LTS.