refactor: 提取 ViewportCamera 与后备 Demo 数据

提取 ViewportCamera 到 `src/sim/camera.ts` 以复用视角缩放控制逻辑。
将原本散落在 sandbox 测试场景里的怪物、物品、任务等 Demo 数据提取至 `src/game/demo-data.ts`,作为 Act 场景的缺省源数据,解耦纯 UI 代码与配置逻辑。
This commit is contained in:
troytt 2026-09-14 12:07:20 +00:00
parent 4d47a56e68
commit 98d6a6dad2
4 changed files with 129 additions and 107 deletions

44
src/game/demo-data.ts Normal file
View File

@ -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,
}]

View File

@ -17,6 +17,7 @@
*/ */
import { MpqArchive } from '../mpq/archive.ts' import { MpqArchive } from '../mpq/archive.ts'
import { httpRangeSource } from '../mpq/source.ts' import { httpRangeSource } from '../mpq/source.ts'
import { ViewportCamera } from "../sim/camera.ts"
import { MountedArchives } from '../mpq/mount.ts' import { MountedArchives } from '../mpq/mount.ts'
import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts' import { loadActTables, resolveActTown, resolveLevel } from '../game/acts.ts'
import type { LevelInfo } 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<void> loadPages(renderer: SpriteRenderer, indices: readonly number[], onProgress: () => void): Promise<void>
} }
/**
* 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. * Read a URL parameter.
@ -439,7 +395,7 @@ async function loadPackRuntime(
? null ? null
: { rgb: Uint8Array.from(paletteRgb), size: 256 } : { rgb: Uint8Array.from(paletteRgb), size: 256 }
const pages: (AtlasHandle | null)[] = scene.pages.map(() => null) 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))] const loadOrder = [...priority, ...pages.map((_, index) => index).filter(index => !priority.includes(index))]
return { return {
@ -751,31 +707,9 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
input.attach() input.attach()
let skippedDraws = 0 let skippedDraws = 0
// Zoom: default to "cover the window", with manual override for looking around.
let character: LoadedCharacter | null = null let character: LoadedCharacter | null = null
let zoom = coverZoom(runtime.widthPx, runtime.heightPx, syncCanvasSize(canvas)) const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
let manualZoom = false camera.attach()
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 loop = new GameLoop({ const loop = new GameLoop({
tickRate: 25, tickRate: 25,
@ -804,7 +738,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
state.pagesAtFirstFrame = runtime.pages.filter(page => page !== null).length state.pagesAtFirstFrame = runtime.pages.filter(page => page !== null).length
state.firstFrameMs = Math.round(performance.now() - started) 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 skippedDraws = 0
const drawTiles = (list: readonly Drawable[], from: number, to: number): void => { const drawTiles = (list: readonly Drawable[], from: number, to: number): void => {
for (let index = from; index < to; index += 1) { for (let index = from; index < to; index += 1) {
@ -881,7 +815,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
renderer.flush() renderer.flush()
state.drawCalls = 1 state.drawCalls = 1
state.tickRate = loop.tickRate state.tickRate = loop.tickRate
state.zoom = Number(zoom.toFixed(2)) state.zoom = Number(camera.zoom.toFixed(2))
state.canvasWidth = canvas.width state.canvasWidth = canvas.width
state.canvasHeight = canvas.height state.canvasHeight = canvas.height
state.pagesLoaded = runtime.pages.filter(page => page !== null).length state.pagesLoaded = runtime.pages.filter(page => page !== null).length
@ -1205,7 +1139,7 @@ async function boot(): Promise<void> {
}) })
// The canvas fills the window, so its size decides how much world the first // 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. // frame needs and therefore which atlas pages must load before it.
const viewport = syncCanvasSize(canvas) const viewport = ViewportCamera.syncCanvasSize(canvas)
try { try {
const forcedLive = param('live', '') === '1' const forcedLive = param('live', '') === '1'

View File

@ -28,6 +28,7 @@ import { parseTable } from '../game/tables.ts'
import { import {
Inventory, affixesFromTable, goldItem, itemBasesFromTable, rollDrop, totalStat, Inventory, affixesFromTable, goldItem, itemBasesFromTable, rollDrop, totalStat,
} from '../game/items.ts' } 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 type { Affix, Item, ItemBase } from '../game/items.ts'
import { Rng } from '../game/rng.ts' import { Rng } from '../game/rng.ts'
import { castSkill, skillsFromTable, tickProjectiles } from '../game/skills.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 * Deliberately small and obviously generic: the point is that the sandbox runs
* and says where its numbers came from, not that these are balanced. * 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. */ /** Per-monster tints, so several monsters on one sheet read as several units. */
const MONSTER_TINTS: readonly (readonly [number, number, number, number])[] = [ 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], [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' const SAVE_KEY = 'd2web.save'
/** Seed for the drop stream, so a session's loot is reproducible. */ /** Seed for the drop stream, so a session's loot is reproducible. */
const LOOT_SEED = 0x5eed 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. */ /** How close the player must be to talk, in world pixels. */
const TALK_RADIUS = 80 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). */ /** How many DT1 libraries to load at once (maps reference more than one). */
const MAX_LIBRARIES = 4 const MAX_LIBRARIES = 4

75
src/sim/camera.ts Normal file
View File

@ -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 }
}
}