Compare commits
26 Commits
5828f40497
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ba38cc23e | |||
| 71aee058b5 | |||
| 3fdf621966 | |||
| 7f0ef0554e | |||
| d83b97a447 | |||
| a93e8a2c5d | |||
| 7c130763b5 | |||
| 007d5b3fee | |||
| 34220818b0 | |||
| 0011bc9877 | |||
| 6fa3ae4465 | |||
| 6de4c1cbb9 | |||
| d354a26a80 | |||
| fb4abb7256 | |||
| 0e4c7c96ee | |||
| cccfd9ba73 | |||
| 216c70dbd9 | |||
| b5130169bd | |||
| f0065a0cda | |||
| fa41075c55 | |||
| 715278ae78 | |||
| 2c949cc19e | |||
| 6385872dd1 | |||
| c9c8e45b0c | |||
| 787ada7cb4 | |||
| 8ed67313a8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
game-test.log
|
||||
.claude/
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -7,8 +7,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Nisse now clear the FOREST/ROCK tile after harvesting, opening paths to deeper resources
|
||||
- Nisse no longer get stuck idle after depositing items at the stockpile
|
||||
- Working Nisse now reset to idle on game load (like walking ones), preventing stale AI state
|
||||
- Stale jobs with empty carry are now cleared after work completes, avoiding a false "haul to stockpile" loop
|
||||
- UI elements (stockpile panel, controls hint) now reposition correctly after window resize
|
||||
- Centered overlay panels (build menu, villager panel) close on resize so they reopen at the correct position
|
||||
- Mouse world coordinates now use `ptr.worldX`/`ptr.worldY` in BuildingSystem and FarmingSystem, fixing misalignment after resize or zoom
|
||||
|
||||
### Changed
|
||||
- Villagers are now called **Nisse** throughout the UI (panel, controls hint, stockpile display, context menu, spawn message)
|
||||
|
||||
### Added
|
||||
- Right-click context menu: suppresses browser default, shows Build and Folks actions in the game world
|
||||
- Scroll wheel zooms toward the mouse cursor position (zoom-to-mouse), correctly accounting for Phaser's center-based zoom model
|
||||
- Middle mouse button held: pan the camera by dragging
|
||||
- Test environment at `/test.html` with `ZoomTestScene` (Phaser default) and `ZoomMouseScene` (zoom-to-mouse) for camera behaviour analysis; file-logging via Vite middleware to `game-test.log`
|
||||
|
||||
### Fixed
|
||||
- `getCenterWorld()` in `CameraSystem` returned wrong world coordinates at zoom ≠ 1; corrected from `scrollX + width/(2·zoom)` to `scrollX + width/2`
|
||||
- Right-click context menu: suppresses browser default, shows Build and Nisse actions in the game world
|
||||
- Initial project setup: Phaser 3 + TypeScript + Vite
|
||||
- Core scenes: `BootScene`, `GameScene`, `UIScene`
|
||||
- Systems: `BuildingSystem`, `CameraSystem`, `FarmingSystem`, `PlayerSystem`,
|
||||
|
||||
@@ -176,9 +176,9 @@ class StateManager {
|
||||
if (!p.world.crops) p.world.crops = {}
|
||||
if (!p.world.villagers) p.world.villagers = {}
|
||||
if (!p.world.stockpile) p.world.stockpile = {}
|
||||
// Reset walking villagers to idle on load
|
||||
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
||||
for (const v of Object.values(p.world.villagers)) {
|
||||
if (v.aiState === 'walking') v.aiState = 'idle'
|
||||
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
||||
}
|
||||
return p
|
||||
} catch (_) { return null }
|
||||
|
||||
@@ -21,7 +21,9 @@ export class UIScene extends Phaser.Scene {
|
||||
private buildModeText!: Phaser.GameObjects.Text
|
||||
private farmToolText!: Phaser.GameObjects.Text
|
||||
private coordsText!: Phaser.GameObjects.Text
|
||||
private controlsHintText!: Phaser.GameObjects.Text
|
||||
private popText!: Phaser.GameObjects.Text
|
||||
private stockpileTitleText!: Phaser.GameObjects.Text
|
||||
private contextMenuGroup!: Phaser.GameObjects.Group
|
||||
private contextMenuVisible = false
|
||||
private inBuildMode = false
|
||||
@@ -80,13 +82,13 @@ export class UIScene extends Phaser.Scene {
|
||||
private createStockpilePanel(): void {
|
||||
const x = this.scale.width - 178, y = 10
|
||||
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
|
||||
this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const
|
||||
items.forEach((item, i) => {
|
||||
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
this.stockpileTexts.set(item, t)
|
||||
})
|
||||
this.popText = this.add.text(x + 10, y + 145, '👥 Pop: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
this.popText = this.add.text(x + 10, y + 145, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
}
|
||||
|
||||
private updateStockpile(): void {
|
||||
@@ -102,7 +104,7 @@ export class UIScene extends Phaser.Scene {
|
||||
const state = stateManager.getState()
|
||||
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length
|
||||
const current = Object.keys(state.world.villagers).length
|
||||
this.popText?.setText(`👥 Pop: ${current} / ${beds} [V] manage`)
|
||||
this.popText?.setText(`👥 Nisse: ${current} / ${beds} [V]`)
|
||||
}
|
||||
|
||||
// ─── Hint ─────────────────────────────────────────────────────────────────
|
||||
@@ -202,13 +204,13 @@ export class UIScene extends Phaser.Scene {
|
||||
this.villagerPanelGroup.add(bg)
|
||||
|
||||
this.villagerPanelGroup.add(
|
||||
this.add.text(px + panelW/2, py + 12, '👥 VILLAGERS [V] close', { fontSize: '12px', color: '#aaaaaa', fontFamily: 'monospace' })
|
||||
this.add.text(px + panelW/2, py + 12, '👥 NISSE [V] close', { fontSize: '12px', color: '#aaaaaa', fontFamily: 'monospace' })
|
||||
.setOrigin(0.5, 0).setScrollFactor(0).setDepth(211)
|
||||
)
|
||||
|
||||
if (villagers.length === 0) {
|
||||
this.villagerPanelGroup.add(
|
||||
this.add.text(px + panelW/2, py + panelH/2, 'No villagers yet.\nBuild a 🛏 Bed first!', {
|
||||
this.add.text(px + panelW/2, py + panelH/2, 'No Nisse yet.\nBuild a 🛏 Bed first!', {
|
||||
fontSize: '13px', color: '#666666', fontFamily: 'monospace', align: 'center'
|
||||
}).setOrigin(0.5).setScrollFactor(0).setDepth(211)
|
||||
)
|
||||
@@ -286,7 +288,7 @@ export class UIScene extends Phaser.Scene {
|
||||
|
||||
private createCoordsDisplay(): void {
|
||||
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
|
||||
this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Villagers', {
|
||||
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse', {
|
||||
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
|
||||
}).setScrollFactor(0).setDepth(100)
|
||||
}
|
||||
@@ -322,7 +324,7 @@ export class UIScene extends Phaser.Scene {
|
||||
action: () => { this.hideContextMenu(); this.scene.get('Game').events.emit('uiRequestBuildMenu') },
|
||||
},
|
||||
{
|
||||
label: '👥 Folks',
|
||||
label: '👥 Nisse',
|
||||
action: () => { this.hideContextMenu(); this.toggleVillagerPanel() },
|
||||
},
|
||||
]
|
||||
@@ -359,10 +361,33 @@ export class UIScene extends Phaser.Scene {
|
||||
|
||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Repositions all fixed UI elements after a canvas resize.
|
||||
* Open overlay panels are closed so they reopen correctly centered.
|
||||
*/
|
||||
private repositionUI(): void {
|
||||
const { width, height } = this.scale
|
||||
this.hintText.setPosition(width/2, height - 40)
|
||||
this.toastText.setPosition(width/2, 60)
|
||||
|
||||
// Stockpile panel — anchored to top-right; move all elements by the delta
|
||||
const newPanelX = width - 178
|
||||
const deltaX = newPanelX - this.stockpilePanel.x
|
||||
if (deltaX !== 0) {
|
||||
this.stockpilePanel.setX(newPanelX)
|
||||
this.stockpileTitleText.setX(this.stockpileTitleText.x + deltaX)
|
||||
this.stockpileTexts.forEach(t => t.setX(t.x + deltaX))
|
||||
this.popText.setX(this.popText.x + deltaX)
|
||||
}
|
||||
|
||||
// Bottom elements
|
||||
this.hintText.setPosition(width / 2, height - 40)
|
||||
this.toastText.setPosition(width / 2, 60)
|
||||
this.coordsText.setPosition(10, height - 24)
|
||||
this.controlsHintText.setPosition(10, height - 42)
|
||||
|
||||
// Close centered panels — their position is calculated on open, so they
|
||||
// would be off-center if left open during a resize
|
||||
if (this.buildMenuVisible) this.closeBuildMenu()
|
||||
if (this.villagerPanelVisible) this.closeVillagerPanel()
|
||||
if (this.contextMenuVisible) this.hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +92,9 @@ export class BuildingSystem {
|
||||
if (!this.active) return
|
||||
|
||||
// Update ghost to follow mouse (snapped to tile grid)
|
||||
const ptr = this.scene.input.activePointer
|
||||
const worldX = this.scene.cameras.main.scrollX + ptr.x
|
||||
const worldY = this.scene.cameras.main.scrollY + ptr.y
|
||||
const ptr = this.scene.input.activePointer
|
||||
const worldX = ptr.worldX
|
||||
const worldY = ptr.worldY
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
|
||||
@@ -142,8 +142,8 @@ export class BuildingSystem {
|
||||
}
|
||||
|
||||
private tryPlace(ptr: Phaser.Input.Pointer): void {
|
||||
const worldX = this.scene.cameras.main.scrollX + ptr.x
|
||||
const worldY = this.scene.cameras.main.scrollY + ptr.y
|
||||
const worldX = ptr.worldX
|
||||
const worldY = ptr.worldY
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
|
||||
|
||||
@@ -23,12 +23,23 @@ export class CameraSystem {
|
||||
}
|
||||
private saveTimer = 0
|
||||
private readonly SAVE_TICK = 2000
|
||||
private middlePanActive = false
|
||||
private lastPanX = 0
|
||||
private lastPanY = 0
|
||||
|
||||
/**
|
||||
* @param scene - The Phaser scene this system belongs to
|
||||
* @param adapter - Network adapter used to persist camera position
|
||||
*/
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the camera: restores saved position, registers keyboard keys,
|
||||
* sets up scroll-wheel zoom-to-mouse, and middle-click pan.
|
||||
*/
|
||||
create(): void {
|
||||
const state = stateManager.getState()
|
||||
const cam = this.scene.cameras.main
|
||||
@@ -49,13 +60,56 @@ export class CameraSystem {
|
||||
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
||||
}
|
||||
|
||||
// Scroll wheel zoom
|
||||
this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => {
|
||||
const zoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
||||
cam.setZoom(zoom)
|
||||
// Scroll wheel: zoom-to-mouse.
|
||||
// Phaser zooms from the screen center, so the world point under the mouse
|
||||
// is corrected by shifting scroll by the mouse offset from center.
|
||||
this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => {
|
||||
const zoomBefore = cam.zoom
|
||||
const newZoom = Phaser.Math.Clamp(zoomBefore - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
||||
cam.setZoom(newZoom)
|
||||
|
||||
const factor = 1 / zoomBefore - 1 / newZoom
|
||||
cam.scrollX += (ptr.x - cam.width / 2) * factor
|
||||
cam.scrollY += (ptr.y - cam.height / 2) * factor
|
||||
|
||||
const worldW = WORLD_TILES * 32
|
||||
const worldH = WORLD_TILES * 32
|
||||
cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, worldW - cam.width / newZoom)
|
||||
cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, worldH - cam.height / newZoom)
|
||||
})
|
||||
|
||||
// Middle-click pan: start on button down
|
||||
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (ptr.middleButtonDown()) {
|
||||
this.middlePanActive = true
|
||||
this.lastPanX = ptr.x
|
||||
this.lastPanY = ptr.y
|
||||
}
|
||||
})
|
||||
|
||||
// Middle-click pan: move camera while held
|
||||
this.scene.input.on('pointermove', (ptr: Phaser.Input.Pointer) => {
|
||||
if (!this.middlePanActive) return
|
||||
const dx = (ptr.x - this.lastPanX) / cam.zoom
|
||||
const dy = (ptr.y - this.lastPanY) / cam.zoom
|
||||
cam.scrollX -= dx
|
||||
cam.scrollY -= dy
|
||||
this.lastPanX = ptr.x
|
||||
this.lastPanY = ptr.y
|
||||
})
|
||||
|
||||
// Middle-click pan: stop on button release
|
||||
this.scene.input.on('pointerup', (ptr: Phaser.Input.Pointer) => {
|
||||
if (this.middlePanActive && !ptr.middleButtonDown()) {
|
||||
this.middlePanActive = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the camera via keyboard input and periodically saves the position.
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
update(delta: number): void {
|
||||
const cam = this.scene.cameras.main
|
||||
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
|
||||
@@ -73,7 +127,7 @@ export class CameraSystem {
|
||||
|
||||
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
||||
|
||||
const worldW = WORLD_TILES * 32 // TILE_SIZE hardcoded since WORLD_PX may not exist
|
||||
const worldW = WORLD_TILES * 32
|
||||
const worldH = WORLD_TILES * 32
|
||||
cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom)
|
||||
cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom)
|
||||
@@ -90,14 +144,24 @@ export class CameraSystem {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the world coordinates of the visual camera center.
|
||||
* Phaser zooms from the screen center, so the center world point
|
||||
* is scrollX + screenWidth/2 (independent of zoom level).
|
||||
* @returns World position of the screen center
|
||||
*/
|
||||
getCenterWorld(): { x: number; y: number } {
|
||||
const cam = this.scene.cameras.main
|
||||
return {
|
||||
x: cam.scrollX + cam.width / (2 * cam.zoom),
|
||||
y: cam.scrollY + cam.height / (2 * cam.zoom),
|
||||
x: cam.scrollX + cam.width / 2,
|
||||
y: cam.scrollY + cam.height / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tile coordinates of the visual camera center.
|
||||
* @returns Tile position (integer) of the screen center
|
||||
*/
|
||||
getCenterTile(): { tileX: number; tileY: number } {
|
||||
const { x, y } = this.getCenterWorld()
|
||||
return { tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) }
|
||||
|
||||
@@ -80,9 +80,8 @@ export class FarmingSystem {
|
||||
// ─── Tool actions ─────────────────────────────────────────────────────────
|
||||
|
||||
private useToolAt(ptr: Phaser.Input.Pointer): void {
|
||||
const cam = this.scene.cameras.main
|
||||
const worldX = cam.scrollX + ptr.x
|
||||
const worldY = cam.scrollY + ptr.y
|
||||
const worldX = ptr.worldX
|
||||
const worldY = ptr.worldY
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
const state = stateManager.getState()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
|
||||
import { TileType } from '../types'
|
||||
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import { findPath } from '../utils/pathfinding'
|
||||
@@ -170,6 +171,7 @@ export class VillagerSystem {
|
||||
case 'stockpile':
|
||||
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
rt.idleScanTimer = 0 // scan for a new job immediately after deposit
|
||||
break
|
||||
|
||||
case 'bed':
|
||||
@@ -199,13 +201,19 @@ export class VillagerSystem {
|
||||
const state = stateManager.getState()
|
||||
|
||||
if (job.type === 'chop') {
|
||||
if (state.world.resources[job.targetId]) {
|
||||
const res = state.world.resources[job.targetId]
|
||||
if (res) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
// Clear the FOREST tile so the area becomes passable for future pathfinding
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
}
|
||||
} else if (job.type === 'mine') {
|
||||
if (state.world.resources[job.targetId]) {
|
||||
const res = state.world.resources[job.targetId]
|
||||
if (res) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
// Clear the ROCK tile so the area becomes passable for future pathfinding
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
}
|
||||
} else if (job.type === 'farm') {
|
||||
@@ -217,7 +225,13 @@ export class VillagerSystem {
|
||||
}
|
||||
}
|
||||
|
||||
// Back to idle so decideAction handles depositing
|
||||
// If the harvest produced nothing (resource already gone), clear the stale job
|
||||
// so tickIdle does not try to walk to a stockpile with nothing to deposit.
|
||||
if (!v.job?.carrying || !Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
|
||||
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||
}
|
||||
|
||||
// Back to idle — tickIdle will handle hauling to stockpile if carrying items
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
}
|
||||
|
||||
@@ -342,7 +356,7 @@ export class VillagerSystem {
|
||||
|
||||
this.adapter.send({ type: 'SPAWN_VILLAGER', villager })
|
||||
this.spawnSprite(villager)
|
||||
this.onMessage?.(`${name} has joined the settlement! 🏘`)
|
||||
this.onMessage?.(`${name} the Nisse has arrived! 🏘`)
|
||||
}
|
||||
|
||||
// ─── Sprite management ────────────────────────────────────────────────────
|
||||
|
||||
364
src/test/ZoomMouseScene.ts
Normal file
364
src/test/ZoomMouseScene.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE } from '../config'
|
||||
|
||||
const GRID_TILES = 500 // world size in tiles
|
||||
const MIN_ZOOM = 0.25
|
||||
const MAX_ZOOM = 4.0
|
||||
const ZOOM_STEP = 0.1
|
||||
const MARKER_EVERY = 10 // small crosshair every N tiles
|
||||
const LABEL_EVERY = 50 // coordinate label every N tiles
|
||||
const CAMERA_SPEED = 400 // px/s
|
||||
const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots
|
||||
|
||||
/**
|
||||
* Second test scene: zoom-to-mouse behavior.
|
||||
* After each zoom step, scrollX/Y is corrected so the world point
|
||||
* under the mouse stays at the same screen position.
|
||||
*
|
||||
* Formula:
|
||||
* newScrollX = scrollX + (mouseX - screenW/2) * (1/zoomBefore - 1/zoomAfter)
|
||||
* newScrollY = scrollY + (mouseY - screenH/2) * (1/zoomBefore - 1/zoomAfter)
|
||||
*
|
||||
* Controls: Scroll wheel to zoom, WASD / Arrow keys to pan, Tab to switch scene.
|
||||
*/
|
||||
export class ZoomMouseScene extends Phaser.Scene {
|
||||
private logText!: Phaser.GameObjects.Text
|
||||
private hudCamera!: Phaser.Cameras.Scene2D.Camera
|
||||
private worldObjects: Phaser.GameObjects.GameObject[] = []
|
||||
private hudObjects: Phaser.GameObjects.GameObject[] = []
|
||||
private keys!: {
|
||||
up: Phaser.Input.Keyboard.Key
|
||||
down: Phaser.Input.Keyboard.Key
|
||||
left: Phaser.Input.Keyboard.Key
|
||||
right: Phaser.Input.Keyboard.Key
|
||||
w: Phaser.Input.Keyboard.Key
|
||||
s: Phaser.Input.Keyboard.Key
|
||||
a: Phaser.Input.Keyboard.Key
|
||||
d: Phaser.Input.Keyboard.Key
|
||||
tab: Phaser.Input.Keyboard.Key
|
||||
}
|
||||
private snapshotTimer = 0
|
||||
|
||||
constructor() {
|
||||
super({ key: 'ZoomMouse' })
|
||||
}
|
||||
|
||||
create(): void {
|
||||
fetch('/api/log', { method: 'DELETE' })
|
||||
this.writeLog('scene_start', { scene: 'ZoomMouse', tileSize: TILE_SIZE, gridTiles: GRID_TILES })
|
||||
|
||||
this.drawGrid()
|
||||
this.setupCamera()
|
||||
this.setupInput()
|
||||
this.createHUD()
|
||||
this.setupCameras()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the static world grid into world space.
|
||||
* All objects are registered in worldObjects for HUD-camera exclusion.
|
||||
*/
|
||||
private drawGrid(): void {
|
||||
const worldPx = GRID_TILES * TILE_SIZE
|
||||
const g = this.add.graphics()
|
||||
this.worldObjects.push(g)
|
||||
|
||||
g.fillStyle(0x111318)
|
||||
g.fillRect(0, 0, worldPx, worldPx)
|
||||
|
||||
g.lineStyle(1, 0x222233, 0.5)
|
||||
for (let i = 0; i <= GRID_TILES; i++) {
|
||||
const p = i * TILE_SIZE
|
||||
g.lineBetween(p, 0, p, worldPx)
|
||||
g.lineBetween(0, p, worldPx, p)
|
||||
}
|
||||
|
||||
for (let tx = 0; tx <= GRID_TILES; tx += MARKER_EVERY) {
|
||||
for (let ty = 0; ty <= GRID_TILES; ty += MARKER_EVERY) {
|
||||
const px = tx * TILE_SIZE
|
||||
const py = ty * TILE_SIZE
|
||||
const isLabel = tx % LABEL_EVERY === 0 && ty % LABEL_EVERY === 0
|
||||
const color = isLabel ? 0xffff00 : 0x44aaff
|
||||
const arm = isLabel ? 10 : 6
|
||||
|
||||
g.lineStyle(1, color, isLabel ? 1.0 : 0.7)
|
||||
g.lineBetween(px - arm, py, px + arm, py)
|
||||
g.lineBetween(px, py - arm, px, py + arm)
|
||||
|
||||
g.fillStyle(color, 1.0)
|
||||
g.fillCircle(px, py, isLabel ? 2.5 : 1.5)
|
||||
}
|
||||
}
|
||||
|
||||
for (let tx = 0; tx <= GRID_TILES; tx += LABEL_EVERY) {
|
||||
for (let ty = 0; ty <= GRID_TILES; ty += LABEL_EVERY) {
|
||||
const label = this.add.text(
|
||||
tx * TILE_SIZE + 4,
|
||||
ty * TILE_SIZE + 4,
|
||||
`${tx},${ty}`,
|
||||
{ fontSize: '9px', color: '#aaddff', fontFamily: 'monospace' }
|
||||
).setDepth(1)
|
||||
this.worldObjects.push(label)
|
||||
}
|
||||
}
|
||||
|
||||
g.lineStyle(2, 0xff8844, 1.0)
|
||||
g.strokeRect(0, 0, worldPx, worldPx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets camera bounds and centers the view on the world.
|
||||
*/
|
||||
private setupCamera(): void {
|
||||
const cam = this.cameras.main
|
||||
const worldPx = GRID_TILES * TILE_SIZE
|
||||
cam.setBounds(0, 0, worldPx, worldPx)
|
||||
cam.scrollX = worldPx / 2 - cam.width / 2
|
||||
cam.scrollY = worldPx / 2 - cam.height / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers scroll wheel zoom with mouse-anchor correction and keyboard keys.
|
||||
* After cam.setZoom(), scrollX/Y is adjusted so the world point under the
|
||||
* mouse stays at the same screen position.
|
||||
*/
|
||||
private setupInput(): void {
|
||||
const cam = this.cameras.main
|
||||
const kb = this.input.keyboard!
|
||||
|
||||
this.keys = {
|
||||
up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP),
|
||||
down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN),
|
||||
left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT),
|
||||
right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT),
|
||||
w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
||||
s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
||||
a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
||||
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
||||
tab: kb.addKey(Phaser.Input.Keyboard.KeyCodes.TAB),
|
||||
}
|
||||
|
||||
// Prevent Tab from switching browser focus
|
||||
;(this.keys.tab as unknown as { preventDefault: boolean }).preventDefault = true
|
||||
|
||||
this.keys.tab.on('down', () => {
|
||||
this.scene.start('ZoomTest')
|
||||
})
|
||||
|
||||
this.input.on('wheel', (
|
||||
ptr: Phaser.Input.Pointer,
|
||||
_objs: unknown,
|
||||
_dx: number,
|
||||
dy: number
|
||||
) => {
|
||||
const zoomBefore = cam.zoom
|
||||
const scrollXBefore = cam.scrollX
|
||||
const scrollYBefore = cam.scrollY
|
||||
|
||||
const newZoom = Phaser.Math.Clamp(zoomBefore - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
||||
cam.setZoom(newZoom)
|
||||
|
||||
// Correct scroll so the world point under the mouse stays fixed.
|
||||
// Phaser zooms from screen center, so the offset from center determines the shift.
|
||||
const cw = cam.width
|
||||
const ch = cam.height
|
||||
const factor = 1 / zoomBefore - 1 / newZoom
|
||||
cam.scrollX += (ptr.x - cw / 2) * factor
|
||||
cam.scrollY += (ptr.y - ch / 2) * factor
|
||||
|
||||
// Clamp to world bounds
|
||||
const worldPx = GRID_TILES * TILE_SIZE
|
||||
cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, worldPx - cw / cam.zoom)
|
||||
cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, worldPx - ch / cam.zoom)
|
||||
|
||||
setTimeout(() => {
|
||||
this.writeLog('zoom', {
|
||||
direction: dy > 0 ? 'out' : 'in',
|
||||
zoomBefore: +zoomBefore.toFixed(4),
|
||||
zoomAfter: +cam.zoom.toFixed(4),
|
||||
scrollX_before: +scrollXBefore.toFixed(2),
|
||||
scrollY_before: +scrollYBefore.toFixed(2),
|
||||
scrollX_after: +cam.scrollX.toFixed(2),
|
||||
scrollY_after: +cam.scrollY.toFixed(2),
|
||||
scrollX_delta: +(cam.scrollX - scrollXBefore).toFixed(2),
|
||||
scrollY_delta: +(cam.scrollY - scrollYBefore).toFixed(2),
|
||||
mouseScreen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) },
|
||||
mouseWorld_before: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) },
|
||||
centerWorld_after: {
|
||||
x: +(cam.scrollX + cam.width / 2).toFixed(2),
|
||||
y: +(cam.scrollY + cam.height / 2).toFixed(2),
|
||||
},
|
||||
vpTiles_after: {
|
||||
w: +((cam.width / cam.zoom) / TILE_SIZE).toFixed(3),
|
||||
h: +((cam.height / cam.zoom) / TILE_SIZE).toFixed(3),
|
||||
},
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all HUD elements: log overlay and screen-center crosshair.
|
||||
* All objects are registered in hudObjects for main-camera exclusion.
|
||||
*/
|
||||
private createHUD(): void {
|
||||
const w = this.scale.width
|
||||
const h = this.scale.height
|
||||
|
||||
const cross = this.add.graphics()
|
||||
const arm = 16
|
||||
cross.lineStyle(1, 0xff2222, 0.9)
|
||||
cross.lineBetween(w / 2 - arm, h / 2, w / 2 + arm, h / 2)
|
||||
cross.lineBetween(w / 2, h / 2 - arm, w / 2, h / 2 + arm)
|
||||
cross.fillStyle(0xff2222, 1.0)
|
||||
cross.fillCircle(w / 2, h / 2, 2)
|
||||
this.hudObjects.push(cross)
|
||||
|
||||
this.logText = this.add.text(10, 10, '', {
|
||||
fontSize: '13px',
|
||||
color: '#e8e8e8',
|
||||
backgroundColor: '#000000bb',
|
||||
padding: { x: 10, y: 8 },
|
||||
lineSpacing: 3,
|
||||
fontFamily: 'monospace',
|
||||
}).setDepth(100)
|
||||
this.hudObjects.push(this.logText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a dedicated HUD camera (zoom=1, no scroll) and separates
|
||||
* world objects from HUD objects so neither camera renders both layers.
|
||||
*/
|
||||
private setupCameras(): void {
|
||||
this.hudCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height)
|
||||
this.hudCamera.setScroll(0, 0)
|
||||
this.hudCamera.setZoom(1)
|
||||
|
||||
this.cameras.main.ignore(this.hudObjects)
|
||||
this.hudCamera.ignore(this.worldObjects)
|
||||
}
|
||||
|
||||
update(_time: number, delta: number): void {
|
||||
this.handleKeyboard(delta)
|
||||
this.updateOverlay()
|
||||
|
||||
this.snapshotTimer += delta
|
||||
if (this.snapshotTimer >= SNAPSHOT_EVERY) {
|
||||
this.snapshotTimer = 0
|
||||
this.writeSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves camera with WASD / arrow keys at CAMERA_SPEED px/s (world space).
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
private handleKeyboard(delta: number): void {
|
||||
const cam = this.cameras.main
|
||||
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
|
||||
const worldPx = GRID_TILES * TILE_SIZE
|
||||
|
||||
let dx = 0, dy = 0
|
||||
if (this.keys.left.isDown || this.keys.a.isDown) dx -= speed
|
||||
if (this.keys.right.isDown || this.keys.d.isDown) dx += speed
|
||||
if (this.keys.up.isDown || this.keys.w.isDown) dy -= speed
|
||||
if (this.keys.down.isDown || this.keys.s.isDown) dy += speed
|
||||
|
||||
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
||||
|
||||
cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldPx - cam.width / cam.zoom)
|
||||
cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldPx - cam.height / cam.zoom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes and renders all diagnostic values to the HUD overlay each frame.
|
||||
* centerWorld uses the corrected formula: scrollX + screenWidth/2.
|
||||
*/
|
||||
private updateOverlay(): void {
|
||||
const cam = this.cameras.main
|
||||
const ptr = this.input.activePointer
|
||||
|
||||
const vpWidthPx = cam.width / cam.zoom
|
||||
const vpHeightPx = cam.height / cam.zoom
|
||||
const vpWidthTiles = vpWidthPx / TILE_SIZE
|
||||
const vpHeightTiles = vpHeightPx / TILE_SIZE
|
||||
|
||||
// Phaser zooms from screen center, so visual center = scrollX + screenWidth/2
|
||||
const centerWorldX = cam.scrollX + cam.width / 2
|
||||
const centerWorldY = cam.scrollY + cam.height / 2
|
||||
const centerTileX = Math.floor(centerWorldX / TILE_SIZE)
|
||||
const centerTileY = Math.floor(centerWorldY / TILE_SIZE)
|
||||
|
||||
const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||
const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||
const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas'
|
||||
|
||||
const lines = [
|
||||
'── ZOOM TEST [Zoom-to-Mouse] ──',
|
||||
'',
|
||||
`Zoom: ${cam.zoom.toFixed(4)}`,
|
||||
`scrollX / scrollY: ${cam.scrollX.toFixed(2)} / ${cam.scrollY.toFixed(2)}`,
|
||||
'',
|
||||
`Viewport (screen): ${cam.width} × ${cam.height} px`,
|
||||
`Viewport (world): ${vpWidthPx.toFixed(2)} × ${vpHeightPx.toFixed(2)} px`,
|
||||
`Viewport (tiles): ${vpWidthTiles.toFixed(3)} × ${vpHeightTiles.toFixed(3)}`,
|
||||
'',
|
||||
`Center world: ${centerWorldX.toFixed(2)}, ${centerWorldY.toFixed(2)}`,
|
||||
`Center tile: ${centerTileX}, ${centerTileY}`,
|
||||
'',
|
||||
`Mouse screen: ${ptr.x.toFixed(1)}, ${ptr.y.toFixed(1)}`,
|
||||
`Mouse world: ${ptr.worldX.toFixed(2)}, ${ptr.worldY.toFixed(2)}`,
|
||||
`Mouse tile: ${mouseTileX}, ${mouseTileY}`,
|
||||
'',
|
||||
`Canvas: ${this.scale.width} × ${this.scale.height} px`,
|
||||
`TILE_SIZE: ${TILE_SIZE} px`,
|
||||
`roundPixels: ${(this.game.renderer.config as Record<string, unknown>)['roundPixels']}`,
|
||||
`Renderer: ${renderer}`,
|
||||
'',
|
||||
'[Scroll] Zoom [WASD / ↑↓←→] Pan [Tab] → Default',
|
||||
]
|
||||
|
||||
this.logText.setText(lines)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a periodic full-state snapshot to the log.
|
||||
*/
|
||||
private writeSnapshot(): void {
|
||||
const cam = this.cameras.main
|
||||
const ptr = this.input.activePointer
|
||||
const vpW = cam.width / cam.zoom
|
||||
const vpH = cam.height / cam.zoom
|
||||
|
||||
this.writeLog('snapshot', {
|
||||
zoom: +cam.zoom.toFixed(4),
|
||||
scrollX: +cam.scrollX.toFixed(2),
|
||||
scrollY: +cam.scrollY.toFixed(2),
|
||||
vpScreen: { w: cam.width, h: cam.height },
|
||||
vpWorld: { w: +vpW.toFixed(2), h: +vpH.toFixed(2) },
|
||||
vpTiles: { w: +((vpW / TILE_SIZE).toFixed(3)), h: +((vpH / TILE_SIZE).toFixed(3)) },
|
||||
centerWorld: {
|
||||
x: +(cam.scrollX + cam.width / 2).toFixed(2),
|
||||
y: +(cam.scrollY + cam.height / 2).toFixed(2),
|
||||
},
|
||||
mouse: {
|
||||
screen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) },
|
||||
world: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POSTs a structured log entry to the Vite dev server middleware.
|
||||
* @param event - Event type label
|
||||
* @param data - Payload object to serialize as JSON
|
||||
*/
|
||||
private writeLog(event: string, data: Record<string, unknown>): void {
|
||||
const entry = JSON.stringify({ t: Date.now(), event, ...data })
|
||||
fetch('/api/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: entry,
|
||||
}).catch(() => { /* swallow if dev server not running */ })
|
||||
}
|
||||
}
|
||||
352
src/test/ZoomTestScene.ts
Normal file
352
src/test/ZoomTestScene.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE } from '../config'
|
||||
|
||||
const GRID_TILES = 500 // world size in tiles
|
||||
const MIN_ZOOM = 0.25
|
||||
const MAX_ZOOM = 4.0
|
||||
const ZOOM_STEP = 0.1
|
||||
const MARKER_EVERY = 10 // small crosshair every N tiles
|
||||
const LABEL_EVERY = 50 // coordinate label every N tiles
|
||||
const CAMERA_SPEED = 400 // px/s
|
||||
const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots
|
||||
|
||||
/**
|
||||
* First test scene: observes pure Phaser default zoom behavior.
|
||||
* No custom scroll compensation — cam.setZoom() only, zoom anchors to camera center.
|
||||
* Logs zoom events and periodic snapshots to /api/log (written to game-test.log).
|
||||
*
|
||||
* Controls: Scroll wheel to zoom, WASD / Arrow keys to pan.
|
||||
*/
|
||||
export class ZoomTestScene extends Phaser.Scene {
|
||||
private logText!: Phaser.GameObjects.Text
|
||||
private hudCamera!: Phaser.Cameras.Scene2D.Camera
|
||||
private worldObjects: Phaser.GameObjects.GameObject[] = []
|
||||
private hudObjects: Phaser.GameObjects.GameObject[] = []
|
||||
private keys!: {
|
||||
up: Phaser.Input.Keyboard.Key
|
||||
down: Phaser.Input.Keyboard.Key
|
||||
left: Phaser.Input.Keyboard.Key
|
||||
right: Phaser.Input.Keyboard.Key
|
||||
w: Phaser.Input.Keyboard.Key
|
||||
s: Phaser.Input.Keyboard.Key
|
||||
a: Phaser.Input.Keyboard.Key
|
||||
d: Phaser.Input.Keyboard.Key
|
||||
tab: Phaser.Input.Keyboard.Key
|
||||
}
|
||||
private snapshotTimer = 0
|
||||
|
||||
constructor() {
|
||||
super({ key: 'ZoomTest' })
|
||||
}
|
||||
|
||||
create(): void {
|
||||
// Clear log file at scene start
|
||||
fetch('/api/log', { method: 'DELETE' })
|
||||
this.writeLog('scene_start', { tileSize: TILE_SIZE, gridTiles: GRID_TILES })
|
||||
|
||||
this.drawGrid()
|
||||
this.setupCamera()
|
||||
this.setupInput()
|
||||
this.createHUD()
|
||||
this.setupCameras()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the static world grid into world space.
|
||||
* All objects are registered in worldObjects for HUD-camera exclusion.
|
||||
*/
|
||||
private drawGrid(): void {
|
||||
const worldPx = GRID_TILES * TILE_SIZE
|
||||
const g = this.add.graphics()
|
||||
this.worldObjects.push(g)
|
||||
|
||||
// Background fill
|
||||
g.fillStyle(0x111811)
|
||||
g.fillRect(0, 0, worldPx, worldPx)
|
||||
|
||||
// Tile grid lines
|
||||
g.lineStyle(1, 0x223322, 0.5)
|
||||
for (let i = 0; i <= GRID_TILES; i++) {
|
||||
const p = i * TILE_SIZE
|
||||
g.lineBetween(p, 0, p, worldPx)
|
||||
g.lineBetween(0, p, worldPx, p)
|
||||
}
|
||||
|
||||
// Crosshair markers
|
||||
for (let tx = 0; tx <= GRID_TILES; tx += MARKER_EVERY) {
|
||||
for (let ty = 0; ty <= GRID_TILES; ty += MARKER_EVERY) {
|
||||
const px = tx * TILE_SIZE
|
||||
const py = ty * TILE_SIZE
|
||||
const isLabel = tx % LABEL_EVERY === 0 && ty % LABEL_EVERY === 0
|
||||
const color = isLabel ? 0xffff00 : 0x00ff88
|
||||
const arm = isLabel ? 10 : 6
|
||||
|
||||
g.lineStyle(1, color, isLabel ? 1.0 : 0.7)
|
||||
g.lineBetween(px - arm, py, px + arm, py)
|
||||
g.lineBetween(px, py - arm, px, py + arm)
|
||||
|
||||
g.fillStyle(color, 1.0)
|
||||
g.fillCircle(px, py, isLabel ? 2.5 : 1.5)
|
||||
}
|
||||
}
|
||||
|
||||
// Coordinate labels at LABEL_EVERY intersections
|
||||
for (let tx = 0; tx <= GRID_TILES; tx += LABEL_EVERY) {
|
||||
for (let ty = 0; ty <= GRID_TILES; ty += LABEL_EVERY) {
|
||||
const label = this.add.text(
|
||||
tx * TILE_SIZE + 4,
|
||||
ty * TILE_SIZE + 4,
|
||||
`${tx},${ty}`,
|
||||
{ fontSize: '9px', color: '#ffff88', fontFamily: 'monospace' }
|
||||
).setDepth(1)
|
||||
this.worldObjects.push(label)
|
||||
}
|
||||
}
|
||||
|
||||
// World border
|
||||
g.lineStyle(2, 0xff4444, 1.0)
|
||||
g.strokeRect(0, 0, worldPx, worldPx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets camera bounds and centers the view on the world.
|
||||
*/
|
||||
private setupCamera(): void {
|
||||
const cam = this.cameras.main
|
||||
const worldPx = GRID_TILES * TILE_SIZE
|
||||
cam.setBounds(0, 0, worldPx, worldPx)
|
||||
cam.scrollX = worldPx / 2 - cam.width / 2
|
||||
cam.scrollY = worldPx / 2 - cam.height / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers scroll wheel zoom and stores keyboard key references.
|
||||
* Zoom uses cam.setZoom() only — pure Phaser default, anchors to camera center.
|
||||
* Each zoom event is logged immediately with before/after state.
|
||||
*/
|
||||
private setupInput(): void {
|
||||
const cam = this.cameras.main
|
||||
const kb = this.input.keyboard!
|
||||
|
||||
this.keys = {
|
||||
up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP),
|
||||
down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN),
|
||||
left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT),
|
||||
right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT),
|
||||
w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
||||
s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
||||
a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
||||
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
||||
tab: kb.addKey(Phaser.Input.Keyboard.KeyCodes.TAB),
|
||||
}
|
||||
|
||||
;(this.keys.tab as unknown as { preventDefault: boolean }).preventDefault = true
|
||||
this.keys.tab.on('down', () => {
|
||||
this.scene.start('ZoomMouse')
|
||||
})
|
||||
|
||||
this.input.on('wheel', (
|
||||
ptr: Phaser.Input.Pointer,
|
||||
_objs: unknown,
|
||||
_dx: number,
|
||||
dy: number
|
||||
) => {
|
||||
const zoomBefore = cam.zoom
|
||||
const scrollXBefore = cam.scrollX
|
||||
const scrollYBefore = cam.scrollY
|
||||
|
||||
const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
||||
cam.setZoom(newZoom)
|
||||
|
||||
setTimeout(() => {
|
||||
this.writeLog('zoom', {
|
||||
direction: dy > 0 ? 'out' : 'in',
|
||||
zoomBefore: +zoomBefore.toFixed(4),
|
||||
zoomAfter: +cam.zoom.toFixed(4),
|
||||
scrollX_before: +scrollXBefore.toFixed(2),
|
||||
scrollY_before: +scrollYBefore.toFixed(2),
|
||||
scrollX_after: +cam.scrollX.toFixed(2),
|
||||
scrollY_after: +cam.scrollY.toFixed(2),
|
||||
scrollX_delta: +(cam.scrollX - scrollXBefore).toFixed(2),
|
||||
scrollY_delta: +(cam.scrollY - scrollYBefore).toFixed(2),
|
||||
mouseScreen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) },
|
||||
mouseWorld: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) },
|
||||
centerWorld_after: {
|
||||
x: +(cam.scrollX + (cam.width / cam.zoom) / 2).toFixed(2),
|
||||
y: +(cam.scrollY + (cam.height / cam.zoom) / 2).toFixed(2),
|
||||
},
|
||||
vpTiles_after: {
|
||||
w: +((cam.width / cam.zoom) / TILE_SIZE).toFixed(3),
|
||||
h: +((cam.height / cam.zoom) / TILE_SIZE).toFixed(3),
|
||||
},
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all HUD elements: log overlay and screen-center crosshair.
|
||||
* All objects are registered in hudObjects for main-camera exclusion.
|
||||
* Uses a dedicated HUD camera (zoom=1, fixed) so elements are never scaled.
|
||||
*/
|
||||
private createHUD(): void {
|
||||
const w = this.scale.width
|
||||
const h = this.scale.height
|
||||
|
||||
// Screen-center crosshair (red)
|
||||
const cross = this.add.graphics()
|
||||
const arm = 16
|
||||
cross.lineStyle(1, 0xff2222, 0.9)
|
||||
cross.lineBetween(w / 2 - arm, h / 2, w / 2 + arm, h / 2)
|
||||
cross.lineBetween(w / 2, h / 2 - arm, w / 2, h / 2 + arm)
|
||||
cross.fillStyle(0xff2222, 1.0)
|
||||
cross.fillCircle(w / 2, h / 2, 2)
|
||||
this.hudObjects.push(cross)
|
||||
|
||||
// Log text overlay
|
||||
this.logText = this.add.text(10, 10, '', {
|
||||
fontSize: '13px',
|
||||
color: '#e8e8e8',
|
||||
backgroundColor: '#000000bb',
|
||||
padding: { x: 10, y: 8 },
|
||||
lineSpacing: 3,
|
||||
fontFamily: 'monospace',
|
||||
}).setDepth(100)
|
||||
this.hudObjects.push(this.logText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a dedicated HUD camera (zoom=1, no scroll) and separates
|
||||
* world objects from HUD objects so neither camera renders both layers.
|
||||
*/
|
||||
private setupCameras(): void {
|
||||
this.hudCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height)
|
||||
this.hudCamera.setScroll(0, 0)
|
||||
this.hudCamera.setZoom(1)
|
||||
|
||||
this.cameras.main.ignore(this.hudObjects)
|
||||
this.hudCamera.ignore(this.worldObjects)
|
||||
}
|
||||
|
||||
update(_time: number, delta: number): void {
|
||||
this.handleKeyboard(delta)
|
||||
this.updateOverlay()
|
||||
|
||||
this.snapshotTimer += delta
|
||||
if (this.snapshotTimer >= SNAPSHOT_EVERY) {
|
||||
this.snapshotTimer = 0
|
||||
this.writeSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves camera with WASD / arrow keys at CAMERA_SPEED px/s (world space).
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
private handleKeyboard(delta: number): void {
|
||||
const cam = this.cameras.main
|
||||
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
|
||||
const worldPx = GRID_TILES * TILE_SIZE
|
||||
|
||||
let dx = 0, dy = 0
|
||||
if (this.keys.left.isDown || this.keys.a.isDown) dx -= speed
|
||||
if (this.keys.right.isDown || this.keys.d.isDown) dx += speed
|
||||
if (this.keys.up.isDown || this.keys.w.isDown) dy -= speed
|
||||
if (this.keys.down.isDown || this.keys.s.isDown) dy += speed
|
||||
|
||||
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
||||
|
||||
cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldPx - cam.width / cam.zoom)
|
||||
cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldPx - cam.height / cam.zoom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes and renders all diagnostic values to the HUD overlay each frame.
|
||||
*/
|
||||
private updateOverlay(): void {
|
||||
const cam = this.cameras.main
|
||||
const ptr = this.input.activePointer
|
||||
|
||||
const vpWidthPx = cam.width / cam.zoom
|
||||
const vpHeightPx = cam.height / cam.zoom
|
||||
const vpWidthTiles = vpWidthPx / TILE_SIZE
|
||||
const vpHeightTiles = vpHeightPx / TILE_SIZE
|
||||
// Phaser zooms from screen center: visual center = scrollX + screenWidth/2
|
||||
const centerWorldX = cam.scrollX + cam.width / 2
|
||||
const centerWorldY = cam.scrollY + cam.height / 2
|
||||
const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||
const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||
const centerTileX = Math.floor(centerWorldX / TILE_SIZE)
|
||||
const centerTileY = Math.floor(centerWorldY / TILE_SIZE)
|
||||
const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas'
|
||||
|
||||
const lines = [
|
||||
'── ZOOM TEST [Phaser default] ──',
|
||||
'',
|
||||
`Zoom: ${cam.zoom.toFixed(4)}`,
|
||||
`scrollX / scrollY: ${cam.scrollX.toFixed(2)} / ${cam.scrollY.toFixed(2)}`,
|
||||
'',
|
||||
`Viewport (screen): ${cam.width} × ${cam.height} px`,
|
||||
`Viewport (world): ${vpWidthPx.toFixed(2)} × ${vpHeightPx.toFixed(2)} px`,
|
||||
`Viewport (tiles): ${vpWidthTiles.toFixed(3)} × ${vpHeightTiles.toFixed(3)}`,
|
||||
'',
|
||||
`Center world: ${centerWorldX.toFixed(2)}, ${centerWorldY.toFixed(2)}`,
|
||||
`Center tile: ${centerTileX}, ${centerTileY}`,
|
||||
'',
|
||||
`Mouse screen: ${ptr.x.toFixed(1)}, ${ptr.y.toFixed(1)}`,
|
||||
`Mouse world: ${ptr.worldX.toFixed(2)}, ${ptr.worldY.toFixed(2)}`,
|
||||
`Mouse tile: ${mouseTileX}, ${mouseTileY}`,
|
||||
'',
|
||||
`Canvas: ${this.scale.width} × ${this.scale.height} px`,
|
||||
`TILE_SIZE: ${TILE_SIZE} px`,
|
||||
`roundPixels: ${(this.game.renderer.config as Record<string, unknown>)['roundPixels']}`,
|
||||
`Renderer: ${renderer}`,
|
||||
'',
|
||||
'[Scroll] Zoom [WASD / ↑↓←→] Pan [Tab] → Mouse',
|
||||
]
|
||||
|
||||
this.logText.setText(lines)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a periodic full-state snapshot to the log.
|
||||
*/
|
||||
private writeSnapshot(): void {
|
||||
const cam = this.cameras.main
|
||||
const ptr = this.input.activePointer
|
||||
const vpW = cam.width / cam.zoom
|
||||
const vpH = cam.height / cam.zoom
|
||||
|
||||
this.writeLog('snapshot', {
|
||||
zoom: +cam.zoom.toFixed(4),
|
||||
scrollX: +cam.scrollX.toFixed(2),
|
||||
scrollY: +cam.scrollY.toFixed(2),
|
||||
vpScreen: { w: cam.width, h: cam.height },
|
||||
vpWorld: { w: +vpW.toFixed(2), h: +vpH.toFixed(2) },
|
||||
vpTiles: { w: +((vpW / TILE_SIZE).toFixed(3)), h: +((vpH / TILE_SIZE).toFixed(3)) },
|
||||
centerWorld: {
|
||||
x: +(cam.scrollX + cam.width / 2).toFixed(2),
|
||||
y: +(cam.scrollY + cam.height / 2).toFixed(2),
|
||||
},
|
||||
mouse: {
|
||||
screen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) },
|
||||
world: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POSTs a structured log entry to the Vite dev server middleware.
|
||||
* Written to game-test.log in the project root.
|
||||
* @param event - Event type label
|
||||
* @param data - Payload object to serialize as JSON
|
||||
*/
|
||||
private writeLog(event: string, data: Record<string, unknown>): void {
|
||||
const entry = JSON.stringify({ t: Date.now(), event, ...data })
|
||||
fetch('/api/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: entry,
|
||||
}).catch(() => { /* swallow if dev server not running */ })
|
||||
}
|
||||
}
|
||||
22
src/test/main.ts
Normal file
22
src/test/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Phaser from 'phaser'
|
||||
import { ZoomTestScene } from './ZoomTestScene'
|
||||
import { ZoomMouseScene } from './ZoomMouseScene'
|
||||
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
backgroundColor: '#0d1a0d',
|
||||
scene: [ZoomTestScene, ZoomMouseScene],
|
||||
scale: {
|
||||
mode: Phaser.Scale.RESIZE,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
render: {
|
||||
pixelArt: false,
|
||||
antialias: true,
|
||||
roundPixels: true,
|
||||
},
|
||||
}
|
||||
|
||||
new Phaser.Game(config)
|
||||
16
test.html
Normal file
16
test.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Game — Test Scenes</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #000; overflow: hidden; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/src/test/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +1,47 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { resolve } from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const LOG_FILE = resolve(__dirname, 'game-test.log')
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true
|
||||
host: true,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'game-logger',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/log', (req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
let body = ''
|
||||
req.on('data', chunk => { body += chunk })
|
||||
req.on('end', () => {
|
||||
fs.appendFileSync(LOG_FILE, body + '\n', 'utf8')
|
||||
res.writeHead(200)
|
||||
res.end('ok')
|
||||
})
|
||||
} else if (req.method === 'DELETE') {
|
||||
fs.writeFileSync(LOG_FILE, '', 'utf8')
|
||||
res.writeHead(200)
|
||||
res.end('cleared')
|
||||
} else {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsInlineLimit: 0
|
||||
}
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
test: resolve(__dirname, 'test.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user