diff --git a/packages/client/src/game/dashboard/AgentSkillsPanel.tsx b/packages/client/src/game/dashboard/AgentSkillsPanel.tsx index 83de0b54c..bd9f3b809 100644 --- a/packages/client/src/game/dashboard/AgentSkillsPanel.tsx +++ b/packages/client/src/game/dashboard/AgentSkillsPanel.tsx @@ -2,6 +2,13 @@ import { GAME_API_URL } from "@/lib/api-config"; import React, { useState, useEffect, useRef } from "react"; import type { Agent } from "./types"; import { ChevronDown, ChevronUp, Swords, TrendingUp } from "lucide-react"; +import { + calculateCombatLevel, + normalizeCombatSkills, + getXPForLevel, + getXPProgress, + type Skills, +} from "@hyperscape/shared"; // Configuration constants const SKILLS_POLL_INTERVAL_MS = 10000; // Poll every 10 seconds to avoid rate limiting @@ -47,23 +54,7 @@ async function fetchWithRetry( throw lastError || new Error("Request failed after retries"); } -interface SkillData { - level: number; - xp: number; -} - -interface AgentSkills { - attack?: SkillData; - strength?: SkillData; - defense?: SkillData; - constitution?: SkillData; - // ranged?: SkillData; // Hidden for melee-only MVP - woodcutting?: SkillData; - fishing?: SkillData; - firemaking?: SkillData; - cooking?: SkillData; - agility?: SkillData; -} +type AgentSkills = Partial; interface AgentSkillsPanelProps { agent: Agent; @@ -84,36 +75,6 @@ const SKILL_CONFIG = [ { key: "agility", label: "Agility", icon: "πŸƒ" }, ] as const; -// XP calculation for progress bar -function calculateXPForLevel(level: number): number { - let total = 0; - for (let i = 1; i < level; i++) { - total += Math.floor(i + 300 * Math.pow(2, i / 7)); - } - return Math.floor(total / 4); -} - -function getXPProgress(xp: number, level: number): number { - if (level >= MAX_SKILL_LEVEL) return 100; - const currentLevelXP = calculateXPForLevel(level); - const nextLevelXP = calculateXPForLevel(level + 1); - const xpIntoLevel = xp - currentLevelXP; - const xpForThisLevel = nextLevelXP - currentLevelXP; - return Math.min(100, Math.max(0, (xpIntoLevel / xpForThisLevel) * 100)); -} - -// Calculate combat level from skills (melee-only MVP) -function calculateCombatLevel(skills: AgentSkills): number { - const defense = skills.defense?.level || 1; - const constitution = skills.constitution?.level || 10; - const attack = skills.attack?.level || 1; - const strength = skills.strength?.level || 1; - - const base = 0.25 * (defense + constitution); - const melee = 0.325 * (attack + strength); - return Math.floor(base + melee); -} - function SkillRow({ icon, label, @@ -351,7 +312,19 @@ export const AgentSkillsPanel: React.FC = ({ }, 0) : 0; - const combatLevel = skills ? calculateCombatLevel(skills) : 3; + const combatLevel = skills + ? calculateCombatLevel( + normalizeCombatSkills({ + attack: skills.attack?.level, + strength: skills.strength?.level, + defense: skills.defense?.level, + constitution: skills.constitution?.level, + ranged: skills.ranged?.level, + magic: skills.magic?.level, + prayer: skills.prayer?.level, + }), + ) + : 3; return (
diff --git a/packages/client/src/game/dashboard/AgentSummaryCard.tsx b/packages/client/src/game/dashboard/AgentSummaryCard.tsx index 237af881d..25e192547 100644 --- a/packages/client/src/game/dashboard/AgentSummaryCard.tsx +++ b/packages/client/src/game/dashboard/AgentSummaryCard.tsx @@ -2,6 +2,10 @@ import { GAME_API_URL } from "@/lib/api-config"; import React, { useState, useEffect, useRef, useCallback } from "react"; import type { Agent } from "./types"; import { Swords, Activity, Target, Coins, Clock, Heart } from "lucide-react"; +import { + calculateCombatLevel, + normalizeCombatSkills, +} from "@hyperscape/shared"; interface HealthData { current: number; @@ -67,20 +71,6 @@ function formatXp(xp: number): string { return xp.toLocaleString(); } -// Calculate combat level from skills -function calculateCombatLevel( - skills: Record | null, -): number { - if (!skills) return 3; - const defense = skills.defense?.level || 1; - const constitution = skills.constitution?.level || 10; - const attack = skills.attack?.level || 1; - const strength = skills.strength?.level || 1; - const base = 0.25 * (defense + constitution); - const melee = 0.325 * (attack + strength); - return Math.floor(base + melee); -} - // Calculate total level function calculateTotalLevel( skills: Record | null, @@ -199,7 +189,19 @@ export const AgentSummaryCard: React.FC = ({ ...prev, online: posData.online !== false, uptimeMs: Date.now() - sessionStartTime, - combatLevel: calculateCombatLevel(skills), + combatLevel: skills + ? calculateCombatLevel( + normalizeCombatSkills({ + attack: skills.attack?.level, + strength: skills.strength?.level, + defense: skills.defense?.level, + constitution: skills.constitution?.level, + ranged: skills.ranged?.level, + magic: skills.magic?.level, + prayer: skills.prayer?.level, + }), + ) + : 3, totalLevel: calculateTotalLevel(skills), currentGoal: goalData?.goal?.description || null, goalProgress: goalData?.goal?.progressPercent || 0, @@ -215,7 +217,19 @@ export const AgentSummaryCard: React.FC = ({ ...prev, online: agent.status === "active", uptimeMs: Date.now() - sessionStartTime, - combatLevel: calculateCombatLevel(skills), + combatLevel: skills + ? calculateCombatLevel( + normalizeCombatSkills({ + attack: skills.attack?.level, + strength: skills.strength?.level, + defense: skills.defense?.level, + constitution: skills.constitution?.level, + ranged: skills.ranged?.level, + magic: skills.magic?.level, + prayer: skills.prayer?.level, + }), + ) + : 3, totalLevel: calculateTotalLevel(skills), currentGoal: goalData?.goal?.description || null, goalProgress: goalData?.goal?.progressPercent || 0, diff --git a/packages/client/src/game/hud/EntityContextMenu.tsx b/packages/client/src/game/hud/EntityContextMenu.tsx index 62915afed..9c50831ca 100644 --- a/packages/client/src/game/hud/EntityContextMenu.tsx +++ b/packages/client/src/game/hud/EntityContextMenu.tsx @@ -1,5 +1,9 @@ import React, { useState, useEffect, useRef } from "react"; -import type { World, LabelSegment } from "@hyperscape/shared"; +import type { + World, + LabelSegment, + ContextMenuAction as SharedContextMenuAction, +} from "@hyperscape/shared"; import { useThemeStore } from "@/ui"; import { UI } from "@/ui/core"; import { @@ -7,15 +11,13 @@ import { getContextMenuSurfaceStyle, } from "@/ui/theme/themes"; -export interface ContextMenuAction { - id: string; - label: string; - /** Rich text label with colors/styles - takes precedence over label if present */ - styledLabel?: LabelSegment[]; - icon?: string; - enabled: boolean; +/** Context menu action for React rendering β€” shared action minus handler/priority, plus onClick */ +export type ContextMenuAction = Omit< + SharedContextMenuAction, + "handler" | "priority" +> & { onClick: () => void; -} +}; /** * Render a styled label with colored segments. diff --git a/packages/client/src/game/hud/Minimap.tsx b/packages/client/src/game/hud/Minimap.tsx index b7281bf40..d38a35a4a 100644 --- a/packages/client/src/game/hud/Minimap.tsx +++ b/packages/client/src/game/hud/Minimap.tsx @@ -2,37 +2,40 @@ * Minimap.tsx - 2D Minimap Component * * Shows player position, nearby entities, and terrain on a 2D minimap. + * Orchestrates the canvas ref lifecycle, requestAnimationFrame loop, + * and composes satellite components. All pure draw logic lives in + * MinimapRenderer.ts; all interaction logic lives in useMinimapInteraction.ts. */ -import React, { - memo, - useCallback, - useEffect, - useRef, - useState, - useMemo, -} from "react"; +import React, { memo, useEffect, useRef } from "react"; import { useThemeStore, useQuestSelectionStore } from "@/ui"; -import { - Entity, - EventType, - THREE, - TERRAIN_CONSTANTS, - INPUT, -} from "@hyperscape/shared"; +import { Entity, THREE } from "@hyperscape/shared"; import type { ClientWorld } from "../../types"; import { type EntityPip, useMinimapEntityPips } from "./useMinimapEntityPips"; import { useQuestStatusSync } from "./useQuestStatusSync"; import { - type MinimapRoad, type MinimapRoadWithAABB, type MinimapTown, + type MinimapRoad, useMinimapWorldCaches, } from "./useMinimapWorldCaches"; import { MINIMAP_TERRAIN_OVERSHOOT, useMinimapTerrainCache, } from "./useMinimapTerrainCache"; +import { + type ProjectedRoad, + type MinimapRenderState, + type HyperscapeWindow, + createRenderState, + clearIconCache, + getSpectatorTarget, + drawRoadsAndBuildings, + drawEntityPips, + drawDestinationMarker, +} from "./MinimapRenderer"; +import { useMinimapInteraction } from "./useMinimapInteraction"; + // Shared with terrain-cache generation so draw-time coverage matches the // cached snapshot's real world footprint. const TERRAIN_OVERSHOOT = MINIMAP_TERRAIN_OVERSHOOT; @@ -41,742 +44,6 @@ const TERRAIN_OVERSHOOT = MINIMAP_TERRAIN_OVERSHOOT; // live overlay motion instead of throttling it behind the player. const RENDER_EVERY_N_FRAMES = 1; -// Zoom bounds and step size β€” kept at module scope for stability across re-renders -const MIN_EXTENT = 20; -const MAX_EXTENT = 1000; -const STEP_EXTENT = 10; - -// Reference minimap pixel size at which the initial zoom level is 1:1. -// sizeBasedExtent = zoom Γ— (avgSize / MINIMAP_BASE_SIZE_PX) -const MINIMAP_BASE_SIZE_PX = 200; - -// Fixed road/building pixel widths β€” do NOT scale with zoom -const ROAD_LINE_WIDTH_PX = 5; -const ROAD_OUTLINE_WIDTH_PX = 7; -const BUILDING_LINE_WIDTH_PX = 0.5; -const ROAD_OUTLINE_COLOR = "rgb(56, 60, 68)"; -const ROAD_FILL_COLOR = "rgb(164, 151, 128)"; -const BUILDING_FILL_COLOR = "rgba(84, 92, 104, 0.92)"; -const BUILDING_STROKE_COLOR = "rgb(34, 39, 46)"; - -/** 2D context interface for minimap drawing β€” satisfied by both CanvasRenderingContext2D and OffscreenCanvasRenderingContext2D */ -interface MinimapDrawContext { - save(): void; - restore(): void; - beginPath(): void; - moveTo(x: number, y: number): void; - lineTo(x: number, y: number): void; - closePath(): void; - stroke(): void; - fill(): void; - strokeStyle: string | CanvasGradient | CanvasPattern; - fillStyle: string | CanvasGradient | CanvasPattern; - lineWidth: number; - lineCap: "butt" | "round" | "square"; - lineJoin: "bevel" | "round" | "miter"; -} - -/** - * Pre-allocated flat pixel-path buffer β€” zero heap allocation per road per frame. - * Stores (x, y) pairs for all visible road points in a single contiguous block. - * Grows by doubling when capacity is exceeded; never shrinks. - */ -function ensureRoadPixelBufCapacity( - roadPixelBufRef: React.MutableRefObject, - needed: number, -): void { - if (roadPixelBufRef.current.length >= needed * 2) return; - let n = roadPixelBufRef.current.length; - while (n < needed * 2) n *= 2; - roadPixelBufRef.current = new Float32Array(n); -} - -/** Per-road projected data β€” populated once per frame, two-pass rendered */ -type ProjectedRoad = { - pts: Float32Array; - len: number; - fill: number; - outline: number; -}; -/** - * Project a world XZ point to canvas pixel coordinates using the camera's - * projection-view matrix β€” the same transform used for entity pips. - * - * This replaces the old worldToPx() which used a separate (often stale) - * center/extent/up coordinate system that would drift out of sync with the - * terrain on zoom, pan, or rotate. - */ -function worldToPx( - wx: number, - wz: number, - projectionViewMatrix: THREE.Matrix4, - scratchVec: THREE.Vector3, - cw: number, - ch: number, -): void { - scratchVec.set(wx, 0, wz); - scratchVec.applyMatrix4(projectionViewMatrix); - scratchVec.x = (scratchVec.x * 0.5 + 0.5) * cw; - scratchVec.y = (scratchVec.y * -0.5 + 0.5) * ch; -} - -/** - * Draw roads and buildings on the overlay canvas. - * - * Uses camera-matrix projection (same as entity pips) so roads zoom, pan, and - * rotate in perfect lockstep with pips β€” no separate coordinate system, no - * terrain-cache dependency, no desync on zoom or rotation. - * - * Roads use fixed pixel widths (ROAD_LINE_WIDTH_PX / ROAD_OUTLINE_WIDTH_PX) so - * they don't visually scale when the player zooms in/out β€” only the terrain - * background scales. - */ -function drawRoadsAndBuildingsOverlay( - ctx: CanvasRenderingContext2D, - roads: MinimapRoadWithAABB[] | null, - towns: MinimapTown[] | null, - roadPixelBufRef: React.MutableRefObject, - projectedRoadsRef: React.MutableRefObject, - projectionViewMatrix: THREE.Matrix4, - scratchVec: THREE.Vector3, - camX: number, - camZ: number, - viewRadius: number, - /** Pixels per world unit: cw / (2 * extent). Used to scale road widths with zoom. */ - worldToPixel: number, - cw: number, - ch: number, -): void { - if (roads && roads.length > 0) { - // Two-pass rendering: all outlines first, all fills second. - // Drawing per-road (outlineβ†’fillβ†’outlineβ†’fill…) leaves dark outline bands - // wherever roads cross because each road's outline paints over the previous - // road's fill. Batching outlines then fills means every fill covers every - // outline edge, so intersections look seamless. - - // Pass 0 β€” count total visible points so we can pre-size the global buffer - // in one shot, avoiding any mid-loop reallocation. - let totalVisiblePts = 0; - for (const road of roads) { - if (road.path.length < 2) continue; - if ( - road.maxX < camX - viewRadius || - road.minX > camX + viewRadius || - road.maxZ < camZ - viewRadius || - road.minZ > camZ + viewRadius - ) - continue; - totalVisiblePts += road.path.length; - } - ensureRoadPixelBufCapacity(roadPixelBufRef, totalVisiblePts); - const roadPixelBuf = roadPixelBufRef.current; - const projectedRoads = projectedRoadsRef.current; - - // Pass 1 (projection) β€” write XY pairs into _roadPixelBuf, store subarray - // views in _projectedRoads. Zero Float32Array allocations per frame. - projectedRoads.length = 0; - let _bufOffset = 0; - - for (const road of roads) { - if (road.path.length < 2) continue; - if ( - road.maxX < camX - viewRadius || - road.minX > camX + viewRadius || - road.maxZ < camZ - viewRadius || - road.minZ > camZ + viewRadius - ) - continue; - - const worldWidth = road.width > 0 ? road.width : 4; - const scaledFill = Math.max( - ROAD_LINE_WIDTH_PX, - Math.min(40, worldWidth * worldToPixel), - ); - const scaledOutline = Math.max(ROAD_OUTLINE_WIDTH_PX, scaledFill + 2); - - const ptsBase = _bufOffset; - for (let ri = 0; ri < road.path.length; ri++) { - worldToPx( - road.path[ri].x, - road.path[ri].z, - projectionViewMatrix, - scratchVec, - cw, - ch, - ); - roadPixelBuf[_bufOffset++] = scratchVec.x; - roadPixelBuf[_bufOffset++] = scratchVec.y; - } - projectedRoads.push({ - pts: roadPixelBuf.subarray(ptsBase, _bufOffset), - len: road.path.length, - fill: scaledFill, - outline: scaledOutline, - }); - } - - if (projectedRoads.length > 0) { - ctx.save(); - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - - // Pass 2 β€” outlines only (all roads) - ctx.strokeStyle = ROAD_OUTLINE_COLOR; - for (const r of projectedRoads) { - ctx.lineWidth = r.outline; - ctx.beginPath(); - ctx.moveTo(r.pts[0], r.pts[1]); - for (let i = 1; i < r.len; i++) - ctx.lineTo(r.pts[i * 2], r.pts[i * 2 + 1]); - ctx.stroke(); - } - - // Pass 3 β€” fills only (all roads) - ctx.strokeStyle = ROAD_FILL_COLOR; - for (const r of projectedRoads) { - ctx.lineWidth = r.fill; - ctx.beginPath(); - ctx.moveTo(r.pts[0], r.pts[1]); - for (let i = 1; i < r.len; i++) - ctx.lineTo(r.pts[i * 2], r.pts[i * 2 + 1]); - ctx.stroke(); - } - ctx.restore(); - } - } - - if (towns && towns.length > 0) { - ctx.save(); - // Building stroke scales with zoom but clamped to a thin line - ctx.lineWidth = Math.max( - BUILDING_LINE_WIDTH_PX + 0.25, - Math.min(3.25, worldToPixel * 0.35), - ); - for (const town of towns) { - for (const building of town.buildings) { - const bx = building.position.x; - const bz = building.position.z; - if ( - Math.abs(bx - camX) > viewRadius || - Math.abs(bz - camZ) > viewRadius - ) - continue; - - const hw = building.size.width * 0.5; - const hd = building.size.depth * 0.5; - const cos = Math.cos(building.rotation); - const sin = Math.sin(building.rotation); - - // Project 4 rotated corners through the camera matrix - worldToPx( - bx + cos * hw - sin * hd, - bz + sin * hw + cos * hd, - projectionViewMatrix, - scratchVec, - cw, - ch, - ); - const p0x = scratchVec.x; - const p0y = scratchVec.y; - - worldToPx( - bx - cos * hw - sin * hd, - bz - sin * hw + cos * hd, - projectionViewMatrix, - scratchVec, - cw, - ch, - ); - const p1x = scratchVec.x; - const p1y = scratchVec.y; - - worldToPx( - bx - cos * hw + sin * hd, - bz - sin * hw - cos * hd, - projectionViewMatrix, - scratchVec, - cw, - ch, - ); - const p2x = scratchVec.x; - const p2y = scratchVec.y; - - worldToPx( - bx + cos * hw + sin * hd, - bz + sin * hw - cos * hd, - projectionViewMatrix, - scratchVec, - cw, - ch, - ); - const p3x = scratchVec.x; - const p3y = scratchVec.y; - - ctx.beginPath(); - ctx.moveTo(p0x, p0y); - ctx.lineTo(p1x, p1y); - ctx.lineTo(p2x, p2y); - ctx.lineTo(p3x, p3y); - ctx.closePath(); - ctx.fillStyle = BUILDING_FILL_COLOR; - ctx.fill(); - ctx.strokeStyle = BUILDING_STROKE_COLOR; - ctx.stroke(); - } - } - ctx.restore(); - } -} - -/** - * Per-instance render state factory. - * - * Previously these were module-level constants, which meant multiple simultaneous - * Minimap instances would corrupt each other's temp vectors mid-frame. Each - * component instance now gets its own isolated set via renderStateRef. - */ -interface MinimapRenderState { - /** Camera forward direction (XZ) */ - forwardVec: THREE.Vector3; - /** Pip worldβ†’screen projection scratch */ - projectVec: THREE.Vector3; - /** Destination marker projection scratch */ - destVec: THREE.Vector3; - /** screenToWorldXZ unprojection scratch */ - unprojectVec: THREE.Vector3; - /** Camera follow target position scratch */ - targetPos: { x: number; z: number }; - /** Combined projection-view matrix, updated once per frame */ - projectionViewMatrix: THREE.Matrix4; - /** Whether projectionViewMatrix has been populated this session */ - hasCachedMatrix: boolean; -} - -function createRenderState(): MinimapRenderState { - return { - forwardVec: new THREE.Vector3(), - projectVec: new THREE.Vector3(), - destVec: new THREE.Vector3(), - unprojectVec: new THREE.Vector3(), - targetPos: { x: 0, z: 0 }, - projectionViewMatrix: new THREE.Matrix4(), - hasCachedMatrix: false, - }; -} - -/** Augmented window type covering all Hyperscape globals written to window */ -type HyperscapeWindow = Window & - typeof globalThis & { - __lastRaycastTarget?: { x: number; y: number; z: number; method: string }; - __HYPERSCAPE_CONFIG__?: { mode?: string; followEntity?: string }; - }; - -/** Camera info shape returned by the client-camera-system */ -interface SpectatorTarget { - id?: string; - position: { x: number; z: number }; -} - -/** - * Returns the spectated entity's position when in spectator mode, or null. - * Centralises the duplicated window.__HYPERSCAPE_CONFIG__ + camera-system reads - * that previously appeared independently in the entity interval and the RAF loop. - */ -function getSpectatorTarget(world: ClientWorld): SpectatorTarget | null { - if ((window as HyperscapeWindow).__HYPERSCAPE_CONFIG__?.mode !== "spectator") - return null; - const cameraSystem = world.getSystem("client-camera-system") as { - getCameraInfo?: () => { - target?: { - id?: string; - node?: { position?: THREE.Vector3 }; - position?: { x: number; z: number }; - }; - }; - } | null; - const info = cameraSystem?.getCameraInfo?.(); - if (!info?.target) return null; - // Entity-interval callers need node.position (Vector3); RAF needs target.position ({x,z}). - // Return the first available position shape. - const pos = info.target.node?.position ?? info.target.position; - if (!pos) return null; - return { id: info.target.id, position: { x: pos.x, z: pos.z } }; -} - -/** Color palette for group members (up to 8 unique) */ -const GROUP_COLORS = [ - "#4CAF50", // Green - party leader - "#2196F3", // Blue - "#9C27B0", // Purple - "#FF9800", // Orange - "#00BCD4", // Cyan - "#E91E63", // Pink - "#CDDC39", // Lime - "#607D8B", // Blue-grey -]; - -/** - * Draw a star shape on canvas for quest markers - */ -function drawStar( - ctx: CanvasRenderingContext2D, - cx: number, - cy: number, - outerRadius: number, - innerRadius: number, - points: number = 5, -): void { - const step = Math.PI / points; - ctx.beginPath(); - for (let i = 0; i < 2 * points; i++) { - const radius = i % 2 === 0 ? outerRadius : innerRadius; - const angle = i * step - Math.PI / 2; - const x = cx + radius * Math.cos(angle); - const y = cy + radius * Math.sin(angle); - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } - } - ctx.closePath(); -} - -/** - * Draw a diamond shape on canvas - */ -function drawDiamond( - ctx: CanvasRenderingContext2D, - cx: number, - cy: number, - size: number, -): void { - ctx.beginPath(); - ctx.moveTo(cx, cy - size); // Top - ctx.lineTo(cx + size, cy); // Right - ctx.lineTo(cx, cy + size); // Bottom - ctx.lineTo(cx - size, cy); // Left - ctx.closePath(); -} - -/** - * Draw a red flag destination marker (RS3-style) - * Simple: thin pole + small filled triangle flag - */ -function drawFlag(ctx: CanvasRenderingContext2D, cx: number, cy: number): void { - // Pole - ctx.strokeStyle = "#880000"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(cx, cy + 3); - ctx.lineTo(cx, cy - 5); - ctx.stroke(); - - // Flag (small filled triangle off the pole) - ctx.fillStyle = "#ff0000"; - ctx.beginPath(); - ctx.moveTo(cx, cy - 5); - ctx.lineTo(cx + 5, cy - 3); - ctx.lineTo(cx, cy - 1); - ctx.closePath(); - ctx.fill(); -} - -/** - * Flyweight icon cache: each distinct subType is rendered exactly ONCE into a - * 16Γ—16 OffscreenCanvas and stored here. Every subsequent call uses drawImage() - * which is GPU-accelerated and ~10–20Γ— faster than re-executing path drawing code. - * - * The cache is populated lazily so fonts are guaranteed to be loaded by first use. - */ -const _iconCache = new Map(); -const _ICON_SIZE = 16; - -function _renderIconOnce(subType: string): OffscreenCanvas | null { - const offscreen = new OffscreenCanvas(_ICON_SIZE, _ICON_SIZE); - const raw = offscreen.getContext("2d"); - if (!raw) return null; - // OffscreenCanvasRenderingContext2D satisfies all properties in the extended - // MinimapDrawContext intersection β€” this cast is safe. - const ictx = raw as Parameters[0]; - const cx = _ICON_SIZE / 2; - const cy = _ICON_SIZE / 2; - const drawn = _drawIconGlyph(ictx, cx, cy, subType); - return drawn ? offscreen : null; -} - -/** - * Draw minimap icon for a location type. - * Returns true if drawn, false for unknown subType (caller falls back to a dot). - * - * On first call per subType, renders to an OffscreenCanvas and caches it. - * All subsequent calls use drawImage() β€” zero path drawing overhead. - */ -function drawMinimapIcon( - ctx: CanvasRenderingContext2D, - cx: number, - cy: number, - subType: string, -): boolean { - let icon = _iconCache.get(subType); - if (icon === undefined) { - // First time β€” render and cache (null = unknown type, skip caching a blank canvas) - icon = _renderIconOnce(subType); - _iconCache.set(subType, icon); - } - if (!icon) return false; - ctx.drawImage( - icon, - cx - _ICON_SIZE / 2, - cy - _ICON_SIZE / 2, - _ICON_SIZE, - _ICON_SIZE, - ); - return true; -} - -/** Inner glyph renderer β€” only called once per subType. */ -function _drawIconGlyph( - ctx: MinimapDrawContext & { - font: string; - textAlign: "left" | "right" | "center" | "start" | "end"; - textBaseline: - | "top" - | "hanging" - | "middle" - | "alphabetic" - | "ideographic" - | "bottom"; - fillText(text: string, x: number, y: number): void; - fillRect(x: number, y: number, w: number, h: number): void; - strokeRect(x: number, y: number, w: number, h: number): void; - arc(x: number, y: number, r: number, sA: number, eA: number): void; - ellipse( - x: number, - y: number, - rX: number, - rY: number, - rot: number, - sA: number, - eA: number, - ): void; - quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; - }, - cx: number, - cy: number, - subType: string, -): boolean { - ctx.save(); - ctx.lineWidth = 1; - ctx.strokeStyle = "#000000"; - - switch (subType) { - // --- Bank: gold coin ($) --- - case "bank": - ctx.fillStyle = "#daa520"; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = "#ffffff"; - ctx.font = "bold 10px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("$", cx + 0.5, cy + 1); - break; - - // --- Shop: small open-top bag --- - case "shop": - ctx.fillStyle = "#daa520"; - ctx.beginPath(); - ctx.moveTo(cx - 5, cy - 4); - ctx.lineTo(cx - 4, cy + 5); - ctx.lineTo(cx + 4, cy + 5); - ctx.lineTo(cx + 5, cy - 4); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - break; - - // --- Prayer altar: simple cross --- - case "altar": - ctx.fillStyle = "#ffffff"; - ctx.fillRect(cx - 1.5, cy - 6, 3, 12); - ctx.fillRect(cx - 5, cy - 2.5, 10, 3); - ctx.strokeRect(cx - 1.5, cy - 6, 3, 12); - ctx.strokeRect(cx - 5, cy - 2.5, 10, 3); - break; - - // --- Runecrafting altar: purple circle --- - case "runecrafting_altar": - ctx.fillStyle = "#7744cc"; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = "#ffffff"; - ctx.font = "bold 10px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("R", cx + 0.5, cy + 1); - break; - - // --- Anvil: dark flat anvil silhouette --- - case "anvil": - ctx.fillStyle = "#666666"; - ctx.beginPath(); - ctx.moveTo(cx - 6, cy + 4); - ctx.lineTo(cx - 4, cy - 1); - ctx.lineTo(cx - 5, cy - 4); - ctx.lineTo(cx + 5, cy - 4); - ctx.lineTo(cx + 4, cy - 1); - ctx.lineTo(cx + 6, cy + 4); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - break; - - // --- Furnace: orange circle with flame --- - case "furnace": - ctx.fillStyle = "#dd5500"; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - // Simple flame (inverted drop) - ctx.fillStyle = "#ffcc00"; - ctx.beginPath(); - ctx.moveTo(cx, cy - 4); - ctx.quadraticCurveTo(cx + 3, cy + 1, cx, cy + 4); - ctx.quadraticCurveTo(cx - 3, cy + 1, cx, cy - 4); - ctx.fill(); - break; - - // --- Cooking range: brown circle with steam --- - case "range": - ctx.fillStyle = "#8b5e3c"; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - // Two short steam lines - ctx.strokeStyle = "#ffffff"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(cx - 2, cy + 1); - ctx.lineTo(cx - 2, cy - 3); - ctx.moveTo(cx + 2, cy + 1); - ctx.lineTo(cx + 2, cy - 3); - ctx.stroke(); - break; - - // --- Fishing spot: cyan dot with fish --- - case "fishing": - ctx.fillStyle = "#2288cc"; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "#000000"; - ctx.stroke(); - // Tiny fish shape - ctx.fillStyle = "#ffffff"; - ctx.beginPath(); - ctx.ellipse(cx - 1, cy, 3.5, 2, 0, 0, Math.PI * 2); - ctx.fill(); - // Tail - ctx.beginPath(); - ctx.moveTo(cx + 2.5, cy); - ctx.lineTo(cx + 5, cy - 2.5); - ctx.lineTo(cx + 5, cy + 2.5); - ctx.closePath(); - ctx.fill(); - break; - - // --- Mining rock: brown dot with pickaxe --- - case "mining": - ctx.fillStyle = "#8b6914"; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "#000000"; - ctx.stroke(); - // Diagonal pick handle - ctx.strokeStyle = "#dddddd"; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(cx - 3.5, cy + 3.5); - ctx.lineTo(cx + 3.5, cy - 3.5); - ctx.stroke(); - // Pick head - ctx.beginPath(); - ctx.moveTo(cx + 1, cy - 5); - ctx.lineTo(cx + 5, cy - 1); - ctx.stroke(); - break; - - // --- Tree: green circle --- - case "tree": - ctx.fillStyle = "#228822"; - ctx.beginPath(); - ctx.arc(cx, cy, 5, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "#115511"; - ctx.stroke(); - break; - - // --- Quest NPC (available): blue circle with white "!" --- - case "quest_available": - case "quest": - ctx.fillStyle = "#2196F3"; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "#000000"; - ctx.stroke(); - ctx.fillStyle = "#ffffff"; - ctx.font = "bold 10px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("!", cx + 0.5, cy + 1); - break; - - // --- Quest NPC (in progress): blue circle with white "?" --- - case "quest_in_progress": - ctx.fillStyle = "#2196F3"; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "#000000"; - ctx.stroke(); - ctx.fillStyle = "#ffffff"; - ctx.font = "bold 10px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("?", cx + 0.5, cy + 1); - break; - - default: - ctx.restore(); - return false; - } - - ctx.restore(); - return true; -} - -/** Terrain system interface used for height sampling and click-to-move */ -interface TerrainSystemLike { - getHeightAt: (x: number, z: number) => number; - getWaterBodyRegistry?: () => { - getWaterSurfaceAt: (x: number, z: number) => number; - }; -} - -/** Network send interface needed for server-authoritative move requests */ -interface WorldNetworkSend { - network: { send: (method: string, data: unknown) => void }; -} - /** Minimal structural interface for elements that can be rotated via inline style */ interface CSSStylable { style: { transform: string }; @@ -848,8 +115,14 @@ function MinimapInner({ const entityCacheRef = useRef>(new Map()); const roadPixelBufRef = useRef(new Float32Array(4096 * 2)); const projectedRoadsRef = useRef([]); - // Per-instance render state β€” isolated from other Minimap instances const renderStateRef = useRef(createRenderState()); + const lastDestinationWorldRef = useRef<{ x: number; z: number } | null>(null); + + // Always rotate with the main camera (RS3-style). + const rotateWithCameraRef = useRef(true); + // Direct ref to the collapsed compass SVG -- yaw is written via DOM to avoid + // triggering React reconciliation from inside requestAnimationFrame. + const compassRef = useRef(null); const { terrainOffscreenRef, @@ -861,14 +134,12 @@ function MinimapInner({ ensureTerrainCache, } = useMinimapTerrainCache(world); - // Cached 2D rendering contexts β€” avoids DOM query every frame + // Cached 2D rendering contexts -- avoids DOM query every frame const mainCtxRef = useRef(null); const overlayCtxRef = useRef(null); - // Static world feature caches β€” populated once, used for overlay with fixed pixel sizes + // Static world feature caches -- populated once, used for overlay with fixed pixel sizes const roadsCacheRef = useRef(null); - // Roads enriched with pre-computed AABBs β€” built once when the road cache is first - // populated so the per-frame visibility check is O(1) instead of O(path_length). const roadsWithAABBRef = useRef(null); const townsCacheRef = useRef(null); @@ -878,83 +149,50 @@ function MinimapInner({ useQuestStatusSync({ world, questStatusesRef, setQuestStatuses }); - // Collapsed state for collapsible minimap - const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); - - // Handle collapse toggle - const toggleCollapse = useCallback(() => { - setIsCollapsed((prev) => { - const newValue = !prev; - onCollapseChange?.(newValue); - return newValue; - }); - }, [onCollapseChange]); - - // Current size state (for resizing) - const [currentWidth, setCurrentWidth] = useState(initialWidth); - const [currentHeight, setCurrentHeight] = useState(initialHeight); - const width = currentWidth; - const height = currentHeight; - - // Refs for width/height to allow RAF loop to access current values without stale closures - const widthRef = useRef(width); - const heightRef = useRef(height); - - // Keep dimension refs updated for RAF loop access - useEffect(() => { - widthRef.current = width; - heightRef.current = height; - }, [width, height]); - - // Resize state - const [isResizing, setIsResizing] = useState(false); - const resizeStartRef = useRef<{ - x: number; - y: number; - w: number; - h: number; - } | null>(null); - const resizeCleanupRef = useRef<(() => void) | null>(null); - // Tracks the latest clamped size so handleUp always reads the post-drag value, - // not the stale closure-captured size from when the pointerdown fired. - const latestSizeRef = useRef({ w: initialWidth, h: initialHeight }); - - // Calculate extent based on size - larger size = more visible area (not scaled) - // Use the average of width/height to determine extent - const sizeBasedExtent = useMemo(() => { - const avgSize = (width + height) / 2; - return zoom * (avgSize / MINIMAP_BASE_SIZE_PX); - }, [width, height, zoom]); - - // Minimap zoom state (orthographic half-extent in world units) - const [targetExtent, setTargetExtent] = useState(sizeBasedExtent); - const targetExtentRef = useRef(targetExtent); - const extentRef = useRef(targetExtent); // Live displayed extent for render loop - // Update extent when size changes (reveals more map) - useEffect(() => { - setTargetExtent(sizeBasedExtent); - }, [sizeBasedExtent]); - - // Always rotate with the main camera (RS3-style). - const rotateWithCameraRef = useRef(true); - // Direct ref to the collapsed compass SVG β€” yaw is written via DOM to avoid - // triggering React reconciliation from inside requestAnimationFrame. - const compassRef = useRef(null); - - // Destination in world space β€” written by handleMinimapClick, cleared by RAF on arrival. - // Ref-only: the RAF loop reads it synchronously, no React state needed. - const lastDestinationWorldRef = useRef<{ x: number; z: number } | null>(null); - // Debounce: ignore minimap clicks within 150ms of the previous one to prevent - // flooding the server with moveRequest packets during accidental double-clicks. - const lastClickTimeRef = useRef(0); + // ── Interaction hook ───────────────────────────────────────────────────────── + const interaction = useMinimapInteraction({ + world, + initialWidth, + initialHeight, + zoom, + resizable, + minSize, + maxSize, + onSizeChange, + collapsible, + defaultCollapsed, + onCollapseChange, + cameraRef, + canvasRef, + overlayCanvasRef, + containerRef, + renderStateRef, + lastDestinationWorldRef, + }); - // Initialize minimap camera (no WebGPU renderer needed β€” Canvas 2D handles all drawing) + const { + width, + height, + widthRef, + heightRef, + extentRef, + targetExtentRef, + isCollapsed, + isResizing, + toggleCollapse, + onCollapseButtonClick, + onOverlayClick, + onPreventDefault, + onStopAndPrevent, + handleResizeStart, + } = interaction; + + // ── Camera init ────────────────────────────────────────────────────────────── useEffect(() => { const canvas = canvasRef.current; const overlayCanvas = overlayCanvasRef.current; if (!canvas || !overlayCanvas) return; - // Create orthographic camera for overhead view const camera = new THREE.OrthographicCamera( -targetExtentRef.current, targetExtentRef.current, @@ -963,7 +201,6 @@ function MinimapInner({ 0.1, 2000, ); - // Orient minimap to match main camera heading on XZ plane const initialForward = new THREE.Vector3(); if (world?.camera) { world.camera.getWorldDirection(initialForward); @@ -979,56 +216,38 @@ function MinimapInner({ camera.up.copy(initialForward); camera.position.set(0, 500, 0); camera.lookAt(0, 0, 0); - - // Mark camera as minimap for systems that need to check (e.g., water system) camera.userData.isMinimap = true; - cameraRef.current = camera; - // Ensure both canvases have the correct backing size canvas.width = width; canvas.height = height; overlayCanvas.width = width; overlayCanvas.height = height; - // Cache 2D contexts once (getContext is a DOM query β€” avoid calling every frame) mainCtxRef.current = canvas.getContext("2d"); overlayCtxRef.current = overlayCanvas.getContext("2d"); - - // Invalidate terrain cache when canvas dimensions change invalidateTerrainCache(); // Note: extent intentionally omitted - changes handled via extentRef in render loop }, [width, height, world]); - // Cleanup camera reference and terrain cache when component unmounts + // ── Cleanup ────────────────────────────────────────────────────────────────── useEffect(() => { return () => { - // Clear camera reference and userData if (cameraRef.current) { cameraRef.current.userData = {}; cameraRef.current = null; } - clearTerrainCache(); roadsCacheRef.current = null; roadsWithAABBRef.current = null; townsCacheRef.current = null; - - // Clear entity cache to prevent memory retention entityCacheRef.current.clear(); - // Clear icon flyweight cache so OffscreenCanvas objects can be GC'd - _iconCache.clear(); - // Remove any dangling resize listeners if unmounted mid-drag - resizeCleanupRef.current?.(); + clearIconCache(); }; }, [clearTerrainCache]); - // Keep extent ref in sync with state for render loop access - useEffect(() => { - targetExtentRef.current = targetExtent; - }, [targetExtent]); - + // ── Satellite hooks ────────────────────────────────────────────────────────── useMinimapEntityPips({ world, isVisible, @@ -1044,8 +263,7 @@ function MinimapInner({ townsCacheRef, }); - // Single unified render loop - handles camera position, frustum, and rendering - // Uses refs for all state access to avoid restarting the RAF loop + // ── RAF render loop ────────────────────────────────────────────────────────── useEffect(() => { const overlayCanvas = overlayCanvasRef.current; if (!overlayCanvas || !isVisible) return; @@ -1055,59 +273,44 @@ function MinimapInner({ const render = () => { frameCount++; - // Cache time once per frame β€” reused for pulse animations, avoids Date.now() per-pip const frameTimeMs = performance.now(); const cam = cameraRef.current; - - // Destructure per-instance render state β€” object aliases let us mutate through them - // without changing any of the hot-path code that uses these names. const rs = renderStateRef.current; - const _tempForwardVec = rs.forwardVec; - const _tempProjectVec = rs.projectVec; - const _tempDestVec = rs.destVec; - const _tempTargetPos = rs.targetPos; - const _cachedProjectionViewMatrix = rs.projectionViewMatrix; + const forwardVec = rs.forwardVec; + const projectVec = rs.projectVec; + const destVec = rs.destVec; + const targetPos = rs.targetPos; + const pvMatrix = rs.projectionViewMatrix; // --- Camera Position Update (follow player or spectated entity) --- const player = world.entities?.player as Entity | undefined; let hasTarget = false; if (player) { - // Normal mode: follow local player - _tempTargetPos.x = player.node.position.x; - _tempTargetPos.z = player.node.position.z; + targetPos.x = player.node.position.x; + targetPos.z = player.node.position.z; hasTarget = true; } else { - // Spectator mode: get camera target from camera system const spectatorTarget = getSpectatorTarget(world); if (spectatorTarget) { - _tempTargetPos.x = spectatorTarget.position.x; - _tempTargetPos.z = spectatorTarget.position.z; + targetPos.x = spectatorTarget.position.x; + targetPos.z = spectatorTarget.position.z; hasTarget = true; } } if (cam && hasTarget) { - // Keep centered on target (player or spectated entity) - // Using pre-allocated _tempTargetPos to avoid GC pressure - cam.position.x = _tempTargetPos.x; - cam.position.z = _tempTargetPos.z; - cam.lookAt(_tempTargetPos.x, 0, _tempTargetPos.z); + cam.position.x = targetPos.x; + cam.position.z = targetPos.z; + cam.lookAt(targetPos.x, 0, targetPos.z); - // Rotate minimap with main camera yaw if enabled if (rotateWithCameraRef.current && world.camera) { - const worldCam = world.camera; - // Reuse pre-allocated vector to avoid GC pressure - worldCam.getWorldDirection(_tempForwardVec); - _tempForwardVec.y = 0; - if (_tempForwardVec.lengthSq() > 1e-6) { - _tempForwardVec.normalize(); - // Compute yaw so that up vector rotates the minimap - const yaw = Math.atan2(_tempForwardVec.x, -_tempForwardVec.z); - const upX = Math.sin(yaw); - const upZ = -Math.cos(yaw); - cam.up.set(upX, 0, upZ); - // Update compass arrow via direct DOM write β€” no React re-render + world.camera.getWorldDirection(forwardVec); + forwardVec.y = 0; + if (forwardVec.lengthSq() > 1e-6) { + forwardVec.normalize(); + const yaw = Math.atan2(forwardVec.x, -forwardVec.z); + cam.up.set(Math.sin(yaw), 0, -Math.cos(yaw)); if (compassRef.current) { compassRef.current.style.transform = `rotate(${THREE.MathUtils.radToDeg(yaw)}deg)`; } @@ -1119,8 +322,8 @@ function MinimapInner({ // Clear destination when reached const destWorld = lastDestinationWorldRef.current; if (destWorld) { - const dx = destWorld.x - _tempTargetPos.x; - const dz = destWorld.z - _tempTargetPos.z; + const dx = destWorld.x - targetPos.x; + const dz = destWorld.z - targetPos.z; if (dx * dx + dz * dz < 0.36) { lastDestinationWorldRef.current = null; } @@ -1129,8 +332,8 @@ function MinimapInner({ // Also clear global raycast target when player reaches it const hw = window as HyperscapeWindow; if (hw.__lastRaycastTarget) { - const dx = hw.__lastRaycastTarget.x - _tempTargetPos.x; - const dz = hw.__lastRaycastTarget.z - _tempTargetPos.z; + const dx = hw.__lastRaycastTarget.x - targetPos.x; + const dz = hw.__lastRaycastTarget.z - targetPos.z; if (dx * dx + dz * dz < 0.36) delete hw.__lastRaycastTarget; } } @@ -1144,11 +347,10 @@ function MinimapInner({ const zoomStep = Math.sign(zoomDelta) * Math.min(60, Math.max(2, Math.abs(zoomDelta) * 0.24)); - const nextExtent = + extentRef.current = Math.abs(zoomDelta) <= Math.abs(zoomStep) ? desiredExtent : currentExtent + zoomStep; - extentRef.current = nextExtent; } const liveExtent = extentRef.current; if (cam.right !== liveExtent) { @@ -1160,125 +362,90 @@ function MinimapInner({ } } - // --- Update camera matrices every frame for smooth pip rendering --- - // Pips represent live entity positions and must stay fluid at 60fps. - // Road/building overlays use the terrain-snapshot parameters (below) so they - // remain locked to the terrain ImageData regardless of this live matrix. + // --- Update camera matrices every frame --- if (cam) { cam.updateMatrixWorld(); - _cachedProjectionViewMatrix.multiplyMatrices( - cam.projectionMatrix, - cam.matrixWorldInverse, - ); + pvMatrix.multiplyMatrices(cam.projectionMatrix, cam.matrixWorldInverse); rs.hasCachedMatrix = true; } - // --- Canvas 2D terrain background (throttled, same cadence as old 3D render) --- + // --- Canvas 2D terrain background --- const shouldRedrawTerrain = frameCount % RENDER_EVERY_N_FRAMES === 0; if (shouldRedrawTerrain && cam) { const mainCanvas = canvasRef.current; - // Use cached context β€” avoids a DOM query every frame const mainCtx = mainCtxRef.current; if (mainCanvas && mainCtx) { - { - const cw = mainCanvas.width; - const ch = mainCanvas.height; - - // Snapshot camera state β€” used for both terrain generation and overlay drawing - // so all layers are guaranteed to be aligned with each other. - const centerX = cam.position.x; - const centerZ = cam.position.z; - const currentExtent = extentRef.current; - const upX = cam.up.x; - const upZ = cam.up.z; - - // Compute the rotation delta between the live camera and the cached terrain angle. - // KEY INSIGHT: rotating the canvas context by +deltaYaw around the canvas center - // is mathematically equivalent to re-drawing everything with the live worldToPx - // camera orientation (proven by coordinate algebra). This means terrain, roads, - // and buildings all rotate INSTANTLY without needing terrain regeneration. - const cachedYaw = Math.atan2( - terrainCacheUpRef.current.x, - -terrainCacheUpRef.current.z, + const cw = mainCanvas.width; + const ch = mainCanvas.height; + const centerX = cam.position.x; + const centerZ = cam.position.z; + const currentExtent = extentRef.current; + const upX = cam.up.x; + const upZ = cam.up.z; + const cachedYaw = Math.atan2( + terrainCacheUpRef.current.x, + -terrainCacheUpRef.current.z, + ); + const currentYaw = Math.atan2(upX, -upZ); + const deltaYaw = currentYaw - cachedYaw; + + ensureTerrainCache({ + centerX, + centerZ, + currentExtent, + upX, + upZ, + viewportPixels: Math.max(cw, ch), + }); + + mainCtx.save(); + mainCtx.translate(cw / 2, ch / 2); + mainCtx.rotate(-deltaYaw); + mainCtx.translate(-cw / 2, -ch / 2); + + if (terrainOffscreenRef.current) { + mainCtx.imageSmoothingEnabled = true; + mainCtx.imageSmoothingQuality = "high"; + const cachedExt = terrainCacheExtentRef.current; + const extentScale = cachedExt > 0 ? cachedExt / currentExtent : 1; + const drawScale = Math.max(1 / TERRAIN_OVERSHOOT, extentScale); + const drawW = cw * TERRAIN_OVERSHOOT * drawScale; + const drawH = ch * TERRAIN_OVERSHOOT * drawScale; + const cachedUpX = terrainCacheUpRef.current.x; + const cachedUpZ = terrainCacheUpRef.current.z; + const cachedRightX = -cachedUpZ; + const cachedRightZ = cachedUpX; + const cachedCenterX = terrainCacheCenterRef.current.x; + const cachedCenterZ = terrainCacheCenterRef.current.z; + const centerDeltaX = centerX - cachedCenterX; + const centerDeltaZ = centerZ - cachedCenterZ; + const offsetRight = + centerDeltaX * cachedRightX + centerDeltaZ * cachedRightZ; + const offsetUp = + centerDeltaX * cachedUpX + centerDeltaZ * cachedUpZ; + const pixelsPerWorldX = cw / (2 * currentExtent); + const pixelsPerWorldY = ch / (2 * currentExtent); + const offsetX = -offsetRight * pixelsPerWorldX; + const offsetY = offsetUp * pixelsPerWorldY; + mainCtx.fillStyle = "#11161c"; + mainCtx.fillRect(0, 0, cw, ch); + mainCtx.drawImage( + terrainOffscreenRef.current, + cw / 2 - drawW / 2 + offsetX, + ch / 2 - drawH / 2 + offsetY, + drawW, + drawH, ); - const currentYaw = Math.atan2(upX, -upZ); - const deltaYaw = currentYaw - cachedYaw; - - // Terrain regeneration is triggered by POSITION or EXTENT change only β€” - // NOT by camera rotation (canvas rotation handles that instantly). - // This eliminates the restart-cancel deadlock: previously, every 4-frame - // terrain check during rotation would increment the version token and cancel - // the in-flight generation before it could finish, freezing the minimap. - ensureTerrainCache({ - centerX, - centerZ, - currentExtent, - upX, - upZ, - viewportPixels: Math.max(cw, ch), - }); - - // Apply a single canvas rotation transform so terrain + all vector overlays - // rotate to the live camera orientation in one GPU operation. - // Negative deltaYaw so minimap rotates same direction as camera (canvas - // positive angle = clockwise; user "rotate left" = counterclockwise = we need -deltaYaw). - mainCtx.save(); - mainCtx.translate(cw / 2, ch / 2); - mainCtx.rotate(-deltaYaw); - mainCtx.translate(-cw / 2, -ch / 2); - - if (terrainOffscreenRef.current) { - mainCtx.imageSmoothingEnabled = true; - mainCtx.imageSmoothingQuality = "high"; - const cachedExt = terrainCacheExtentRef.current; - const extentScale = cachedExt > 0 ? cachedExt / currentExtent : 1; - // Draw stale snapshots at their true world scale. When rapidly - // zooming out, keep at least viewport coverage so we never expose - // a hard black box while the replacement cache is generated. - const drawScale = Math.max(1 / TERRAIN_OVERSHOOT, extentScale); - const drawW = cw * TERRAIN_OVERSHOOT * drawScale; - const drawH = ch * TERRAIN_OVERSHOOT * drawScale; - const cachedUpX = terrainCacheUpRef.current.x; - const cachedUpZ = terrainCacheUpRef.current.z; - const cachedRightX = -cachedUpZ; - const cachedRightZ = cachedUpX; - const cachedCenterX = terrainCacheCenterRef.current.x; - const cachedCenterZ = terrainCacheCenterRef.current.z; - const centerDeltaX = centerX - cachedCenterX; - const centerDeltaZ = centerZ - cachedCenterZ; - // Project center deltas into the cached terrain basis, not the - // live camera basis. The cached image is authored in that older - // orientation and then globally rotated above via deltaYaw. - const offsetRight = - centerDeltaX * cachedRightX + centerDeltaZ * cachedRightZ; - const offsetUp = - centerDeltaX * cachedUpX + centerDeltaZ * cachedUpZ; - const pixelsPerWorldX = cw / (2 * currentExtent); - const pixelsPerWorldY = ch / (2 * currentExtent); - const offsetX = -offsetRight * pixelsPerWorldX; - const offsetY = offsetUp * pixelsPerWorldY; - mainCtx.fillStyle = "#11161c"; - mainCtx.fillRect(0, 0, cw, ch); - mainCtx.drawImage( - terrainOffscreenRef.current, - cw / 2 - drawW / 2 + offsetX, - ch / 2 - drawH / 2 + offsetY, - drawW, - drawH, - ); - } else { - // Fallback: dark background until terrain system is ready - mainCtx.fillStyle = "#11161c"; - mainCtx.fillRect(0, 0, cw, ch); - } - - // Restore canvas transform β€” terrain only, no overlays here - mainCtx.restore(); + } else { + mainCtx.fillStyle = "#11161c"; + mainCtx.fillRect(0, 0, cw, ch); } + + mainCtx.restore(); } } - // Draw 2D overlay (roads β†’ buildings β†’ pips β†’ flag) every frame + // --- Draw 2D overlay (roads -> buildings -> pips -> flag) every frame --- const ctx = overlayCtxRef.current; if (ctx) { const cw = overlayCanvas.width; @@ -1287,165 +454,64 @@ function MinimapInner({ const viewportH = heightRef.current; ctx.clearRect(0, 0, cw, ch); - // ── Roads & buildings ───────────────────────────────────────────────── - // Same camera-matrix projection as entity pips β€” moves, zooms, and - // rotates in perfect sync with no separate coordinate system. if (rs.hasCachedMatrix && cam) { const currentExtent = extentRef.current; - drawRoadsAndBuildingsOverlay( + drawRoadsAndBuildings({ ctx, - roadsWithAABBRef.current, - townsCacheRef.current, - roadPixelBufRef, - projectedRoadsRef, - _cachedProjectionViewMatrix, - _tempProjectVec, - cam.position.x, - cam.position.z, - currentExtent * 2, - // pixels per world unit β€” drives road width scaling with zoom - cw / (2 * currentExtent), + roads: roadsWithAABBRef.current, + towns: townsCacheRef.current, + roadPixelBufHolder: roadPixelBufRef, + projectedRoads: projectedRoadsRef.current, + projectionViewMatrix: pvMatrix, + scratchVec: projectVec, + camX: cam.position.x, + camZ: cam.position.z, + viewRadius: currentExtent * 2, + worldToPixel: cw / (2 * currentExtent), cw, ch, - ); + }); } - // ── Entity pips ─────────────────────────────────────────────────────── - const pipsArray = entityPipsRefForRender.current; - // World-space cull radius: extent + small pip margin so pips near the edge - // aren't clipped mid-frame. 8 world units covers the largest icon (16px icon - // at typical zoom β‰ˆ 4 world units; double for safety). - const pipCullRadius = extentRef.current + 8; - const camPX = cam ? cam.position.x : 0; - const camPZ = cam ? cam.position.z : 0; - for (let pipIdx = 0; pipIdx < pipsArray.length; pipIdx++) { - const pip = pipsArray[pipIdx]; - // World-space pre-cull: skip projection entirely for off-screen pips. - // Uses Chebyshev distance (max of |dx|, |dz|) β€” tighter than circle, - // safe because the minimap is square-ish. - if ( - Math.abs(pip.position.x - camPX) > pipCullRadius || - Math.abs(pip.position.z - camPZ) > pipCullRadius - ) - continue; - - // Convert world position to screen position using cached matrix - // This keeps pips synced with the throttled 3D render (not the live camera) - if (rs.hasCachedMatrix) { - // Reuse pre-allocated vector instead of cloning to avoid GC pressure - _tempProjectVec.copy(pip.position); - // Apply cached projection-view matrix manually instead of using project() - _tempProjectVec.applyMatrix4(_cachedProjectionViewMatrix); - - // Use refs for width/height to avoid stale closure values during resize - const x = (_tempProjectVec.x * 0.5 + 0.5) * viewportW; - const y = (_tempProjectVec.y * -0.5 + 0.5) * viewportH; - - // Only draw if within bounds (use refs for current dimensions) - if (x >= 0 && x <= viewportW && y >= 0 && y <= viewportH) { - // Pip radius β€” default 3px; player and quest are the only exceptions - let radius = 3; - const borderColor = "#000000"; - const borderWidth = 1; - if (pip.type === "player") { - radius = - pip.groupIndex !== undefined && pip.groupIndex >= 0 ? 4 : 3; - } else if (pip.type === "quest") { - radius = pip.isActive ? 7 : 5; - } - - // Determine pip color (group members use GROUP_COLORS) - let pipColor = pip.color; - if ( - pip.type === "player" && - pip.groupIndex !== undefined && - pip.groupIndex >= 0 - ) { - pipColor = GROUP_COLORS[pip.groupIndex % GROUP_COLORS.length]; - } - - // Apply pulse animation for active pips (quests, etc.) - let pulseScale = 1; - if (pip.isActive) { - // frameTimeMs is cached once per frame β€” avoid per-pip Date.now() call - const pulseTime = frameTimeMs / 500; // 500ms per cycle - pulseScale = 1 + 0.15 * Math.sin(pulseTime * Math.PI * 2); - } - - // Draw pip β€” subtype icons use drawImage (no path needed). - // beginPath() is deferred past the icon check to avoid building - // a path that gets discarded for every icon-bearing pip. - ctx.fillStyle = pipColor; - - if (pip.subType && drawMinimapIcon(ctx, x, y, pip.subType)) { - // Icon drawn via cached OffscreenCanvas β€” no path work needed - } else if (pip.isLocalPlayer) { - // RS3/OSRS: local player is a white square (slightly larger than dots) - const sqHalf = 2.5; - ctx.fillStyle = "#ffffff"; - ctx.fillRect(x - sqHalf, y - sqHalf, sqHalf * 2, sqHalf * 2); - } else if (pip.type === "quest" || pip.icon === "star") { - // Star for quest markers. - // Shadow is set BEFORE the fill so one draw call produces both - // the solid star and its glow ring β€” then cleared before stroke - // so the outline stays crisp with no halo artefacts. - const scaledRadius = radius * pulseScale; - if (pip.isActive) { - ctx.shadowColor = pipColor; - ctx.shadowBlur = 8; - } - drawStar(ctx, x, y, scaledRadius, scaledRadius * 0.5, 5); - ctx.fill(); - ctx.shadowBlur = 0; // reset before stroke - ctx.strokeStyle = borderColor; - ctx.lineWidth = borderWidth; - ctx.stroke(); - } else if (pip.icon === "diamond") { - // Diamond shape - drawDiamond(ctx, x, y, radius); - ctx.fill(); - ctx.strokeStyle = borderColor; - ctx.lineWidth = borderWidth; - ctx.stroke(); - } else { - // Circle for everything else (players, mobs, items) - ctx.beginPath(); - ctx.arc(x, y, radius, 0, 2 * Math.PI); - ctx.fill(); - - // Add border for better visibility - ctx.strokeStyle = borderColor; - ctx.lineWidth = borderWidth; - ctx.stroke(); - } - } - } + if (rs.hasCachedMatrix) { + drawEntityPips({ + ctx, + pips: entityPipsRefForRender.current, + projectionViewMatrix: pvMatrix, + scratchVec: projectVec, + camX: cam ? cam.position.x : 0, + camZ: cam ? cam.position.z : 0, + pipCullRadius: extentRef.current + 8, + viewportW, + viewportH, + frameTimeMs, + }); } - // Draw destination like world clicks: project world target to minimap + // Draw destination marker const lastTarget = (window as HyperscapeWindow).__lastRaycastTarget; const destWorldRef = lastDestinationWorldRef.current; const hasLastTarget = lastTarget && Number.isFinite(lastTarget.x) && Number.isFinite(lastTarget.z); - const targetX = hasLastTarget ? lastTarget.x : destWorldRef?.x; - const targetZ = hasLastTarget ? lastTarget.z : destWorldRef?.z; + const markerX = hasLastTarget ? lastTarget.x : destWorldRef?.x; + const markerZ = hasLastTarget ? lastTarget.z : destWorldRef?.z; if ( rs.hasCachedMatrix && - targetX !== undefined && - targetZ !== undefined + markerX !== undefined && + markerZ !== undefined ) { - // Reuse pre-allocated vector instead of creating new one - _tempDestVec.set(targetX, 0, targetZ); - // Apply cached projection-view matrix to stay synced with throttled 3D render - _tempDestVec.applyMatrix4(_cachedProjectionViewMatrix); - // Use refs for width/height to avoid stale closure values during resize - const sx = (_tempDestVec.x * 0.5 + 0.5) * viewportW; - const sy = (_tempDestVec.y * -0.5 + 0.5) * viewportH; - // RS3-style red flag destination marker - drawFlag(ctx, sx, sy); + drawDestinationMarker({ + ctx, + projectionViewMatrix: pvMatrix, + scratchVec: destVec, + viewportW, + viewportH, + targetX: markerX, + targetZ: markerZ, + }); } } @@ -1459,214 +525,7 @@ function MinimapInner({ }; }, [isVisible, world]); - // Convert a click in the minimap to a world XZ position - const screenToWorldXZ = useCallback( - (clientX: number, clientY: number): { x: number; z: number } | null => { - const cam = cameraRef.current; - const cvs = overlayCanvasRef.current || canvasRef.current; - if (!cam || !cvs) return null; - - const rect = cvs.getBoundingClientRect(); - const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1; - const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1; - // Per-instance scratch vector β€” safe when multiple Minimaps exist simultaneously - const vec = renderStateRef.current.unprojectVec; - vec.set(ndcX, ndcY, 0); - vec.unproject(cam); - return { x: vec.x, z: vec.z }; - }, - [], - ); - - // Shared click handler core - const handleMinimapClick = useCallback( - (clientX: number, clientY: number) => { - // Debounce: drop clicks within 150ms to prevent moveRequest flooding - const now = performance.now(); - if (now - lastClickTimeRef.current < 150) return; - lastClickTimeRef.current = now; - - const worldPos = screenToWorldXZ(clientX, clientY); - if (!worldPos) return; - - const player = world.entities?.player as - | { position?: { x: number; z: number }; runMode?: boolean } - | undefined; - if (!player?.position) return; - const dx = worldPos.x - player.position.x; - const dz = worldPos.z - player.position.z; - const dist = Math.hypot(dx, dz); - let targetX = worldPos.x; - let targetZ = worldPos.z; - if (dist > INPUT.MAX_CLICK_DISTANCE_TILES) { - const scale = INPUT.MAX_CLICK_DISTANCE_TILES / dist; - targetX = player.position.x + dx * scale; - targetZ = player.position.z + dz * scale; - } - - const terrainSystem = world.getSystem("terrain") as unknown as - | TerrainSystemLike - | null - | undefined; - let targetY = 0; - if (terrainSystem?.getHeightAt) { - const h = terrainSystem.getHeightAt(targetX, targetZ); - targetY = (Number.isFinite(h) ? h : 0) + 0.1; - } - - // Send server-authoritative move request instead of local movement - const currentRun = (player as { runMode?: boolean }).runMode === true; - (world as unknown as WorldNetworkSend).network.send("moveRequest", { - target: [targetX, targetY, targetZ], - runMode: currentRun, - cancel: false, - }); - - // Persist destination until arrival (no auto-fade) - lastDestinationWorldRef.current = { x: targetX, z: targetZ }; - // Expose same diagnostic target used by world clicks so minimap renders dot identically - (window as HyperscapeWindow).__lastRaycastTarget = { - x: targetX, - y: targetY, - z: targetZ, - method: "minimap", - }; - }, - [screenToWorldXZ, world], - ); - - const onOverlayClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - handleMinimapClick(e.clientX, e.clientY); - }, - [handleMinimapClick], - ); - - // Stable prevent-default-only handler β€” no deps, never recreated - const onPreventDefault = useCallback( - (e: React.SyntheticEvent) => e.preventDefault(), - [], - ); - - // Stable stop-propagation + prevent-default handler for canvas events - const onStopAndPrevent = useCallback((e: React.SyntheticEvent) => { - e.preventDefault(); - e.stopPropagation(); - }, []); - - // Collapse button click β€” same as toggleCollapse but also swallows the event - const onCollapseButtonClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - toggleCollapse(); - }, - [toggleCollapse], - ); - - // Wheel handler for minimap zoom - uses native WheelEvent for passive: false support - // Uses functional update to ensure correct extent value during rapid scrolling - // No dependencies - handler is stable and listener doesn't need to be re-attached - const handleWheel = useCallback( - (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); - const sign = Math.sign(e.deltaY); - if (sign === 0) return; - // Notched steps for smooth zoom - const steps = Math.max( - 1, - Math.min(5, Math.round(Math.abs(e.deltaY) / 100)), - ); - // Use functional update to always have the latest extent value - setTargetExtent((prev) => - THREE.MathUtils.clamp( - prev + sign * steps * STEP_EXTENT, - MIN_EXTENT, - MAX_EXTENT, - ), - ); - }, - [], // No dependencies - uses functional update - ); - - // Attach wheel listener with { passive: false } to allow preventDefault() - // React's onWheel is passive by default, causing "Unable to preventDefault" errors - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - container.addEventListener("wheel", handleWheel, { passive: false }); - - return () => { - container.removeEventListener("wheel", handleWheel); - }; - }, [handleWheel]); - - // SE corner drag handler β€” widens right and down (matching the only rendered handle) - const handleResizeStart = useCallback( - (e: React.PointerEvent) => { - if (!resizable) return; - e.preventDefault(); - e.stopPropagation(); - - setIsResizing(true); - resizeStartRef.current = { - x: e.clientX, - y: e.clientY, - w: width, - h: height, - }; - - const handleMove = (moveEvent: PointerEvent) => { - if (!resizeStartRef.current) return; - - const dx = moveEvent.clientX - resizeStartRef.current.x; - const dy = moveEvent.clientY - resizeStartRef.current.y; - const newW = resizeStartRef.current.w + dx; - const newH = resizeStartRef.current.h + dy; - - // Clamp to bounds independently for width and height - const effectiveMaxSize = maxSize ?? Infinity; - const clampedW = Math.max( - minSize, - Math.min(effectiveMaxSize, Math.round(newW / 8) * 8), - ); - const clampedH = Math.max( - minSize, - Math.min(effectiveMaxSize, Math.round(newH / 8) * 8), - ); - setCurrentWidth(clampedW); - setCurrentHeight(clampedH); - // Write latest clamped size into the ref so handleUp always sees the - // post-drag final size, not the stale closure-captured initial values. - latestSizeRef.current = { w: clampedW, h: clampedH }; - }; - - const cleanupResize = () => { - window.removeEventListener("pointermove", handleMove); - window.removeEventListener("pointerup", handleUp); - resizeCleanupRef.current = null; - }; - - const handleUp = () => { - setIsResizing(false); - resizeStartRef.current = null; - // Read from ref β€” immune to stale closure over currentWidth/currentHeight - onSizeChange?.(latestSizeRef.current.w, latestSizeRef.current.h); - cleanupResize(); - }; - - window.addEventListener("pointermove", handleMove); - window.addEventListener("pointerup", handleUp); - resizeCleanupRef.current = cleanupResize; - }, - [resizable, width, height, minSize, maxSize, onSizeChange], - ); - - // Render collapsed state as a 32x32 icon + // ── Collapsed state render ─────────────────────────────────────────────────── if (collapsible && isCollapsed) { return (
- {/* Player direction arrow in collapsed state β€” rotated via direct DOM write in RAF */} { compassRef.current = el; @@ -1710,6 +568,7 @@ function MinimapInner({ ); } + // ── Expanded state render ──────────────────────────────────────────────────── return (
- {/* 3D canvas */} + {/* Terrain canvas */} - {/* Resize handles (SE corner only for simplicity) */} + {/* Resize handle (SE corner) */} {resizable && (
)} - {/* Edit mode drag overlay - makes the entire minimap content draggable */} - {/* This is positioned INSIDE the edges so resize handles remain accessible */} - {/* Corners (12px) and edges (8px) are reserved for resize, interior is for drag */} + {/* Edit mode drag overlay */} {isUnlocked && dragHandleProps && (
)} - {/* Collapse button (top-right) - only shown when collapsible */} + {/* Collapse button (top-right) */} {collapsible && ( -
- ); -}; - -/** Combat stats row with SVG icons - compact */ -const CombatStatsRow = React.memo(function CombatStatsRow({ - attackLevel, - strengthLevel, - defenseLevel, - isMobile, -}: { - attackLevel: number; - strengthLevel: number; - defenseLevel: number; - isMobile: boolean; -}) { - const theme = useThemeStore((s) => s.theme); - const stats: Array<{ - key: "attack" | "strength" | "defense"; - value: number; - color: string; - }> = [ - { key: "attack", value: attackLevel, color: "#ef4444" }, - { key: "strength", value: strengthLevel, color: "#22c55e" }, - { key: "defense", value: defenseLevel, color: "#3b82f6" }, - ]; - - return ( -
- {stats.map((stat, index) => ( - - {index > 0 && ( -
- )} -
- - - {stat.value} - -
- - ))} -
- ); -}); - -// Event data interfaces for type-safe event handling -interface StyleUpdateEvent { - playerId: string; - currentStyle: { id: string }; -} - -interface TargetChangedEvent { - targetId: string | null; - targetName?: string; - targetHealth?: PlayerHealth; -} - -interface TargetHealthEvent { - targetId: string; - health: PlayerHealth; -} - -interface AutoRetaliateEvent { - playerId: string; - enabled: boolean; -} - -// Type guards for runtime validation -function isStyleUpdateEvent(data: unknown): data is StyleUpdateEvent { - if (typeof data !== "object" || data === null) return false; - const d = data as Record; - return ( - typeof d.playerId === "string" && - typeof d.currentStyle === "object" && - d.currentStyle !== null && - typeof (d.currentStyle as Record).id === "string" - ); -} - -function isTargetChangedEvent(data: unknown): data is TargetChangedEvent { - if (typeof data !== "object" || data === null) return false; - const d = data as Record; - return d.targetId === null || typeof d.targetId === "string"; -} - -function isTargetHealthEvent(data: unknown): data is TargetHealthEvent { - if (typeof data !== "object" || data === null) return false; - const d = data as Record; - return ( - typeof d.targetId === "string" && - typeof d.health === "object" && - d.health !== null - ); -} +// Client-side cache for combat style state (persists across panel opens/closes) +// This enables instant display when reopening panel (RuneScape pattern) +const combatStyleCache = new Map(); +const autoRetaliateCache = new Map(); +const VALID_WEAPON_TYPES = new Set(Object.values(WeaponType)); -function isAutoRetaliateEvent(data: unknown): data is AutoRetaliateEvent { - if (typeof data !== "object" || data === null) return false; - const d = data as Record; - return typeof d.playerId === "string" && typeof d.enabled === "boolean"; -} +/** All possible combat styles with their XP training info and colors (OSRS-accurate) */ +const ALL_STYLES: CombatStyleInfo[] = [ + // Melee styles + { id: "accurate", label: "Accurate", xp: "Attack", color: "#ef4444" }, + { id: "aggressive", label: "Aggressive", xp: "Strength", color: "#22c55e" }, + { id: "defensive", label: "Defensive", xp: "Defense", color: "#3b82f6" }, + { id: "controlled", label: "Controlled", xp: "All", color: "#a855f7" }, + // Ranged styles + { id: "rapid", label: "Rapid", xp: "Ranged", color: "#f59e0b" }, + { id: "longrange", label: "Longrange", xp: "Rng+Def", color: "#06b6d4" }, + // Magic styles + { id: "autocast", label: "Autocast", xp: "Magic", color: "#8b5cf6" }, +]; interface CombatPanelProps { world: ClientWorld; @@ -848,30 +64,21 @@ interface CombatPanelProps { equipment: PlayerEquipmentItems | null; } -// Client-side cache for combat style state (persists across panel opens/closes) -// This enables instant display when reopening panel (RuneScape pattern) -const combatStyleCache = new Map(); -const autoRetaliateCache = new Map(); -const VALID_WEAPON_TYPES = new Set(Object.values(WeaponType)); - export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { const theme = useThemeStore((s) => s.theme); const { shouldUseMobileUI } = useMobileLayout(); const panelRef = useRef(null); const [panelSize, setPanelSize] = useState({ width: 280, height: 360 }); + // Initialize from cache if available, otherwise default to "accurate" - // Check order: module cache > network cache > default const [style, setStyle] = useState(() => { const playerId = world.entities?.player?.id; - // 1. Check module cache (for instant display on panel reopen) if (playerId && combatStyleCache.has(playerId)) { return combatStyleCache.get(playerId)!; } - // 2. Check network cache (for fresh page loads - packet arrived before UI mounted) const networkCache = world.network?.lastAttackStyleByPlayerId?.[playerId || ""]; if (networkCache?.currentStyle?.id) { - // Also update module cache for future panel reopens if (playerId) { combatStyleCache.set(playerId, networkCache.currentStyle.id); } @@ -879,50 +86,53 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { } return "accurate"; }); + const [cooldown, setCooldown] = useState(0); const [targetName, setTargetName] = useState(null); const [targetHealth, setTargetHealth] = useState(null); + // Auto-retaliate state (OSRS default is ON) const [autoRetaliate, setAutoRetaliate] = useState(() => { const player = world.entities?.player; const playerId = player?.id; - // First check cache (for instant display on panel reopen) if (playerId && autoRetaliateCache.has(playerId)) { return autoRetaliateCache.get(playerId)!; } - // Read directly from player entity (set from server data during entity creation) const playerCombat = (player as { combat?: { autoRetaliate?: boolean } }) ?.combat; if (typeof playerCombat?.autoRetaliate === "boolean") { return playerCombat.autoRetaliate; } - return true; // OSRS default: ON + return true; }); + const playerId = world.entities?.player?.id ?? null; const previousPlayerIdRef = useRef(null); - // Calculate combat level using OSRS formula (melee-only MVP) const combatLevel = stats?.skills - ? (() => { - const s = stats.skills; - const base = - 0.25 * ((s.defense?.level || 1) + (s.constitution?.level || 10)); - const melee = - 0.325 * ((s.attack?.level || 1) + (s.strength?.level || 1)); - return Math.floor(base + melee); - })() + ? calculateCombatLevel( + normalizeCombatSkills({ + attack: stats.skills.attack?.level, + strength: stats.skills.strength?.level, + defense: stats.skills.defense?.level, + constitution: stats.skills.constitution?.level, + ranged: stats.skills.ranged?.level, + magic: stats.skills.magic?.level, + prayer: stats.skills.prayer?.level, + }), + ) : 1; + const inCombat = stats?.inCombat || false; - const health = stats?.health || { current: 100, max: 100 }; + const health = stats?.health ?? { current: 0, max: 1 }; const attackLevel = stats?.skills?.attack?.level || 1; const strengthLevel = stats?.skills?.strength?.level || 1; const defenseLevel = stats?.skills?.defense?.level || 1; + // Sync state from server events useEffect(() => { if (!playerId) return; - // Immediately sync from network cache (handles fresh page loads) - // The packet may have arrived before this component mounted const networkCache = world.network?.lastAttackStyleByPlayerId?.[playerId]; if (networkCache?.currentStyle?.id) { combatStyleCache.set(playerId, networkCache.currentStyle.id); @@ -945,7 +155,6 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { playerId, (info: { style: string; cooldown?: number }) => { if (info) { - // Update cache for instant display on panel reopen combatStyleCache.set(playerId, info.style); setStyle(info.style); setCooldown(info.cooldown || 0); @@ -953,7 +162,6 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { }, ); - // Initialize auto-retaliate state from server actions?.actionMethods?.getAutoRetaliate?.(playerId, (enabled: boolean) => { autoRetaliateCache.set(playerId, enabled); setAutoRetaliate(enabled); @@ -962,19 +170,16 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { const onUpdate = (data: unknown) => { if (!isStyleUpdateEvent(data)) return; if (data.playerId !== playerId) return; - // Update cache for instant display on panel reopen combatStyleCache.set(playerId, data.currentStyle.id); setStyle(data.currentStyle.id); }; const onChanged = (data: unknown) => { if (!isStyleUpdateEvent(data)) return; if (data.playerId !== playerId) return; - // Update cache for instant display on panel reopen combatStyleCache.set(playerId, data.currentStyle.id); setStyle(data.currentStyle.id); }; - // Listen for combat target updates const onTargetChanged = (data: unknown) => { if (!isTargetChangedEvent(data)) return; if (data.targetId) { @@ -993,7 +198,6 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { } }; - // Listen for auto-retaliate changes from server const onAutoRetaliateChanged = (data: unknown) => { if (!isAutoRetaliateEvent(data)) return; if (data.playerId !== playerId) return; @@ -1049,6 +253,7 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { }; }, [playerId, targetName, world]); + // Clean up cache when player changes useEffect(() => { const previousPlayerId = previousPlayerIdRef.current; if (previousPlayerId && previousPlayerId !== playerId) { @@ -1059,8 +264,8 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { }, [playerId]); const changeStyle = (next: string) => { - const playerId = world.entities?.player?.id; - if (!playerId) return; + const currentPlayerId = world.entities?.player?.id; + if (!currentPlayerId) return; const actions = world.getSystem("actions") as { actionMethods?: { @@ -1071,12 +276,10 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { if (!actions?.actionMethods?.changeAttackStyle) return; // Optimistic: update UI instantly (OSRS has zero visible delay) - combatStyleCache.set(playerId, next); + combatStyleCache.set(currentPlayerId, next); setStyle(next); - // Send to server β€” server confirms via attackStyleChanged packet, - // which will overwrite our optimistic value with the authoritative one - actions.actionMethods.changeAttackStyle(playerId, next); + actions.actionMethods.changeAttackStyle(currentPlayerId, next); // OSRS-accurate: selecting autocast opens the spells panel for spell selection if (next === "autocast") { @@ -1117,8 +320,8 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { }; const toggleAutoRetaliate = () => { - const playerId = world.entities?.player?.id; - if (!playerId) return; + const currentPlayerId = world.entities?.player?.id; + if (!currentPlayerId) return; const actions = world.getSystem("actions") as { actionMethods?: { @@ -1129,71 +332,11 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { if (!actions?.actionMethods?.setAutoRetaliate) return; const newValue = !autoRetaliate; - - // Optimistic: update UI instantly (OSRS has zero visible delay) - autoRetaliateCache.set(playerId, newValue); + autoRetaliateCache.set(currentPlayerId, newValue); setAutoRetaliate(newValue); - - // Send to server β€” server confirms via autoRetaliateChanged packet, - // which will overwrite our optimistic value with the authoritative one - actions.actionMethods.setAutoRetaliate(playerId, newValue); + actions.actionMethods.setAutoRetaliate(currentPlayerId, newValue); }; - // All possible combat styles with their XP training info and colors - // Includes melee, ranged, and magic styles (OSRS-accurate) - const allStyles: Array<{ - id: string; - label: string; - xp: string; - color: string; - }> = [ - // Melee styles - { - id: "accurate", - label: "Accurate", - xp: "Attack", - color: "#ef4444", - }, - { - id: "aggressive", - label: "Aggressive", - xp: "Strength", - color: "#22c55e", - }, - { - id: "defensive", - label: "Defensive", - xp: "Defense", - color: "#3b82f6", - }, - { - id: "controlled", - label: "Controlled", - xp: "All", - color: "#a855f7", - }, - // Ranged styles - { - id: "rapid", - label: "Rapid", - xp: "Ranged", - color: "#f59e0b", - }, - { - id: "longrange", - label: "Longrange", - xp: "Rng+Def", - color: "#06b6d4", - }, - // Magic styles - { - id: "autocast", - label: "Autocast", - xp: "Magic", - color: "#8b5cf6", - }, - ]; - // Filter styles based on equipped weapon (OSRS-accurate restrictions) const styles = useMemo(() => { const normalizedWeaponType = equipment?.weapon?.weaponType?.toLowerCase(); @@ -1203,16 +346,12 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { : WeaponType.NONE : WeaponType.NONE; const availableStyleIds = getAvailableStyles(weaponType); - return allStyles.filter((s) => + return ALL_STYLES.filter((s) => (availableStyleIds as readonly string[]).includes(s.id), ); }, [equipment?.weapon?.weaponType]); - const healthPercent = Math.round((health.current / health.max) * 100); - const targetHealthPercent = targetHealth - ? Math.round((targetHealth.current / targetHealth.max) * 100) - : 0; - + // Responsive panel sizing via ResizeObserver useEffect(() => { const element = panelRef.current; if (!element || typeof ResizeObserver === "undefined") return; @@ -1230,8 +369,6 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { return () => observer.disconnect(); }, []); - // Responsive padding/sizing β€” built from shared panelLayout constants - // compact = mobile or small panel; outer/inner/gap scale accordingly const compactPanel = shouldUseMobileUI || panelSize.height < 330 || panelSize.width < 250; const ultraCompactPanel = panelSize.height < 290 || panelSize.width < 220; @@ -1242,8 +379,7 @@ export function CombatPanel({ world, stats, equipment }: CombatPanelProps) { gap: PANEL_MOBILE_PADDING, } : { outer: PANEL_PADDING, inner: PANEL_PADDING + 2, gap: PANEL_GRID_GAP }; - // ultraCompactPanel is available for future use if needed (currently unused) - void ultraCompactPanel; + return (
- {/* Attack Styles β€” Banner Grid (top) */} + {/* Attack Styles + Bonuses (top section) */}
-
- - Combat Styles - - {style === "autocast" && ( - - Autocast - - )} -
-
- {styles.map((s) => ( - 0} - isMobile={compactPanel} - onClick={() => changeStyle(s.id)} - theme={theme} - /> - ))} -
- - {cooldown > 0 && ( -
- Style change in {Math.ceil(cooldown / 1000)}s -
- )} - - {/* HP + Combat Level */} -
- - - - {/* HP bar inline */} -
-
-
-
-
- - {health.current}/{health.max} - - {inCombat && ( - - βš” - - )} - - Lvl{" "} - - {combatLevel} - - -
- - {/* Target health β€” only when in combat */} - {targetName && targetHealth && !ultraCompactPanel && ( -
- - 🎯 {targetName} - -
-
-
-
-
- - {targetHealth.current}/{targetHealth.max} - -
- )} + - {/* Stats Row */} -
{/* Spacer to push auto-retaliate to bottom */}
- {/* Auto Retaliate β€” pinned to bottom */} - + {/* Auto Retaliate -- pinned to bottom */} +
); } diff --git a/packages/client/src/game/panels/DuelPanel/DuelHUD.tsx b/packages/client/src/game/panels/DuelPanel/DuelHUD.tsx index 5a02ae340..fa90ee74e 100644 --- a/packages/client/src/game/panels/DuelPanel/DuelHUD.tsx +++ b/packages/client/src/game/panels/DuelPanel/DuelHUD.tsx @@ -18,6 +18,7 @@ import { type CSSProperties, } from "react"; import { useThemeStore } from "@/ui"; +import { getHpPercent, getHpColor } from "@hyperscape/shared"; import { UI } from "@/ui/core"; import type { DuelRules } from "@hyperscape/shared"; @@ -117,9 +118,9 @@ export function DuelHUD({ state, onForfeit }: DuelHUDProps) { if (!state.visible) return null; - const healthPercent = Math.max( - 0, - Math.min(100, (state.opponentHealth / state.opponentMaxHealth) * 100), + const healthPercent = getHpPercent( + state.opponentHealth, + state.opponentMaxHealth, ); // Get active rules for display @@ -133,12 +134,14 @@ export function DuelHUD({ state, onForfeit }: DuelHUDProps) { // Can forfeit? const canForfeit = !state.rules.noForfeit; - // Health bar color based on percentage - const getHealthColor = (percent: number): string => { - if (percent > 50) return theme.colors.state.success; - if (percent > 25) return theme.colors.state.warning; - return theme.colors.state.danger; + // Map OSRS HP color to theme semantic colors + const hpColorToTheme: Record = { + "#22c55e": theme.colors.state.success, + "#eab308": theme.colors.state.warning, + "#ef4444": theme.colors.state.danger, }; + const getHealthColor = (percent: number): string => + hpColorToTheme[getHpColor(percent)] ?? theme.colors.state.danger; // Styles const containerStyle: CSSProperties = { diff --git a/packages/client/src/game/panels/PrayerPanel.tsx b/packages/client/src/game/panels/PrayerPanel.tsx index d297600ad..2c765e7e1 100644 --- a/packages/client/src/game/panels/PrayerPanel.tsx +++ b/packages/client/src/game/panels/PrayerPanel.tsx @@ -53,36 +53,12 @@ import { import type { PlayerStats, ClientWorld } from "../../types"; import { EventType, - type PrayerStateSyncPayload, - type PrayerToggledEvent, + isPrayerStateSyncPayload, + isPrayerToggledPayload, type PrayerDefinition, prayerDataProvider, } from "@hyperscape/shared"; -// Type guards for prayer events -function isPrayerStateSyncPayload( - data: unknown, -): data is PrayerStateSyncPayload { - if (typeof data !== "object" || data === null) return false; - const obj = data as Record; - return ( - typeof obj.playerId === "string" && - typeof obj.points === "number" && - typeof obj.maxPoints === "number" && - Array.isArray(obj.active) - ); -} - -function isPrayerToggledPayload(data: unknown): data is PrayerToggledEvent { - if (typeof data !== "object" || data === null) return false; - const obj = data as Record; - return ( - typeof obj.playerId === "string" && - typeof obj.prayerId === "string" && - typeof obj.active === "boolean" - ); -} - // Prayer panel layout constants β€” use shared sizing tokens from panelLayout.ts // to ensure consistency across Prayer, Spells, Skills, and Inventory panels. const PRAYER_ICON_SIZE = PANEL_ICON_SIZE; // 36px desktop icon size diff --git a/packages/client/src/game/panels/SkillsPanel.tsx b/packages/client/src/game/panels/SkillsPanel.tsx index 3a58333aa..2c678b0db 100644 --- a/packages/client/src/game/panels/SkillsPanel.tsx +++ b/packages/client/src/game/panels/SkillsPanel.tsx @@ -31,6 +31,9 @@ import type { PlayerStats, Skills } from "../../types"; import { SKILL_DEFINITIONS, getUnlocksForSkill, + calculateCombatLevel, + normalizeCombatSkills, + getXPForLevel, type SkillDefinition, } from "@hyperscape/shared"; import { SkillGuidePanel } from "./SkillGuidePanel"; @@ -47,25 +50,6 @@ interface Skill { xp: number; } -function calculateXPForLevel(level: number): number { - let total = 0; - for (let i = 1; i < level; i++) { - total += Math.floor(i + 300 * Math.pow(2, i / 7)); - } - return Math.floor(total / 4); -} - -/** Calculate combat level from skill stats */ -function calculateCombatLevel(stats: Partial): number { - const attack = stats.attack?.level ?? 1; - const strength = stats.strength?.level ?? 1; - const defense = stats.defense?.level ?? 1; - const constitution = stats.constitution?.level ?? 10; - return Math.floor( - 0.25 * (defense + constitution) + 0.325 * (attack + strength), - ); -} - /** Draggable skill card component for action bar drag-drop */ // Memoized to prevent re-renders of all skill cards when any changes const DraggableSkillCard = memo(function DraggableSkillCard({ @@ -254,7 +238,17 @@ export function SkillsPanel({ stats }: SkillsPanelProps) { const totalLevel = skills.reduce((sum, skill) => sum + skill.level, 0); const totalXP = skills.reduce((sum, skill) => sum + skill.xp, 0); - const combatLevel = calculateCombatLevel(s); + const combatLevel = calculateCombatLevel( + normalizeCombatSkills({ + attack: s.attack?.level, + strength: s.strength?.level, + defense: s.defense?.level, + constitution: s.constitution?.level, + ranged: s.ranged?.level, + magic: s.magic?.level, + prayer: s.prayer?.level, + }), + ); return (
{ - const currentLevelXP = calculateXPForLevel(hoveredSkill.level); - const nextLevelXP = calculateXPForLevel(hoveredSkill.level + 1); + const currentLevelXP = getXPForLevel(hoveredSkill.level); + const nextLevelXP = getXPForLevel(hoveredSkill.level + 1); const xpRemaining = nextLevelXP - hoveredSkill.xp; const xpIntoLevel = hoveredSkill.xp - currentLevelXP; const xpForThisLevel = nextLevelXP - currentLevelXP; diff --git a/packages/client/src/game/panels/StatsPanel.tsx b/packages/client/src/game/panels/StatsPanel.tsx index c6a50d30f..39ebe3161 100644 --- a/packages/client/src/game/panels/StatsPanel.tsx +++ b/packages/client/src/game/panels/StatsPanel.tsx @@ -12,6 +12,10 @@ import { useMemo } from "react"; import { useThemeStore } from "@/ui"; import { getPanelSurfaceStyle } from "@/ui/theme/themes"; +import { + calculateCombatLevel, + normalizeCombatSkills, +} from "@hyperscape/shared"; import type { PlayerEquipmentItems, PlayerStats } from "../../types"; // ============================================================================ @@ -188,12 +192,17 @@ export function StatsPanel({ const combatLevel = useMemo(() => { const skills = stats?.skills; if (!skills) return 1; - const base = - 0.25 * - ((skills.defense?.level || 1) + (skills.constitution?.level || 10)); - const melee = - 0.325 * ((skills.attack?.level || 1) + (skills.strength?.level || 1)); - return Math.floor(base + melee); + return calculateCombatLevel( + normalizeCombatSkills({ + attack: skills.attack?.level, + strength: skills.strength?.level, + defense: skills.defense?.level, + constitution: skills.constitution?.level, + ranged: skills.ranged?.level, + magic: skills.magic?.level, + prayer: skills.prayer?.level, + }), + ); }, [stats]); const panelStyle = { diff --git a/packages/client/src/game/panels/combat/AutoRetaliateToggle.tsx b/packages/client/src/game/panels/combat/AutoRetaliateToggle.tsx new file mode 100644 index 000000000..1c76d5a63 --- /dev/null +++ b/packages/client/src/game/panels/combat/AutoRetaliateToggle.tsx @@ -0,0 +1,113 @@ +/** + * Auto-Retaliate Toggle + * + * Renders the auto-retaliate on/off button with OSRS-style toggle indicator. + */ + +import React from "react"; +import { getInteractiveTileStyle } from "@/ui/theme/themes"; +import { PANEL_PADDING } from "../../../constants/panelLayout"; +import type { AutoRetaliateToggleProps } from "./types"; + +/** Auto-retaliate toggle button with on/off indicator */ +export const AutoRetaliateToggle = React.memo(function AutoRetaliateToggle({ + enabled, + onToggle, + theme, +}: AutoRetaliateToggleProps) { + return ( + + ); +}); diff --git a/packages/client/src/game/panels/combat/CombatBonusesDisplay.tsx b/packages/client/src/game/panels/combat/CombatBonusesDisplay.tsx new file mode 100644 index 000000000..c7320f5b4 --- /dev/null +++ b/packages/client/src/game/panels/combat/CombatBonusesDisplay.tsx @@ -0,0 +1,263 @@ +/** + * Combat Bonuses Display + * + * Renders HP bar, combat level, target health bar, and stat row (ATK/STR/DEF). + */ + +import React from "react"; +import { getHpPercent } from "@hyperscape/shared"; +import { useThemeStore } from "@/ui"; +import { StatIcon } from "./StyleIcons"; +import type { CombatBonusesDisplayProps } from "./types"; + +/** Combat stats row with SVG icons - compact */ +const CombatStatsRow = React.memo(function CombatStatsRow({ + attackLevel, + strengthLevel, + defenseLevel, + isMobile, +}: { + attackLevel: number; + strengthLevel: number; + defenseLevel: number; + isMobile: boolean; +}) { + const theme = useThemeStore((s) => s.theme); + const stats: Array<{ + key: "attack" | "strength" | "defense"; + value: number; + color: string; + }> = [ + { key: "attack", value: attackLevel, color: "#ef4444" }, + { key: "strength", value: strengthLevel, color: "#22c55e" }, + { key: "defense", value: defenseLevel, color: "#3b82f6" }, + ]; + + return ( +
+ {stats.map((stat, index) => ( + + {index > 0 && ( +
+ )} +
+ + + {stat.value} + +
+ + ))} +
+ ); +}); + +/** HP bar, combat level, target health, and stat row */ +export const CombatBonusesDisplay = React.memo(function CombatBonusesDisplay({ + health, + combatLevel, + inCombat, + attackLevel, + strengthLevel, + defenseLevel, + targetName, + targetHealth, + compactPanel, + ultraCompactPanel, + isMobile, + innerPadding, + theme, +}: CombatBonusesDisplayProps) { + const healthPercent = getHpPercent(health.current, health.max); + const targetHealthPercent = targetHealth + ? getHpPercent(targetHealth.current, targetHealth.max) + : 0; + + return ( + <> + {/* HP + Combat Level */} +
+ + + + {/* HP bar inline */} +
+
+
+
+
+ + {health.current}/{health.max} + + {inCombat && ( + + βš” + + )} + + Lvl{" "} + + {combatLevel} + + +
+ + {/* Target health -- only when in combat */} + {targetName && targetHealth && !ultraCompactPanel && ( +
+ + 🎯 {targetName} + +
+
+
+
+
+ + {targetHealth.current}/{targetHealth.max} + +
+ )} + + {/* Stats Row */} + + + ); +}); diff --git a/packages/client/src/game/panels/combat/CombatStyleSelector.tsx b/packages/client/src/game/panels/combat/CombatStyleSelector.tsx new file mode 100644 index 000000000..52e7d5011 --- /dev/null +++ b/packages/client/src/game/panels/combat/CombatStyleSelector.tsx @@ -0,0 +1,370 @@ +/** + * Combat Style Selector + * + * Renders the banner grid of combat style options with heraldic shield icons. + * Includes the draggable CombatStyleBanner sub-component and autocast badge. + */ + +import React from "react"; +import { useDraggable } from "@dnd-kit/core"; +import { getPanelInsetStyle } from "@/ui/theme/themes"; +import type { Theme } from "@/ui/theme/themes"; +import { + BannerStyleIcon, + SHIELD_OUTER, + SHIELD_INNER, + XP_SHORT_LABELS, +} from "./StyleIcons"; +import type { CombatStyleInfo, CombatStyleSelectorProps } from "./types"; + +/** Single combat style banner -- heraldic shield using theme colors */ +const CombatStyleBanner = ({ + style: styleInfo, + isActive, + disabled, + isMobile, + onClick, + theme, +}: { + style: CombatStyleInfo; + isActive: boolean; + disabled: boolean; + isMobile: boolean; + onClick: () => void; + theme: Theme; +}) => { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `combatstyle-${styleInfo.id}`, + data: { + combatStyle: { + id: styleInfo.id, + label: styleInfo.label, + color: styleInfo.color, + }, + source: "combatstyle", + }, + disabled, + }); + + const shortXp = + XP_SHORT_LABELS[styleInfo.xp] || + `+${styleInfo.xp.slice(0, 3).toUpperCase()}`; + const baseGradId = `banner-base-${styleInfo.id}`; + const tintGradId = `banner-tint-${styleInfo.id}`; + + // Theme-derived colors + const bgDark = theme.colors.background.primary; + const bgMid = theme.colors.background.secondary; + const bgLight = theme.colors.background.tertiary; + const accentGold = theme.colors.accent.primary; + const borderClr = theme.colors.border.default; + const textMuted = theme.colors.text.muted; + const textPrimary = theme.colors.text.primary; + + return ( +
+ +
+ ); +}; + +/** Combat style selector section with banner grid, header, and cooldown indicator */ +export const CombatStyleSelector = React.memo(function CombatStyleSelector({ + styles, + activeStyleId, + cooldown, + compactPanel, + theme, + onStyleChange, +}: CombatStyleSelectorProps) { + return ( + <> +
+ + Combat Styles + + {activeStyleId === "autocast" && ( + + Autocast + + )} +
+
+ {styles.map((s) => ( + 0} + isMobile={compactPanel} + onClick={() => onStyleChange(s.id)} + theme={theme} + /> + ))} +
+ + {cooldown > 0 && ( +
+ Style change in {Math.ceil(cooldown / 1000)}s +
+ )} + + ); +}); diff --git a/packages/client/src/game/panels/combat/SpecialAttackBar.tsx b/packages/client/src/game/panels/combat/SpecialAttackBar.tsx new file mode 100644 index 000000000..08fdf8fb1 --- /dev/null +++ b/packages/client/src/game/panels/combat/SpecialAttackBar.tsx @@ -0,0 +1,93 @@ +/** + * Special Attack Bar + * + * Displays the special attack energy bar with fill indicator. + * OSRS-style special attack energy (0-100%) with themed styling. + */ + +import React from "react"; +import { getPanelInsetStyle } from "@/ui/theme/themes"; +import type { SpecialAttackBarProps } from "./types"; + +/** Special attack energy bar with percentage fill */ +export function SpecialAttackBar({ + specialEnergy, + theme, + compactPanel, +}: SpecialAttackBarProps) { + const energyPercent = Math.min(100, Math.max(0, specialEnergy)); + const hasEnough = energyPercent >= 25; + + return ( +
+ + + +
+
+
+
+
+ + {energyPercent}% + + + Spec + +
+ ); +} diff --git a/packages/client/src/game/panels/combat/StyleIcons.tsx b/packages/client/src/game/panels/combat/StyleIcons.tsx new file mode 100644 index 000000000..675da185b --- /dev/null +++ b/packages/client/src/game/panels/combat/StyleIcons.tsx @@ -0,0 +1,443 @@ +/** + * Combat Style SVG Icons + * + * Contains all SVG icon components used in the combat panel: + * - StyleIcon: Outline icons for general use + * - StatIcon: Compact stat icons (attack/strength/defense) + * - BannerStyleIcon: Filled heraldic-style icons for combat banners + * - SHIELD_OUTER / SHIELD_INNER: SVG paths for shield crest shapes + */ + +import React from "react"; + +/** SVG outline icons for attack styles */ +export const StyleIcon = ({ + style, + size = 16, + color = "currentColor", +}: { + style: string; + size?: number; + color?: string; +}) => { + switch (style) { + case "accurate": + return ( + + + + + + ); + case "aggressive": + return ( + + + + + ); + case "defensive": + return ( + + + + ); + case "controlled": + return ( + + + + + ); + case "rapid": + return ( + + + + ); + case "longrange": + return ( + + + + + + + + ); + case "autocast": + return ( + + + + ); + default: + return null; + } +}; + +/** Stat icons as SVG for attack/strength/defense */ +export const StatIcon = ({ + stat, + size = 14, + color = "currentColor", +}: { + stat: "attack" | "strength" | "defense"; + size?: number; + color?: string; +}) => { + switch (stat) { + case "attack": + return ( + + + + + + + + ); + case "strength": + return ( + + + + + + + + + + + ); + case "defense": + return ( + + + + ); + } +}; + +/** Filled game-style icons for combat banners -- bold, solid fills for fantasy UI */ +export const BannerStyleIcon = ({ + style, + size = 24, + color = "currentColor", + muted = false, +}: { + style: string; + size?: number; + color?: string; + muted?: boolean; +}) => { + const fo = muted ? 0.25 : 0.55; + const so = muted ? 0.45 : 1; + + switch (style) { + case "accurate": + return ( + + + + + + + ); + case "aggressive": + return ( + + + + + ); + case "defensive": + return ( + + + + + ); + case "controlled": + return ( + + + + + + + ); + case "rapid": + return ( + + + + ); + case "longrange": + return ( + + + + + + + ); + case "autocast": + return ( + + + + ); + default: + return null; + } +}; + +/** Shield/crest SVG paths for combat banners */ +export const SHIELD_OUTER = + "M 5 0 L 95 0 Q 100 0 100 5 L 100 82 Q 100 102 50 128 Q 0 102 0 82 L 0 5 Q 0 0 5 0 Z"; +export const SHIELD_INNER = + "M 8 3 L 92 3 Q 97 3 97 7 L 97 80 Q 97 99 50 123 Q 3 99 3 80 L 3 7 Q 3 3 8 3 Z"; + +/** Short XP labels for compact banner display */ +export const XP_SHORT_LABELS: Record = { + Attack: "+ATK", + Strength: "+STR", + Defense: "+DEF", + All: "+ALL", + Ranged: "+RNG", + "Rng+Def": "+R/D", + Magic: "+MAG", +}; diff --git a/packages/client/src/game/panels/combat/index.ts b/packages/client/src/game/panels/combat/index.ts new file mode 100644 index 000000000..2216d70f1 --- /dev/null +++ b/packages/client/src/game/panels/combat/index.ts @@ -0,0 +1,35 @@ +/** + * Combat Panel Sub-Components + * + * Barrel export for all combat panel components and types. + */ + +export { + StyleIcon, + StatIcon, + BannerStyleIcon, + SHIELD_OUTER, + SHIELD_INNER, + XP_SHORT_LABELS, +} from "./StyleIcons"; +export { CombatStyleSelector } from "./CombatStyleSelector"; +export { CombatBonusesDisplay } from "./CombatBonusesDisplay"; +export { SpecialAttackBar } from "./SpecialAttackBar"; +export { AutoRetaliateToggle } from "./AutoRetaliateToggle"; +export { + isStyleUpdateEvent, + isTargetChangedEvent, + isTargetHealthEvent, + isAutoRetaliateEvent, +} from "./typeGuards"; +export type { + CombatStyleInfo, + CombatStyleSelectorProps, + CombatBonusesDisplayProps, + SpecialAttackBarProps, + AutoRetaliateToggleProps, + StyleUpdateEvent, + TargetChangedEvent, + TargetHealthEvent, + AutoRetaliateEvent, +} from "./types"; diff --git a/packages/client/src/game/panels/combat/typeGuards.ts b/packages/client/src/game/panels/combat/typeGuards.ts new file mode 100644 index 000000000..f78708b99 --- /dev/null +++ b/packages/client/src/game/panels/combat/typeGuards.ts @@ -0,0 +1,49 @@ +/** + * Combat Panel Type Guards + * + * Runtime validation functions for combat event data. + */ + +import type { + StyleUpdateEvent, + TargetChangedEvent, + TargetHealthEvent, + AutoRetaliateEvent, +} from "./types"; + +export function isStyleUpdateEvent(data: unknown): data is StyleUpdateEvent { + if (typeof data !== "object" || data === null) return false; + const d = data as Record; + return ( + typeof d.playerId === "string" && + typeof d.currentStyle === "object" && + d.currentStyle !== null && + typeof (d.currentStyle as Record).id === "string" + ); +} + +export function isTargetChangedEvent( + data: unknown, +): data is TargetChangedEvent { + if (typeof data !== "object" || data === null) return false; + const d = data as Record; + return d.targetId === null || typeof d.targetId === "string"; +} + +export function isTargetHealthEvent(data: unknown): data is TargetHealthEvent { + if (typeof data !== "object" || data === null) return false; + const d = data as Record; + return ( + typeof d.targetId === "string" && + typeof d.health === "object" && + d.health !== null + ); +} + +export function isAutoRetaliateEvent( + data: unknown, +): data is AutoRetaliateEvent { + if (typeof data !== "object" || data === null) return false; + const d = data as Record; + return typeof d.playerId === "string" && typeof d.enabled === "boolean"; +} diff --git a/packages/client/src/game/panels/combat/types.ts b/packages/client/src/game/panels/combat/types.ts new file mode 100644 index 000000000..26b1299f2 --- /dev/null +++ b/packages/client/src/game/panels/combat/types.ts @@ -0,0 +1,79 @@ +/** + * Combat Panel Type Definitions + * + * Shared types used across combat sub-components. + */ + +import type { Theme } from "@/ui/theme/themes"; +import type { PlayerHealth } from "../../../types"; + +/** Individual combat style descriptor */ +export interface CombatStyleInfo { + id: string; + label: string; + xp: string; + color: string; +} + +/** Props for the combat style selector section */ +export interface CombatStyleSelectorProps { + styles: CombatStyleInfo[]; + activeStyleId: string; + cooldown: number; + compactPanel: boolean; + theme: Theme; + onStyleChange: (styleId: string) => void; +} + +/** Props for the combat bonuses / HP + level display */ +export interface CombatBonusesDisplayProps { + health: PlayerHealth; + combatLevel: number; + inCombat: boolean; + attackLevel: number; + strengthLevel: number; + defenseLevel: number; + targetName: string | null; + targetHealth: PlayerHealth | null; + compactPanel: boolean; + ultraCompactPanel: boolean; + isMobile: boolean; + innerPadding: number; + theme: Theme; +} + +/** Props for the special attack bar (future expansion) */ +export interface SpecialAttackBarProps { + specialEnergy: number; + theme: Theme; + compactPanel: boolean; +} + +/** Props for the auto-retaliate toggle */ +export interface AutoRetaliateToggleProps { + enabled: boolean; + onToggle: () => void; + theme: Theme; +} + +/** Event data interfaces for type-safe event handling */ +export interface StyleUpdateEvent { + playerId: string; + currentStyle: { id: string }; +} + +export interface TargetChangedEvent { + targetId: string | null; + targetName?: string; + targetHealth?: PlayerHealth; +} + +export interface TargetHealthEvent { + targetId: string; + health: PlayerHealth; +} + +export interface AutoRetaliateEvent { + playerId: string; + enabled: boolean; +} diff --git a/packages/client/src/game/systems/currency/currencyUtils.ts b/packages/client/src/game/systems/currency/currencyUtils.ts index 11f4af08f..04a00a871 100644 --- a/packages/client/src/game/systems/currency/currencyUtils.ts +++ b/packages/client/src/game/systems/currency/currencyUtils.ts @@ -6,6 +6,8 @@ * @packageDocumentation */ +import { MAX_COINS } from "@hyperscape/shared"; + /** Currency type identifier */ export type CurrencyType = "gold" | "silver" | "copper" | "gems" | "tokens"; @@ -39,7 +41,7 @@ export const DEFAULT_CURRENCIES: Record = { backgroundColor: "#3d3224", icon: "coins", conversionRate: 1, - maxValue: 2147483647, // Max int32 + maxValue: MAX_COINS, // Max int32 }, silver: { type: "silver", @@ -49,7 +51,7 @@ export const DEFAULT_CURRENCIES: Record = { backgroundColor: "#2a2a2a", icon: "coins", conversionRate: 0.01, // 100 silver = 1 gold - maxValue: 2147483647, + maxValue: MAX_COINS, }, copper: { type: "copper", @@ -59,7 +61,7 @@ export const DEFAULT_CURRENCIES: Record = { backgroundColor: "#2d1a0d", icon: "coins", conversionRate: 0.0001, // 10000 copper = 1 gold - maxValue: 2147483647, + maxValue: MAX_COINS, }, gems: { type: "gems", @@ -322,18 +324,6 @@ export function calculateBreakdown(totalCopper: number): { return { gold, silver, copper }; } -/** - * Convert breakdown back to total copper - */ -export function toTotalCopper(breakdown: { - gold?: number; - silver?: number; - copper?: number; -}): number { - const { gold = 0, silver = 0, copper = 0 } = breakdown; - return gold * 10000 + silver * 100 + copper; -} - /** * Format a breakdown as a display string */ @@ -359,23 +349,12 @@ export function formatBreakdown(breakdown: { /** * Format gold value for OSRS-style wealth display (K/M/B suffixes). - * Used by DuelPanel and TradePanel for stake/trade value indicators. + * Delegates to compactNumber to avoid duplicate formatting logic. */ export function formatGoldValue(value: number): string { - if (value < 1000) { - return value.toLocaleString(); - } else if (value < 1000000) { - const k = Math.floor(value / 1000); - const remainder = Math.floor((value % 1000) / 100); - return remainder > 0 ? `${k}.${remainder}K` : `${k}K`; - } else if (value < 1000000000) { - const m = Math.floor(value / 1000000); - const remainder = Math.floor((value % 1000000) / 100000); - return remainder > 0 ? `${m}.${remainder}M` : `${m}M`; - } else { - const b = Math.floor(value / 1000000000); - return `${b}B`; - } + if (value < 1000) return value.toLocaleString(); + const { value: v, suffix } = compactNumber(value); + return `${v}${suffix}`; } /** Change indicator for value changes */ diff --git a/packages/client/src/game/systems/currency/index.ts b/packages/client/src/game/systems/currency/index.ts index 6af7b89b6..bd084cfef 100644 --- a/packages/client/src/game/systems/currency/index.ts +++ b/packages/client/src/game/systems/currency/index.ts @@ -21,7 +21,6 @@ export { convertCurrency, validateAmount, calculateBreakdown, - toTotalCopper, formatBreakdown, getChangeIndicator, getChangeColor, diff --git a/packages/client/src/hooks/useModalPanels.ts b/packages/client/src/hooks/useModalPanels.ts index 13c3b845e..76f14b198 100644 --- a/packages/client/src/hooks/useModalPanels.ts +++ b/packages/client/src/hooks/useModalPanels.ts @@ -20,6 +20,7 @@ interface LegacyUIUpdatePayload { } interface DuelCompletedPayload { + duelId?: string; won?: boolean; opponentName?: string; itemsReceived?: InventoryItem[]; @@ -29,6 +30,12 @@ interface DuelCompletedPayload { forfeit?: boolean; } +function isDuelCompletedPayload(data: unknown): data is DuelCompletedPayload { + if (!data || typeof data !== "object") return false; + const obj = data as Record; + return typeof obj.won === "boolean"; +} + function normalizeDuelResultItems( items: InventoryItem[] | undefined, ): DuelResultData["itemsReceived"] { @@ -801,7 +808,9 @@ function handleLegacyDuelUIUpdate( } if (payload.component === "duelCompleted") { - const completedData = payload.data as unknown as DuelCompletedPayload; + const rawData: unknown = payload.data; + if (!isDuelCompletedPayload(rawData)) return; + const completedData = rawData; setDuelData(null); setDuelResultData({ visible: true, diff --git a/packages/client/src/screens/AgentMonitorScreen.tsx b/packages/client/src/screens/AgentMonitorScreen.tsx index f11ec5a45..c8030d734 100644 --- a/packages/client/src/screens/AgentMonitorScreen.tsx +++ b/packages/client/src/screens/AgentMonitorScreen.tsx @@ -7,6 +7,7 @@ import { GAME_API_URL } from "@/lib/api-config"; import React, { useEffect, useState, useCallback, useRef } from "react"; +import { getHpPercent, getHpColor, getXPForLevel } from "@hyperscape/shared"; import { Swords, RefreshCw, @@ -219,27 +220,6 @@ type DetailTab = | "actions" | "pipeline"; -// ─── XP Table ─────────────────────────────────────────────────────────────── - -const XP_TABLE = [ - 0, 83, 174, 276, 388, 512, 650, 801, 969, 1154, 1358, 1584, 1833, 2107, 2411, - 2746, 3115, 3523, 3973, 4470, 5018, 5624, 6291, 7028, 7842, 8740, 9730, 10824, - 12031, 13363, 14833, 16456, 18247, 20224, 22406, 24815, 27473, 30408, 33648, - 37224, 41171, 45529, 50339, 55649, 61512, 67983, 75127, 83014, 91721, 101333, - 111945, 123660, 136594, 150872, 166636, 184040, 203254, 224466, 247886, - 273742, 302288, 333804, 368599, 407015, 449428, 496254, 547953, 605032, - 668051, 737627, 814445, 899257, 992895, 1096278, 1210421, 1336443, 1475581, - 1629200, 1798808, 1986068, 2192818, 2421087, 2673114, 2951373, 3258594, - 3597792, 3972294, 4385776, 4842295, 5346332, 5902831, 6517253, 7195629, - 7944614, 8771558, 9684577, 10692629, 11805606, 13034431, -]; - -function xpForLevel(level: number): number { - if (level <= 1) return 0; - if (level > 99) return XP_TABLE[98] ?? 13034431; - return XP_TABLE[level - 1] ?? 0; -} - // ─── Helpers ──────────────────────────────────────────────────────────────── function formatDuration(ms: number): string { @@ -269,15 +249,10 @@ function formatTime(timestamp: number): string { }); } -function hpColor(ratio: number): string { - if (ratio > 0.6) return "#22c55e"; - if (ratio > 0.3) return "#eab308"; - return "#ef4444"; -} - function hpClass(ratio: number): string { - if (ratio > 0.6) return "high"; - if (ratio > 0.3) return "mid"; + const color = getHpColor(ratio * 100); + if (color === "#22c55e") return "high"; + if (color === "#eab308") return "mid"; return "low"; } @@ -669,7 +644,10 @@ function OverviewTab({ agent }: { agent: AgentData }) {
@@ -857,8 +835,8 @@ function SkillsTab({ agent }: { agent: AgentData }) { return (
{skillEntries.map(([name, skill]) => { - const currentLevelXp = xpForLevel(skill.level); - const nextLevelXp = xpForLevel(skill.level + 1); + const currentLevelXp = getXPForLevel(skill.level); + const nextLevelXp = getXPForLevel(skill.level + 1); const xpRange = nextLevelXp - currentLevelXp; const xpProgress = xpRange > 0 ? (skill.xp - currentLevelXp) / xpRange : 1; @@ -1800,8 +1778,8 @@ function PipelineNodeBody({ data }: { data: PipelineNodeData }) { switch (nodeType) { case "survival": { - const pct = data.maxHealth > 0 ? (data.health / data.maxHealth) * 100 : 0; - const color = pct < 25 ? "#ef4444" : pct < 50 ? "#eab308" : "#22c55e"; + const pct = getHpPercent(data.health, data.maxHealth); + const color = getHpColor(pct); return (
diff --git a/packages/client/src/ui/components/StatusBar.tsx b/packages/client/src/ui/components/StatusBar.tsx index d52fe7c81..b2c07e37c 100644 --- a/packages/client/src/ui/components/StatusBar.tsx +++ b/packages/client/src/ui/components/StatusBar.tsx @@ -8,6 +8,7 @@ */ import React, { memo, type CSSProperties } from "react"; +import { getHpColor } from "@hyperscape/shared"; import { useTheme } from "../stores/themeStore"; /** Status type */ @@ -160,16 +161,6 @@ export interface StatusOrbProps { style?: CSSProperties; } -/** - * Get label color based on HP percentage (OSRS-style) - * Green (>50%) -> Yellow (25-50%) -> Red (<25%) - */ -function getHpLabelColor(percent: number): string { - if (percent > 50) return "#22c55e"; // Green - if (percent > 25) return "#eab308"; // Yellow - return "#ef4444"; // Red -} - /** * Get status effect background color */ @@ -248,7 +239,7 @@ export const StatusOrb = memo(function StatusOrb({ // Determine label color const labelColor = dynamicLabelColor && type === "hp" - ? getHpLabelColor(percent) + ? getHpColor(percent) : theme.colors.text.primary; // Use muted, darker versions of status colors for the fill diff --git a/packages/server/src/systems/ServerNetwork/duel-settlement.ts b/packages/server/src/systems/ServerNetwork/duel-settlement.ts index 203feb2e0..909cee138 100644 --- a/packages/server/src/systems/ServerNetwork/duel-settlement.ts +++ b/packages/server/src/systems/ServerNetwork/duel-settlement.ts @@ -7,7 +7,7 @@ * Extracted from ServerNetwork to keep the main orchestrator lean. */ -import { getItem, World } from "@hyperscape/shared"; +import { getItem, World, MAX_COINS } from "@hyperscape/shared"; import { InventoryRepository } from "../../database/repositories/InventoryRepository"; import type { ServerSocket } from "../../shared/types"; @@ -399,7 +399,7 @@ async function executeDuelStakeTransfer( }; const existingSlot = existingRow.slotIndex; const existingQty = existingRow.quantity; - if (existingQty > 2147483647 - transferQuantity) { + if (existingQty > MAX_COINS - transferQuantity) { console.error( `[Duel] SECURITY: Stack merge would overflow! ` + `winnerId=${winnerId}, itemId=${stake.itemId}, ` + @@ -438,7 +438,7 @@ async function executeDuelStakeTransfer( id: string; quantity: number; }; - if (bankRow.quantity > 2147483647 - transferQuantity) { + if (bankRow.quantity > MAX_COINS - transferQuantity) { console.error( `[Duel] SECURITY: Bank stack merge would overflow! ` + `winnerId=${winnerId}, itemId=${stake.itemId}, ` + diff --git a/packages/shared/src/constants/BankingConstants.ts b/packages/shared/src/constants/BankingConstants.ts index 3361a0053..1e75a7991 100644 --- a/packages/shared/src/constants/BankingConstants.ts +++ b/packages/shared/src/constants/BankingConstants.ts @@ -2,6 +2,8 @@ * Banking system constants */ +import { MAX_COINS } from "../systems/shared/character/CoinPouchSystem"; + export const BANKING_CONSTANTS = Object.freeze({ // Bank sizes MAX_BANK_SLOTS: 480, // 12 tabs * 40 slots per tab @@ -16,7 +18,7 @@ export const BANKING_CONSTANTS = Object.freeze({ ITEMS_PER_ROW: 8, // Transaction limits - MAX_ITEM_STACK: 2147483647, // Max int32 + MAX_ITEM_STACK: MAX_COINS, MIN_ITEM_QUANTITY: 1, // Error messages diff --git a/packages/shared/src/constants/interaction.ts b/packages/shared/src/constants/interaction.ts index e01f5ce1b..5f2ba65cf 100644 --- a/packages/shared/src/constants/interaction.ts +++ b/packages/shared/src/constants/interaction.ts @@ -6,6 +6,7 @@ */ import { BANKING_CONSTANTS } from "./BankingConstants"; +import { MAX_COINS } from "../systems/shared/character/CoinPouchSystem"; // ============================================================================ // SESSION TYPES @@ -66,7 +67,7 @@ export const SESSION_CONFIG = { export const INPUT_LIMITS = { MAX_ITEM_ID_LENGTH: 64, MAX_STORE_ID_LENGTH: 64, - MAX_QUANTITY: 2_147_483_647, // Max signed 32-bit int + MAX_QUANTITY: MAX_COINS, MAX_INVENTORY_SLOTS: 28, /** Single source of truth: BankingConstants.ts */ MAX_BANK_SLOTS: BANKING_CONSTANTS.MAX_BANK_SLOTS, diff --git a/packages/shared/src/data/NoteGenerator.ts b/packages/shared/src/data/NoteGenerator.ts index eeb25b366..ba9699d81 100644 --- a/packages/shared/src/data/NoteGenerator.ts +++ b/packages/shared/src/data/NoteGenerator.ts @@ -20,6 +20,7 @@ import type { Item } from "../types/game/item-types"; import { ItemRarity } from "../types/entities"; +import { MAX_COINS } from "../systems/shared/character/CoinPouchSystem"; /** NOTE SUFFIX: Appended to base item ID to create noted variant */ export const NOTE_SUFFIX = "_noted"; @@ -111,7 +112,7 @@ export function generateNotedItem(baseItem: Item): Item { // Noted items are ALWAYS stackable stackable: true, - maxStackSize: 2147483647, // Max int32 + maxStackSize: MAX_COINS, // Notes are weightless (paper) weight: 0, diff --git a/packages/shared/src/extras/ui/imageCache.ts b/packages/shared/src/extras/ui/imageCache.ts index 5ae77414c..3a040340e 100644 --- a/packages/shared/src/extras/ui/imageCache.ts +++ b/packages/shared/src/extras/ui/imageCache.ts @@ -25,8 +25,12 @@ interface LoadedEntry { img: HTMLImageElement; } +/** Error TTL β€” retry after 30 seconds so transient CDN failures don't permanently block images */ +const ERROR_TTL_MS = 30_000; + interface ErrorEntry { status: "error"; + errorTime: number; } type CacheEntry = LoadingEntry | LoadedEntry | ErrorEntry; @@ -83,8 +87,11 @@ export function loadCachedImage( existing.callbacks.push(onLoad); return null; } - // Error β€” don't retry - return null; + // Error β€” retry after TTL expires + if (Date.now() - existing.errorTime < ERROR_TTL_MS) { + return null; + } + cache.delete(url); // TTL expired, fall through to reload } // Start new load @@ -112,7 +119,7 @@ export function loadCachedImage( }); img.addEventListener("error", () => { - const errorEntry: ErrorEntry = { status: "error" }; + const errorEntry: ErrorEntry = { status: "error", errorTime: Date.now() }; cache.set(url, errorEntry); entry.callbacks.length = 0; }); diff --git a/packages/shared/src/index.client.ts b/packages/shared/src/index.client.ts index 67fe7c2ee..f340186fb 100644 --- a/packages/shared/src/index.client.ts +++ b/packages/shared/src/index.client.ts @@ -763,3 +763,41 @@ export { getCombatLevelColor, getCombatLevelDescription, } from "./systems/client/interaction/utils/combatLevelColor"; + +// Combat level calculation (OSRS-accurate formula) +export { + calculateCombatLevel, + normalizeCombatSkills, + type CombatSkills, + type CombatType, + MIN_COMBAT_LEVEL, + MAX_COMBAT_LEVEL, +} from "./utils/game/CombatLevelCalculator"; + +// XP ↔ Level calculations (OSRS-accurate, standalone utilities for UI) +export { + getXPForLevel, + getLevelForXP, + getXPToNextLevel, + getXPProgress, +} from "./utils/game/XPCalculator"; + +// HP bar utilities (OSRS-style) +export { getHpPercent, getHpColor } from "./utils/game/CombatUtils"; + +// Currency constants (single source of truth for coin caps) +export { + MAX_COINS, + DEFAULT_STARTING_COINS, +} from "./systems/shared/character/CoinPouchSystem"; + +// Prayer event type guards (runtime validation for UI components) +export { + isPrayerStateSyncPayload, + isPrayerToggledPayload, +} from "./types/game/prayer-types"; +export type { + PrayerStateSyncPayload, + PrayerToggledEvent, + PrayerState, +} from "./types/game/prayer-types"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0d003905e..19424abcd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -264,6 +264,8 @@ export { PRAYER_TOGGLE_COOLDOWN_MS, PRAYER_TOGGLE_RATE_LIMIT, PRAYER_ID_PATTERN, + isPrayerStateSyncPayload, + isPrayerToggledPayload, } from "./types/game/prayer-types"; export type { PrayerCategory, @@ -1199,7 +1201,10 @@ export type { } from "./types/interaction"; // Context menu styled label type (for combat level colors) -export type { LabelSegment } from "./systems/client/interaction/types"; +export type { + LabelSegment, + ContextMenuAction, +} from "./systems/client/interaction/types"; // Bank equipment type guards, types, and constants export { @@ -1367,3 +1372,30 @@ export { getCombatLevelColor, getCombatLevelDescription, } from "./systems/client/interaction/utils/combatLevelColor"; + +// Combat level calculation (OSRS-accurate formula) +export { + calculateCombatLevel, + normalizeCombatSkills, + type CombatSkills, + type CombatType, + MIN_COMBAT_LEVEL, + MAX_COMBAT_LEVEL, +} from "./utils/game/CombatLevelCalculator"; + +// XP ↔ Level calculations (OSRS-accurate, standalone utilities for UI) +export { + getXPForLevel, + getLevelForXP, + getXPToNextLevel, + getXPProgress, +} from "./utils/game/XPCalculator"; + +// HP bar utilities (OSRS-style) +export { getHpPercent, getHpColor } from "./utils/game/CombatUtils"; + +// Currency constants (single source of truth for coin caps) +export { + MAX_COINS, + DEFAULT_STARTING_COINS, +} from "./systems/shared/character/CoinPouchSystem"; diff --git a/packages/shared/src/systems/shared/character/CoinPouchSystem.ts b/packages/shared/src/systems/shared/character/CoinPouchSystem.ts index 62267da92..f2dde99ed 100644 --- a/packages/shared/src/systems/shared/character/CoinPouchSystem.ts +++ b/packages/shared/src/systems/shared/character/CoinPouchSystem.ts @@ -30,10 +30,10 @@ import type { PlayerID } from "../../../types/core/identifiers"; import type { DatabaseSystem } from "../../../types/systems/system-interfaces"; /** Default starting coins for new players */ -const DEFAULT_STARTING_COINS = 100; +export const DEFAULT_STARTING_COINS = 100; -/** Maximum coins a player can hold (prevent overflow) */ -const MAX_COINS = 2147483647; // Max 32-bit signed integer (OSRS cap) +/** Maximum coins a player can hold (prevent overflow) β€” OSRS INT32 cap */ +export const MAX_COINS = 2147483647; /** * CoinPouchSystem - Manages player coin balances diff --git a/packages/shared/src/systems/shared/character/InventorySystem.ts b/packages/shared/src/systems/shared/character/InventorySystem.ts index be28092c3..83a96ea18 100644 --- a/packages/shared/src/systems/shared/character/InventorySystem.ts +++ b/packages/shared/src/systems/shared/character/InventorySystem.ts @@ -27,6 +27,7 @@ import { EntityManager } from ".."; import { SystemBase } from "../infrastructure/SystemBase"; import { Logger } from "../../../utils/Logger"; import type { DatabaseSystem } from "../../../types/systems/system-interfaces"; +import { MAX_COINS } from "./CoinPouchSystem"; import type { GroundItemSystem } from "../economy/GroundItemSystem"; import type { CoinPouchSystem } from "./CoinPouchSystem"; import { DeathState } from "../../../types/entities"; @@ -34,8 +35,8 @@ import { DeathState } from "../../../types/entities"; export class InventorySystem extends SystemBase { protected playerInventories = new Map(); private readonly MAX_INVENTORY_SLOTS = 28; - /** Max stackable quantity β€” matches PostgreSQL integer max to prevent DB truncation */ - private readonly MAX_QUANTITY = 2_147_483_647; + /** Max stackable quantity β€” uses shared MAX_COINS (PostgreSQL integer max) */ + private readonly MAX_QUANTITY = MAX_COINS; // Pickup locks to prevent race conditions when multiple players try to pickup same item private pickupLocks = new Set(); diff --git a/packages/shared/src/systems/shared/character/PlayerSystem.ts b/packages/shared/src/systems/shared/character/PlayerSystem.ts index 80e2be81b..40d37ef4a 100644 --- a/packages/shared/src/systems/shared/character/PlayerSystem.ts +++ b/packages/shared/src/systems/shared/character/PlayerSystem.ts @@ -62,6 +62,11 @@ import type { PlayerLeaveEvent, PlayerLevelUpEvent, } from "../../../types/events"; +import type { StatsComponent } from "../../../components/StatsComponent"; +import { + calculateCombatLevel, + normalizeCombatSkills, +} from "../../../utils/game/CombatLevelCalculator"; import { EventType } from "../../../types/events"; import type { World } from "../../../types/index"; import { Logger } from "../../../utils/Logger"; @@ -1478,14 +1483,22 @@ export class PlayerSystem extends SystemBase { player.health.current <= 0; } - // Update stats component health - const statsComponent = playerEntity.getComponent("stats"); - if (statsComponent && statsComponent.data && statsComponent.data.health) { - const healthData = statsComponent.data.health as { - current: number; - max: number; - }; - healthData.current = player.health.current; + // Update stats component health (both public property AND data dict) + // StatsComponent has TWO health objects: this.health (public) and this.data.health + // Both must stay in sync β€” handleLevelUp reads this.health, serialization reads this.data.health + const statsComponent = playerEntity.getComponent( + "stats", + ) as StatsComponent | null; + if (statsComponent) { + statsComponent.health.current = player.health.current; + statsComponent.health.max = player.health.max; + if (statsComponent.data?.health) { + ( + statsComponent.data.health as { current: number; max: number } + ).current = player.health.current; + (statsComponent.data.health as { current: number; max: number }).max = + player.health.max; + } } // COMBAT_DAMAGE_DEALT is emitted by CombatSystem - no need to emit here @@ -1788,22 +1801,17 @@ export class PlayerSystem extends SystemBase { } private calculateCombatLevel(skills: Skills): number { - // OSRS Combat Level Formula: - // base = 0.25 Γ— (Defence + Hitpoints + floor(Prayer / 2)) - // melee = 0.325 Γ— (Attack + Strength) - // ranged = 0.325 Γ— floor(Ranged Γ— 1.5) - // magic = 0.325 Γ— floor(Magic Γ— 1.5) - // combat = base + max(melee, ranged, magic) - - // Since we don't have Prayer or Magic yet, simplified formula: - const base = 0.25 * (skills.defense.level + skills.constitution.level); - - const melee = 0.325 * (skills.attack.level + skills.strength.level); - const ranged = 0.325 * Math.floor(skills.ranged.level * 1.5); - - const combatLevel = base + Math.max(melee, ranged); - - return Math.floor(combatLevel); + return calculateCombatLevel( + normalizeCombatSkills({ + attack: skills.attack.level, + strength: skills.strength.level, + defense: skills.defense.level, + hitpoints: skills.constitution.level, + ranged: skills.ranged.level, + magic: skills.magic?.level, + prayer: skills.prayer?.level, + }), + ); } /** @@ -2332,6 +2340,21 @@ export class PlayerSystem extends SystemBase { // Update player skills player.skills = data.skills; + // Sync health.max from constitution (same formula as initial registration at line 693) + // Without this, emitPlayerUpdate() broadcasts stale health.max after constitution XP gain + const constitutionLevel = + Number.isFinite(data.skills.constitution?.level) && + data.skills.constitution.level > 0 + ? data.skills.constitution.level + : 10; + const healthMaxChanged = constitutionLevel !== player.health.max; + if (healthMaxChanged) { + player.health.max = constitutionLevel; + if (player.health.current > player.health.max) { + player.health.current = player.health.max; + } + } + // Recalculate combat level player.combat.combatLevel = this.calculateCombatLevel(data.skills); @@ -2341,7 +2364,9 @@ export class PlayerSystem extends SystemBase { // Update stats component with new skill data for SkillsSystem and combat calculations const playerEntity = this.world.entities.get(data.playerId); if (playerEntity) { - const statsComponent = playerEntity.getComponent("stats"); + const statsComponent = playerEntity.getComponent( + "stats", + ) as StatsComponent | null; if (statsComponent) { // Update skill data (full SkillData objects with level + xp) in stats component statsComponent.data.attack = data.skills.attack; @@ -2353,6 +2378,18 @@ export class PlayerSystem extends SystemBase { statsComponent.data.fishing = data.skills.fishing; statsComponent.data.firemaking = data.skills.firemaking; statsComponent.data.cooking = data.skills.cooking; + + // Sync health to statsComponent so handleLevelUp reads correct values + // (defense-in-depth: handleLevelUp reads stats.health, not player.health) + statsComponent.health.current = player.health.current; + statsComponent.health.max = player.health.max; + } + + // Push updated health.max to entity data for network serialization + if (healthMaxChanged) { + playerEntity.data.health = player.health.current; + (playerEntity.data as { maxHealth?: number }).maxHealth = + player.health.max; } } diff --git a/packages/shared/src/types/bank-operations.ts b/packages/shared/src/types/bank-operations.ts index c22770a6a..272acadca 100644 --- a/packages/shared/src/types/bank-operations.ts +++ b/packages/shared/src/types/bank-operations.ts @@ -5,12 +5,14 @@ * Used by both client (pre-validation) and server (security validation). */ +import { MAX_COINS } from "../systems/shared/character/CoinPouchSystem"; + // ============================================================================ // VALIDATION CONSTANTS // ============================================================================ const MAX_ITEM_ID_LENGTH = 64; -const MAX_QUANTITY = 2147483647; // Max signed 32-bit int +const MAX_QUANTITY = MAX_COINS; const MAX_TAB_INDEX = 20; const MAX_SLOT = 500; diff --git a/packages/shared/src/types/game/prayer-types.ts b/packages/shared/src/types/game/prayer-types.ts index 87519bff7..33e783b71 100644 --- a/packages/shared/src/types/game/prayer-types.ts +++ b/packages/shared/src/types/game/prayer-types.ts @@ -87,6 +87,39 @@ export interface PrayerStateSyncPayload { // === Type Guards (Runtime Validation) === +/** + * Validates PrayerStateSyncPayload shape. + * Used by client UI components to safely narrow event data. + */ +export function isPrayerStateSyncPayload( + data: unknown, +): data is PrayerStateSyncPayload { + if (!data || typeof data !== "object") return false; + const obj = data as Record; + return ( + typeof obj.playerId === "string" && + typeof obj.points === "number" && + typeof obj.maxPoints === "number" && + Array.isArray(obj.active) + ); +} + +/** + * Validates PrayerToggledEvent shape. + * Used by client UI components to safely narrow event data. + */ +export function isPrayerToggledPayload( + data: unknown, +): data is PrayerToggledEvent { + if (!data || typeof data !== "object") return false; + const obj = data as Record; + return ( + typeof obj.playerId === "string" && + typeof obj.prayerId === "string" && + typeof obj.active === "boolean" + ); +} + /** * Validates prayer ID format (security + anti-exploit) * - Max 64 characters diff --git a/packages/shared/src/utils/game/CombatUtils.ts b/packages/shared/src/utils/game/CombatUtils.ts index 359a6a881..fe268066a 100644 --- a/packages/shared/src/utils/game/CombatUtils.ts +++ b/packages/shared/src/utils/game/CombatUtils.ts @@ -355,3 +355,30 @@ export function isAlive(player: Player): boolean { export function isDead(player: Player): boolean { return !player.alive || player.health.current <= 0; } + +// HP bar utilities (OSRS-style) + +/** + * Calculate clamped, rounded health percentage from raw values. + * + * @param current - Current health points + * @param max - Maximum health points + * @returns Percentage 0-100 (rounded integer) + */ +export function getHpPercent(current: number, max: number): number { + if (max <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((current / max) * 100))); +} + +/** + * Get the OSRS-style hex color for a health percentage. + * Thresholds: >50% green, 25-50% yellow, ≀25% red. + * + * @param percent - Health percentage (0-100) + * @returns Hex color string + */ +export function getHpColor(percent: number): string { + if (percent > 50) return "#22c55e"; + if (percent > 25) return "#eab308"; + return "#ef4444"; +} diff --git a/packages/shared/src/utils/game/XPCalculator.ts b/packages/shared/src/utils/game/XPCalculator.ts new file mode 100644 index 000000000..096f26b60 --- /dev/null +++ b/packages/shared/src/utils/game/XPCalculator.ts @@ -0,0 +1,103 @@ +/** + * XP Calculator - OSRS-Accurate Experience Point Calculations + * + * Standalone utility functions for XP ↔ Level conversions. + * Implements the exact XP formula from Old School RuneScape. + * + * Formula source: https://oldschool.runescape.wiki/w/Experience + * + * Note: SkillsSystem uses a pre-computed XP table (instance method) for server + * performance in hot paths. These standalone functions are intended for client-side + * UI rendering where occasional on-the-fly computation is acceptable. + */ + +/** Maximum skill level */ +const MAX_LEVEL = 99; + +/** + * Get the total XP required to reach a given level. + * + * Uses the same accumulative formula as SkillsSystem.generateXPTable() and + * the on-chain XPTable.sol contract to ensure parity across server, client, + * and blockchain. Produces 13,034,394 at level 99. + * + * @param level - Target level (1-99) + * @returns Total XP required (0 for level 1) + * + * @example + * getXPForLevel(1) // => 0 + * getXPForLevel(2) // => 83 + * getXPForLevel(99) // => 13,034,394 + */ +export function getXPForLevel(level: number): number { + if (level <= 1) return 0; + const clampedLevel = Math.min(level, MAX_LEVEL); + + // Accumulative formula matching SkillsSystem.generateXPTable() exactly: + // xpDelta(L) = floor((L-1 + 300 * 2^((L-1)/7)) / 4) + // xpForLevel(N) = sum(xpDelta(2)..xpDelta(N)) + let cumulative = 0; + for (let l = 2; l <= clampedLevel; l++) { + const xp = Math.floor(l - 1 + 300 * Math.pow(2, (l - 1) / 7)) / 4; + cumulative = Math.floor(cumulative + xp); + } + return cumulative; +} + +/** + * Get the level for a given amount of XP. + * + * Scans from MAX_LEVEL down to find the highest level whose XP threshold + * the given XP meets or exceeds. + * + * @param xp - Current XP amount + * @returns Level (1-99) + * + * @example + * getLevelForXP(0) // => 1 + * getLevelForXP(83) // => 2 + * getLevelForXP(100) // => 2 + */ +export function getLevelForXP(xp: number): number { + if (xp <= 0) return 1; + for (let level = MAX_LEVEL; level >= 1; level--) { + if (xp >= getXPForLevel(level)) { + return level; + } + } + return 1; +} + +/** + * Get XP remaining until the next level. + * + * @param currentXP - Current total XP + * @param currentLevel - Current level + * @returns XP remaining (0 if at max level) + */ +export function getXPToNextLevel( + currentXP: number, + currentLevel: number, +): number { + if (currentLevel >= MAX_LEVEL) return 0; + return getXPForLevel(currentLevel + 1) - currentXP; +} + +/** + * Get XP progress percentage toward the next level. + * + * @param currentXP - Current total XP + * @param currentLevel - Current level + * @returns Progress percentage (0-100) + */ +export function getXPProgress(currentXP: number, currentLevel: number): number { + if (currentLevel >= MAX_LEVEL) return 100; + + const currentLevelXP = getXPForLevel(currentLevel); + const nextLevelXP = getXPForLevel(currentLevel + 1); + const progressXP = currentXP - currentLevelXP; + const requiredXP = nextLevelXP - currentLevelXP; + + if (requiredXP <= 0) return 100; + return Math.min(100, Math.max(0, (progressXP / requiredXP) * 100)); +} diff --git a/packages/shared/src/utils/game/index.ts b/packages/shared/src/utils/game/index.ts index d1f5abbaa..5a5ba5806 100644 --- a/packages/shared/src/utils/game/index.ts +++ b/packages/shared/src/utils/game/index.ts @@ -37,3 +37,6 @@ export * from "./ComponentUtils"; // Combat level calculation (OSRS-accurate) export * from "./CombatLevelCalculator"; + +// XP ↔ Level calculations (OSRS-accurate, standalone utilities) +export * from "./XPCalculator";