refactor: 提取 ViewportCamera 与后备 Demo 数据
提取 ViewportCamera 到 `src/sim/camera.ts` 以复用视角缩放控制逻辑。 将原本散落在 sandbox 测试场景里的怪物、物品、任务等 Demo 数据提取至 `src/game/demo-data.ts`,作为 Act 场景的缺省源数据,解耦纯 UI 代码与配置逻辑。
This commit is contained in:
parent
4d47a56e68
commit
98d6a6dad2
|
|
@ -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,
|
||||
}]
|
||||
|
|
@ -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<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.
|
||||
|
|
@ -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<void> {
|
|||
})
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue