feat(ui): add interactive scene lighting selector (#lighting) after variant selector in acts.html

- Add <select id="lighting"> in acts.html right after #variant with 9 lighting modes:
  * auto: 自动 (原版 1.13c)
  * noon: 正午 (明亮日光)
  * morning: 清晨 (晨曦暖金)
  * sunset: 黄昏 (暮色紫金)
  * night: 午夜 (幽暗蓝月)
  * cave: 深渊地牢 (火炬暗黑)
  * hell: 地狱血月 (第四幕猩红)
  * eclipse: 蚀日之咒 (幽蓝暗蚀)
  * off: 关闭光照 (全亮无光影)
- Wire real-time zero-reload switching via switchLighting() in act-scene.ts and sync URL ?lighting= query param
This commit is contained in:
troytt 2026-09-24 10:20:18 +00:00
parent cef09dbd81
commit 5079f364ec
3 changed files with 207 additions and 11 deletions

View File

@ -57,6 +57,17 @@
</select></label>
<label>场景 <select id="scene" disabled><option>正在读取场景列表…</option></select></label>
<label>细分场景 <select id="variant" disabled></select></label>
<label>光照 <select id="lighting">
<option value="auto">自动 (原版 1.13c)</option>
<option value="noon">正午 (明亮日光)</option>
<option value="morning">清晨 (晨曦暖金)</option>
<option value="sunset">黄昏 (暮色紫金)</option>
<option value="night">午夜 (幽暗蓝月)</option>
<option value="cave">深渊地牢 (火炬暗黑)</option>
<option value="hell">地狱血月 (第四幕猩红)</option>
<option value="eclipse">蚀日之咒 (幽蓝暗蚀)</option>
<option value="off">关闭光照 (全亮无光影)</option>
</select></label>
<span class="hint" style="margin:0">I背包 · A角色 · T技能 · Q任务 · V小站 · ~腰带 · R奔跑 · W换手</span>
</div>
<div id="status" class="overlay">正在加载地图…</div>

View File

@ -292,6 +292,8 @@ export interface ActSceneState {
culledDraws: number
/** Wall-clock milliseconds the last `onRender` took. */
renderMs: number
/** Active lighting preset id ('auto', 'noon', 'morning', 'sunset', 'night', 'cave', 'hell', 'eclipse', 'off'). */
lightingPreset?: LightingPreset | undefined
/** Active lighting mode ('Glide 1.13c' or undefined). */
lightingMode?: string | undefined
/** Ambient light intensity [0..255]. */
@ -343,10 +345,98 @@ declare global {
__d2webActScene?: {
switchHero: (token: string) => Promise<boolean>
getHero: () => string
switchLighting?: (preset: string) => LightingPreset
getLighting?: () => LightingPreset
}
}
}
export type LightingPreset =
| 'auto'
| 'noon'
| 'morning'
| 'sunset'
| 'night'
| 'cave'
| 'hell'
| 'eclipse'
| 'off'
export interface LightingPresetConfig {
readonly id: LightingPreset
readonly labelZh: string
readonly enabled: boolean
readonly overrideAmbient?: {
readonly intensity: number
readonly red: number
readonly green: number
readonly blue: number
}
}
export const LIGHTING_PRESETS: Readonly<Record<LightingPreset, LightingPresetConfig>> = {
auto: {
id: 'auto',
labelZh: '自动 (原版 1.13c)',
enabled: true,
},
noon: {
id: 'noon',
labelZh: '正午 (明亮日光)',
enabled: true,
overrideAmbient: { intensity: 255, red: 255, green: 255, blue: 255 },
},
morning: {
id: 'morning',
labelZh: '清晨 (晨曦暖金)',
enabled: true,
overrideAmbient: { intensity: 190, red: 225, green: 198, blue: 145 },
},
sunset: {
id: 'sunset',
labelZh: '黄昏 (暮色紫金)',
enabled: true,
overrideAmbient: { intensity: 145, red: 210, green: 155, blue: 198 },
},
night: {
id: 'night',
labelZh: '午夜 (幽暗蓝月)',
enabled: true,
overrideAmbient: { intensity: 68, red: 125, green: 148, blue: 245 },
},
cave: {
id: 'cave',
labelZh: '深渊地牢 (火炬暗黑)',
enabled: true,
overrideAmbient: { intensity: 14, red: 225, green: 215, blue: 205 },
},
hell: {
id: 'hell',
labelZh: '地狱血月 (第四幕猩红)',
enabled: true,
overrideAmbient: { intensity: 105, red: 255, green: 68, blue: 58 },
},
eclipse: {
id: 'eclipse',
labelZh: '蚀日之咒 (幽蓝暗蚀)',
enabled: true,
overrideAmbient: { intensity: 52, red: 45, green: 85, blue: 245 },
},
off: {
id: 'off',
labelZh: '关闭光照 (全亮无光影)',
enabled: false,
},
}
export function normalizeLightingPreset(raw: string | null | undefined): LightingPreset {
const clean = (raw ?? '').trim().toLowerCase()
if (clean === '0' || clean === 'false' || clean === 'off' || clean === 'none') return 'off'
if (clean === '1' || clean === 'true' || clean === 'auto' || clean === '') return 'auto'
if (clean in LIGHTING_PRESETS) return clean as LightingPreset
return 'auto'
}
export interface HeroClassInfo {
readonly token: string
readonly code: string
@ -5287,6 +5377,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
const lightingSelect = document.querySelector<HTMLSelectElement>('#lighting')
const input = new KeyboardInput()
input.attach()
@ -5318,7 +5409,47 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const playerAnimator = new ActorAnimator()
const lightGrid = new LightGrid()
const environment = new Environment()
const lightingEnabled = param('lighting', '1') !== '0'
let currentLightingPreset: LightingPreset = normalizeLightingPreset(param('lighting', 'auto'))
let lightingEnabled = LIGHTING_PRESETS[currentLightingPreset].enabled
state.lightingPreset = currentLightingPreset
state.lightingMode = LIGHTING_PRESETS[currentLightingPreset].labelZh
const switchLighting = (preset: string): LightingPreset => {
currentLightingPreset = normalizeLightingPreset(preset)
const cfg = LIGHTING_PRESETS[currentLightingPreset]
lightingEnabled = cfg.enabled
state.lightingPreset = currentLightingPreset
state.lightingMode = cfg.labelZh
if (lightingSelect !== null && lightingSelect.value !== currentLightingPreset) {
lightingSelect.value = currentLightingPreset
}
try {
const url = new URL(location.href)
if (currentLightingPreset === 'auto') {
url.searchParams.delete('lighting')
} else {
url.searchParams.set('lighting', currentLightingPreset)
}
history.replaceState(null, '', url.toString())
} catch {
// ignore in non-browser context
}
status.textContent = `已切换场景光照为:${cfg.labelZh}`
setTimeout(() => {
if (status.textContent.startsWith('已切换场景光照为:')) {
status.textContent = ''
}
}, 2500)
return currentLightingPreset
}
if (lightingSelect !== null) {
lightingSelect.disabled = false
lightingSelect.value = currentLightingPreset
lightingSelect.onchange = () => {
switchLighting(lightingSelect.value)
}
}
const switchHero = async (newHeroToken: string): Promise<boolean> => {
const nextToken = normalizeHeroToken(newHeroToken)
@ -5383,8 +5514,11 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
window.__d2webActScene = {
switchHero,
getHero: () => currentHeroToken,
switchLighting,
getLighting: () => currentLightingPreset,
}
;(state as any).switchHero = switchHero
;(state as any).switchLighting = switchLighting
}
const monsterAnimators = new Map<number, ActorAnimator>()
const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
@ -5764,7 +5898,10 @@ if (typeof window !== 'undefined') {
}
if (next.source === 'pack') {
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.variant)}`)
const nextParams = new URLSearchParams(location.search)
nextParams.set('act', String(next.act))
nextParams.set('level', next.variant)
history.replaceState(null, '', `?${nextParams.toString()}`)
void populatePackSelectors(next.base, { act: next.act, label: next.variant })
}
@ -6301,14 +6438,18 @@ if (typeof window !== 'undefined') {
}
}
// Maintain a subtle warm-stone minimum ambient baseline in zero-intensity indoor caves
// so distant cave walls retain authentic silhouette depth against the [0, 0, 0] clear color.
const rawIntensity = environment.currentIntensity
// Resolve ambient from active lighting preset override or 1.13c Environment engine
const presetCfg = LIGHTING_PRESETS[currentLightingPreset]
const overrideAmb = presetCfg.overrideAmbient
const rawIntensity = overrideAmb !== undefined ? overrideAmb.intensity : environment.currentIntensity
const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 14
const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 225)
const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 215)
const effB = overrideAmb !== undefined ? overrideAmb.blue : (rawIntensity > 0 ? environment.currentBlue : 205)
const ambNorm = effectiveAmbIntensity / 255
const ambR = ((rawIntensity > 0 ? environment.currentRed : 225) / 255) * ambNorm
const ambG = ((rawIntensity > 0 ? environment.currentGreen : 215) / 255) * ambNorm
const ambB = ((rawIntensity > 0 ? environment.currentBlue : 205) / 255) * ambNorm
const ambR = (effR / 255) * ambNorm
const ambG = (effG / 255) * ambNorm
const ambB = (effB / 255) * ambNorm
renderer.setLighting({
ambient: [ambR, ambG, ambB],
@ -6319,11 +6460,16 @@ if (typeof window !== 'undefined') {
lightmap: lightGrid.exportTextureBuffer(),
})
state.lightingMode = 'Glide 1.13c'
state.ambientIntensity = environment.currentIntensity
state.ambientColor = [environment.currentRed, environment.currentGreen, environment.currentBlue]
state.lightingPreset = currentLightingPreset
state.lightingMode = presetCfg.labelZh
state.ambientIntensity = effectiveAmbIntensity
state.ambientColor = [effR, effG, effB]
} else {
renderer.setLighting(null)
state.lightingPreset = currentLightingPreset
state.lightingMode = LIGHTING_PRESETS[currentLightingPreset].labelZh
state.ambientIntensity = 255
state.ambientColor = [255, 255, 255]
}
skippedDraws = 0
let culledDraws = 0
@ -7268,6 +7414,20 @@ async function boot(): Promise<void> {
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
const lightingSelect = document.querySelector<HTMLSelectElement>('#lighting')
if (lightingSelect !== null) {
lightingSelect.disabled = false
lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto'))
lightingSelect.onchange = () => {
const params = new URLSearchParams(location.search)
if (lightingSelect.value === 'auto') {
params.delete('lighting')
} else {
params.set('lighting', lightingSelect.value)
}
location.search = params.toString()
}
}
if (heroSelect !== null) {
heroSelect.disabled = false
heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so')))

View File

@ -381,4 +381,29 @@ describe('Scene Lighting & Environment Integration', () => {
const frontAlpha = buf[wallFrontIdx * 4 + 3]
expect(frontAlpha).toBe(grid.intensity[wallFrontIdx])
})
it('supports interactive scene lighting presets and #lighting selector in acts.html', async () => {
const { LIGHTING_PRESETS, normalizeLightingPreset } = await import('../src/scene/act-scene.ts')
const fs = await import('node:fs')
expect(normalizeLightingPreset(undefined)).toBe('auto')
expect(normalizeLightingPreset('1')).toBe('auto')
expect(normalizeLightingPreset('0')).toBe('off')
expect(normalizeLightingPreset('off')).toBe('off')
expect(normalizeLightingPreset('noon')).toBe('noon')
expect(normalizeLightingPreset('night')).toBe('night')
expect(normalizeLightingPreset('cave')).toBe('cave')
expect(normalizeLightingPreset('hell')).toBe('hell')
expect(normalizeLightingPreset('eclipse')).toBe('eclipse')
expect(LIGHTING_PRESETS.noon.overrideAmbient?.intensity).toBe(255)
expect(LIGHTING_PRESETS.cave.overrideAmbient?.intensity).toBe(14)
expect(LIGHTING_PRESETS.off.enabled).toBe(false)
const actsHtml = fs.readFileSync('acts.html', 'utf8')
expect(actsHtml).toContain('<select id="lighting">')
for (const preset of Object.keys(LIGHTING_PRESETS)) {
expect(actsHtml).toContain(`value="${preset}"`)
}
})
})