From 98d6a6dad2f55493ff2fdb81258f5aed68ed0375 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 12:07:20 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8F=90=E5=8F=96=20ViewportCamera?= =?UTF-8?q?=20=E4=B8=8E=E5=90=8E=E5=A4=87=20Demo=20=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取 ViewportCamera 到 `src/sim/camera.ts` 以复用视角缩放控制逻辑。 将原本散落在 sandbox 测试场景里的怪物、物品、任务等 Demo 数据提取至 `src/game/demo-data.ts`,作为 Act 场景的缺省源数据,解耦纯 UI 代码与配置逻辑。 --- src/game/demo-data.ts | 44 +++++++++++++++++++++++ src/scene/act-scene.ts | 80 ++++-------------------------------------- src/scene/map-scene.ts | 37 ++----------------- src/sim/camera.ts | 75 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 107 deletions(-) create mode 100644 src/game/demo-data.ts create mode 100644 src/sim/camera.ts diff --git a/src/game/demo-data.ts b/src/game/demo-data.ts new file mode 100644 index 0000000..3badba2 --- /dev/null +++ b/src/game/demo-data.ts @@ -0,0 +1,44 @@ +import type { MonsterStats } from './combat' +import type { ItemBase, Affix } from './items' +import type { SkillDef } from './skills' +import type { NpcDef, QuestDef } from './quests' + +export const DEMO_MONSTERS: readonly MonsterStats[] = [ + { id: 'demo-fallen', name: 'Fallen', hp: 12, damage: 3, cooldownTicks: 24, reach: 36, aggroRadius: 220, speed: 80, xp: 8 }, + { id: 'demo-zombie', name: 'Zombie', hp: 30, damage: 6, cooldownTicks: 32, reach: 40, aggroRadius: 170, speed: 46, xp: 15 }, + { id: 'demo-skeleton', name: 'Skeleton', hp: 18, damage: 4, cooldownTicks: 28, reach: 38, aggroRadius: 260, speed: 70, xp: 12 }, +] + +/** Fallback experience thresholds, index 1 = level 1. */ +export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280] + +/** Fallback item bases and affixes, used when an archive ships no item tables. */ +export const DEMO_BASES: readonly ItemBase[] = [ + { id: 'demo-sword', name: 'Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 40, damage: 6, defense: 0, tags: ['weap'], level: 1 }, + { id: 'demo-cap', name: 'Cap', kind: 'armor', invWidth: 2, invHeight: 2, maxStack: 1, value: 30, damage: 0, defense: 5, tags: ['armo'], level: 1 }, + { id: 'demo-potion', name: 'Potion', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 5, value: 20, damage: 0, defense: 0, tags: ['misc'], level: 1 }, +] + +export const DEMO_AFFIXES: readonly Affix[] = [ + { id: 'demo-cruel', name: 'Cruel', kind: 'prefix', level: 3, itemTypes: ['weap'], modifiers: [{ stat: 'maxdamage', min: 3, max: 8 }] }, + { id: 'demo-might', name: 'of Might', kind: 'suffix', level: 2, itemTypes: [], modifiers: [{ stat: 'strength', min: 1, max: 4 }] }, +] + +/** Fallback skills, used when an archive ships no skill table. */ +export const DEMO_SKILLS: readonly SkillDef[] = [ + { id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 10, range: 48, projectile: false, speed: 0, baseMinDamage: 4, baseMaxDamage: 6, damagePerLevel: 1, radius: 20 }, + { id: 'firebolt', name: 'Fire Bolt', manaCost: 6, cooldownTicks: 20, range: 260, projectile: true, speed: 320, baseMinDamage: 7, baseMaxDamage: 10, damagePerLevel: 3, radius: 20 }, +] + +/** Fallback NPC, used when an archive ships no NPC table. */ +export const DEMO_NPCS: readonly NpcDef[] = [{ + id: 'elder', name: 'Elder', questId: 'demo', + offerLines: ['The moor is crawling with vermin.', 'Will you thin them out?'], + progressLines: ['Still at it?'], doneLines: ['The moor is quieter. Thank you.'], +}] + +/** Fallback quest, used when an archive ships no quest table. */ +export const DEMO_QUESTS: readonly QuestDef[] = [{ + id: 'demo', name: 'Clear the Moor', description: 'Slay three monsters', + monsterId: '*', killCount: 3, rewardXp: 40, rewardGold: 80, +}] diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 67eb33a..ce5a57f 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -17,6 +17,7 @@ */ import { MpqArchive } from '../mpq/archive.ts' import { httpRangeSource } from '../mpq/source.ts' +import { ViewportCamera } from "../sim/camera.ts" import { MountedArchives } from '../mpq/mount.ts' import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts' import type { LevelInfo } from '../game/acts.ts' @@ -205,52 +206,7 @@ interface MapRuntime { loadPages(renderer: SpriteRenderer, indices: readonly number[], onProgress: () => void): Promise } -/** - * Match the canvas backing store to its on-screen size. - * - * The canvas fills the window, so its pixel size has to follow the window *and* - * the device pixel ratio, or the browser scales a small buffer up and every tile - * edge goes soft. The backing store is capped because a 4K retina window would - * otherwise ask for a 30-megapixel framebuffer the software GL path cannot keep - * up with. - * - * @param canvas - the canvas element. - * @returns the CSS size the camera should treat as the viewport. - */ -function syncCanvasSize(canvas: HTMLCanvasElement): { width: number; height: number } { - const dpr = Math.min(window.devicePixelRatio || 1, 2) - const cssWidth = Math.max(320, canvas.clientWidth || window.innerWidth) - const cssHeight = Math.max(240, canvas.clientHeight || window.innerHeight) - const maxPixels = 1920 * 1200 - const scale = Math.min(dpr, Math.sqrt(maxPixels / (cssWidth * cssHeight))) - const width = Math.max(1, Math.round(cssWidth * scale)) - const height = Math.max(1, Math.round(cssHeight * scale)) - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width - canvas.height = height - } - return { width: cssWidth, height: cssHeight } -} -/** - * The zoom that makes a map cover the viewport. - * - * "Fill the screen" means the map should not sit in the middle with empty space - * around it, so this is the *cover* scale (`max`), not the fit scale (`min`): a - * map wider than the viewport is zoomed until it covers, and a small map is - * zoomed in until it does. The result is clamped so a tiny 17×20 level does not - * turn into a handful of giant pixels. - * - * @param mapWidth - map width in scene pixels. - * @param mapHeight - map height in scene pixels. - * @param viewport - viewport size in CSS pixels. - * @returns the zoom factor. - */ -function coverZoom(mapWidth: number, mapHeight: number, viewport: { width: number; height: number }): number { - if (mapWidth <= 0 || mapHeight <= 0) return 1.6 - const cover = Math.max(viewport.width / mapWidth, viewport.height / mapHeight) - return Math.min(6, Math.max(0.9, cover)) -} /** * Read a URL parameter. @@ -439,7 +395,7 @@ async function loadPackRuntime( ? null : { rgb: Uint8Array.from(paletteRgb), size: 256 } const pages: (AtlasHandle | null)[] = scene.pages.map(() => null) - const priority = pagesNeededForViewport(floors, walls, spawn, viewport, coverZoom(scene.widthPx, scene.heightPx, viewport)) + const priority = pagesNeededForViewport(floors, walls, spawn, viewport, ViewportCamera.coverZoom(scene.widthPx, scene.heightPx, viewport)) const loadOrder = [...priority, ...pages.map((_, index) => index).filter(index => !priority.includes(index))] return { @@ -751,31 +707,9 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number input.attach() let skippedDraws = 0 - // Zoom: default to "cover the window", with manual override for looking around. let character: LoadedCharacter | null = null - let zoom = coverZoom(runtime.widthPx, runtime.heightPx, syncCanvasSize(canvas)) - let manualZoom = false - const setZoom = (value: number): void => { - manualZoom = true - zoom = Math.min(8, Math.max(0.4, value)) - } - const onWheel = (event: WheelEvent): void => { - event.preventDefault() - setZoom(zoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12)) - } - const onKeyZoom = (event: KeyboardEvent): void => { - if (event.code === 'Equal' || event.code === 'NumpadAdd') setZoom(zoom * 1.15) - if (event.code === 'Minus' || event.code === 'NumpadSubtract') setZoom(zoom / 1.15) - if (event.code === 'Digit0') { manualZoom = false; zoom = coverZoom(runtime.widthPx, runtime.heightPx, syncCanvasSize(canvas)) } - } - const onResize = (): void => { - const size = syncCanvasSize(canvas) - if (!manualZoom) zoom = coverZoom(runtime.widthPx, runtime.heightPx, size) - state.zoom = Number(zoom.toFixed(2)) - } - canvas.addEventListener('wheel', onWheel, { passive: false }) - window.addEventListener('keydown', onKeyZoom) - window.addEventListener('resize', onResize) + const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx) + camera.attach() const loop = new GameLoop({ tickRate: 25, @@ -804,7 +738,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number state.pagesAtFirstFrame = runtime.pages.filter(page => page !== null).length state.firstFrameMs = Math.round(performance.now() - started) } - renderer.begin({ x: player.x, y: player.y - 16, zoom }, [0.03, 0.03, 0.04]) + renderer.begin({ x: player.x, y: player.y - 16, zoom: camera.zoom }, [0.03, 0.03, 0.04]) skippedDraws = 0 const drawTiles = (list: readonly Drawable[], from: number, to: number): void => { for (let index = from; index < to; index += 1) { @@ -881,7 +815,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number renderer.flush() state.drawCalls = 1 state.tickRate = loop.tickRate - state.zoom = Number(zoom.toFixed(2)) + state.zoom = Number(camera.zoom.toFixed(2)) state.canvasWidth = canvas.width state.canvasHeight = canvas.height state.pagesLoaded = runtime.pages.filter(page => page !== null).length @@ -1205,7 +1139,7 @@ async function boot(): Promise { }) // The canvas fills the window, so its size decides how much world the first // frame needs and therefore which atlas pages must load before it. - const viewport = syncCanvasSize(canvas) + const viewport = ViewportCamera.syncCanvasSize(canvas) try { const forcedLive = param('live', '') === '1' diff --git a/src/scene/map-scene.ts b/src/scene/map-scene.ts index 631a244..0ca70a2 100644 --- a/src/scene/map-scene.ts +++ b/src/scene/map-scene.ts @@ -28,6 +28,7 @@ import { parseTable } from '../game/tables.ts' import { Inventory, affixesFromTable, goldItem, itemBasesFromTable, rollDrop, totalStat, } from '../game/items.ts' +import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" import type { Affix, Item, ItemBase } from '../game/items.ts' import { Rng } from '../game/rng.ts' import { castSkill, skillsFromTable, tickProjectiles } from '../game/skills.ts' @@ -63,13 +64,7 @@ const MARKER_HEIGHT = 26 * Deliberately small and obviously generic: the point is that the sandbox runs * and says where its numbers came from, not that these are balanced. */ -const DEMO_MONSTERS: readonly MonsterStats[] = [ - { id: 'demo-fallen', name: 'Fallen', hp: 12, damage: 3, cooldownTicks: 24, reach: 36, aggroRadius: 220, speed: 80, xp: 8 }, - { id: 'demo-zombie', name: 'Zombie', hp: 30, damage: 6, cooldownTicks: 32, reach: 40, aggroRadius: 170, speed: 46, xp: 15 }, - { id: 'demo-skeleton', name: 'Skeleton', hp: 18, damage: 4, cooldownTicks: 28, reach: 38, aggroRadius: 260, speed: 70, xp: 12 }, -] -/** Fallback experience thresholds, index 1 = level 1. */ -const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280] + /** Per-monster tints, so several monsters on one sheet read as several units. */ const MONSTER_TINTS: readonly (readonly [number, number, number, number])[] = [ [1, 0.78, 0.72, 1], [0.78, 1, 0.8, 1], [0.82, 0.86, 1, 1], [1, 0.92, 0.7, 1], @@ -89,36 +84,10 @@ const PICKUP_RADIUS = 64 const SAVE_KEY = 'd2web.save' /** Seed for the drop stream, so a session's loot is reproducible. */ const LOOT_SEED = 0x5eed -/** Fallback item bases and affixes, used when an archive ships no item tables. */ -const DEMO_BASES: readonly ItemBase[] = [ - { id: 'demo-sword', name: 'Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 40, damage: 6, defense: 0, tags: ['weap'], level: 1 }, - { id: 'demo-cap', name: 'Cap', kind: 'armor', invWidth: 2, invHeight: 2, maxStack: 1, value: 30, damage: 0, defense: 5, tags: ['armo'], level: 1 }, - { id: 'demo-potion', name: 'Potion', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 5, value: 20, damage: 0, defense: 0, tags: ['misc'], level: 1 }, -] -const DEMO_AFFIXES: readonly Affix[] = [ - { id: 'demo-cruel', name: 'Cruel', kind: 'prefix', level: 3, itemTypes: ['weap'], modifiers: [{ stat: 'maxdamage', min: 3, max: 8 }] }, - { id: 'demo-might', name: 'of Might', kind: 'suffix', level: 2, itemTypes: [], modifiers: [{ stat: 'strength', min: 1, max: 4 }] }, -] + /** How close the player must be to talk, in world pixels. */ const TALK_RADIUS = 80 -/** Fallback skills, used when an archive ships no skill table. */ -const DEMO_SKILLS: readonly SkillDef[] = [ - { id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 10, range: 48, projectile: false, speed: 0, baseMinDamage: 4, baseMaxDamage: 6, damagePerLevel: 1, radius: 20 }, - { id: 'firebolt', name: 'Fire Bolt', manaCost: 6, cooldownTicks: 20, range: 260, projectile: true, speed: 320, baseMinDamage: 7, baseMaxDamage: 10, damagePerLevel: 3, radius: 20 }, -] -/** Fallback NPC, used when an archive ships no NPC table. */ -const DEMO_NPCS: readonly NpcDef[] = [{ - id: 'elder', name: 'Elder', questId: 'demo', - offerLines: ['The moor is crawling with vermin.', 'Will you thin them out?'], - progressLines: ['Still at it?'], doneLines: ['The moor is quieter. Thank you.'], -}] -/** Fallback quest, used when an archive ships no quest table. */ -const DEMO_QUESTS = [{ - id: 'demo', name: 'Clear the Moor', description: 'Slay three monsters', - monsterId: '*', killCount: 3, rewardXp: 40, rewardGold: 80, -}] as const - /** How many DT1 libraries to load at once (maps reference more than one). */ const MAX_LIBRARIES = 4 diff --git a/src/sim/camera.ts b/src/sim/camera.ts new file mode 100644 index 0000000..ab27ab5 --- /dev/null +++ b/src/sim/camera.ts @@ -0,0 +1,75 @@ +export class ViewportCamera { + zoom: number + manualZoom = false + + private readonly onWheel: (event: WheelEvent) => void + private readonly onKeyZoom: (event: KeyboardEvent) => void + private readonly onResize: () => void + + constructor( + private canvas: HTMLCanvasElement, + public mapWidthPx: number, + public mapHeightPx: number + ) { + this.zoom = ViewportCamera.coverZoom(mapWidthPx, mapHeightPx, ViewportCamera.syncCanvasSize(canvas)) + + this.onWheel = (event: WheelEvent) => { + event.preventDefault() + this.setZoom(this.zoom * (event.deltaY < 0 ? 1.12 : 1 / 1.12)) + } + + this.onKeyZoom = (event: KeyboardEvent) => { + if (event.code === 'Equal' || event.code === 'NumpadAdd') this.setZoom(this.zoom * 1.15) + if (event.code === 'Minus' || event.code === 'NumpadSubtract') this.setZoom(this.zoom / 1.15) + if (event.code === 'Digit0') { + this.manualZoom = false + this.zoom = ViewportCamera.coverZoom(this.mapWidthPx, this.mapHeightPx, ViewportCamera.syncCanvasSize(this.canvas)) + } + } + + this.onResize = () => { + const size = ViewportCamera.syncCanvasSize(this.canvas) + if (!this.manualZoom) { + this.zoom = ViewportCamera.coverZoom(this.mapWidthPx, this.mapHeightPx, size) + } + } + } + + setZoom(value: number): void { + this.manualZoom = true + this.zoom = Math.min(8, Math.max(0.4, value)) + } + + attach(): void { + this.canvas.addEventListener('wheel', this.onWheel, { passive: false }) + window.addEventListener('keydown', this.onKeyZoom) + window.addEventListener('resize', this.onResize) + } + + detach(): void { + this.canvas.removeEventListener('wheel', this.onWheel) + window.removeEventListener('keydown', this.onKeyZoom) + window.removeEventListener('resize', this.onResize) + } + + static coverZoom(mapWidth: number, mapHeight: number, viewport: { width: number; height: number }): number { + if (mapWidth <= 0 || mapHeight <= 0) return 1.6 + const cover = Math.max(viewport.width / mapWidth, viewport.height / mapHeight) + return Math.min(6, Math.max(0.9, cover)) + } + + static syncCanvasSize(canvas: HTMLCanvasElement): { width: number; height: number } { + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const cssWidth = Math.max(320, canvas.clientWidth || window.innerWidth) + const cssHeight = Math.max(240, canvas.clientHeight || window.innerHeight) + const maxPixels = 1920 * 1200 + const scale = Math.min(dpr, Math.sqrt(maxPixels / (cssWidth * cssHeight))) + const width = Math.max(1, Math.round(cssWidth * scale)) + const height = Math.max(1, Math.round(cssHeight * scale)) + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width + canvas.height = height + } + return { width, height } + } +}