|
import Phaser from 'phaser' |
|
import { useEffect, useRef } from 'react' |
|
|
|
import waterBeachTilesetUrl from '../assets/tileset-17b2e5ff-42a8-4212-8448-c51aab68a364/tileset.png' |
|
import waterBeachMeta from '../assets/tileset-17b2e5ff-42a8-4212-8448-c51aab68a364/tileset_meta.json' |
|
import beachGrassTilesetUrl from '../assets/tileset-7509580b-a1cb-49dc-87c3-7f9f7505f5ff/tileset.png' |
|
import beachGrassMeta from '../assets/tileset-7509580b-a1cb-49dc-87c3-7f9f7505f5ff/tileset_meta.json' |
|
|
|
/** |
|
* Creates and configures a new Phaser game instance |
|
* @param container - The HTML div element that will host the Phaser canvas |
|
* @returns A new Phaser.Game instance |
|
*/ |
|
function createGame(container: HTMLDivElement) { |
|
// Define the Phaser game configuration object |
|
const config: Phaser.Types.Core.GameConfig = { |
|
// AUTO lets Phaser choose between WebGL and Canvas rendering automatically |
|
type: Phaser.AUTO, |
|
// The DOM element where Phaser will inject its canvas |
|
parent: container, |
|
// Game viewport dimensions in pixels |
|
width: 800, |
|
height: 600, |
|
// Background color (will be fully covered by tiles but sets ocean tone during load) |
|
backgroundColor: '#0b233d', |
|
pixelArt: true, // crisp scaling for pixel art when zoomed / scaled |
|
// Scene configuration - defines what happens during the game lifecycle |
|
scene: { |
|
/** |
|
* PRELOAD PHASE: Load all assets before the scene starts |
|
* This runs once when the scene is created |
|
*/ |
|
preload() { |
|
// Keys for spritesheets |
|
const waterKey = 'water-tiles' // deep ocean (lower) -> beach (upper) |
|
const beachGrassKey = 'beach-grass-tiles' // beach (lower) -> grass (upper) |
|
this.load.spritesheet(waterKey, waterBeachTilesetUrl as unknown as string, { |
|
frameWidth: 16, |
|
frameHeight: 16, |
|
}) |
|
this.load.spritesheet(beachGrassKey, beachGrassTilesetUrl as unknown as string, { |
|
frameWidth: 16, |
|
frameHeight: 16, |
|
}) |
|
}, |
|
|
|
/** |
|
* CREATE PHASE: Set up the initial game objects and scene |
|
* This runs once after preload() completes successfully |
|
*/ |
|
create() { |
|
const waterKey = 'water-tiles' |
|
const beachGrassKey = 'beach-grass-tiles' |
|
const TILE_SIZE = 16 |
|
const SCALE = 2 |
|
|
|
// Determine how many tiles we need to cover the viewport when scaled |
|
const cols = Math.ceil(800 / (TILE_SIZE * SCALE)) // 25 |
|
const rows = Math.ceil(600 / (TILE_SIZE * SCALE)) // 19 (608px tall after scale -> slight overflow) |
|
|
|
// Create an in-memory blank tilemap |
|
const map = this.make.tilemap({ |
|
tileWidth: TILE_SIZE, |
|
tileHeight: TILE_SIZE, |
|
width: cols, |
|
height: rows, |
|
}) |
|
|
|
// Use the loaded spritesheet as a tileset |
|
const waterTileset = map.addTilesetImage(waterKey, waterKey, TILE_SIZE, TILE_SIZE, 0, 0, 0) |
|
if (!waterTileset) { |
|
// Fail gracefully if tileset didn't load |
|
console.warn('[Semester08] Water tileset failed to register') |
|
return |
|
} |
|
|
|
const waterLayer = map.createBlankLayer('water', waterTileset) |
|
if (!waterLayer) { |
|
console.warn('[Semester08] Failed to create water layer') |
|
return |
|
} |
|
// --- Dynamic Wang Mapping (replaces prior hardcoded mapping) --- |
|
type TileMeta = { |
|
corners: { |
|
NW: 'lower' | 'upper' |
|
NE: 'lower' | 'upper' |
|
SW: 'lower' | 'upper' |
|
SE: 'lower' | 'upper' |
|
} |
|
bounding_box: { x: number; y: number } |
|
name: string |
|
} |
|
const TILESET_COLS = 4 // 4x4 grid per metadata layout |
|
const frameForMask: Record<number, number> = {} |
|
const nameForMask: Record<number, string> = {} |
|
if ( |
|
waterBeachMeta && |
|
(waterBeachMeta as any).tileset_data && |
|
Array.isArray((waterBeachMeta as any).tileset_data.tiles) |
|
) { |
|
const tiles = (waterBeachMeta as any).tileset_data.tiles as TileMeta[] |
|
for (const t of tiles) { |
|
const col = Math.round(t.bounding_box.x / 16) |
|
const row = Math.round(t.bounding_box.y / 16) |
|
const frame = row * TILESET_COLS + col |
|
let mask = 0 |
|
if (t.corners.NW === 'upper') mask |= 1 |
|
if (t.corners.NE === 'upper') mask |= 2 |
|
if (t.corners.SW === 'upper') mask |= 4 |
|
if (t.corners.SE === 'upper') mask |= 8 |
|
// Later entries (if duplicates) will overwrite; metadata expected unique |
|
frameForMask[mask] = frame |
|
nameForMask[mask] = t.name |
|
} |
|
} else { |
|
console.warn('[Semester08] Missing or invalid waterBeachMeta; falling back to defaults') |
|
} |
|
const DEEP_WATER_FRAME = frameForMask[0] ?? 6 // all-lower (water) tile |
|
function pickFrame(mask: number): number { |
|
return frameForMask[mask] ?? frameForMask[15] ?? 12 // fallback to all-upper beach |
|
} |
|
waterLayer.fill(DEEP_WATER_FRAME, 0, 0, cols, rows) |
|
|
|
// --- Island + Shoreline Generation (Iteration 3) --- |
|
// We create a second blank layer using SAME map & tileset for shoreline transitions (water->beach) |
|
// then a third layer for grass core (using beach->grass tileset) for simple 2-tile thick beach ring. |
|
|
|
// Prepare shoreline layer (uses water->beach tileset for transitions) |
|
const shoreLayer = map.createBlankLayer('shoreline', waterTileset) |
|
if (!shoreLayer) { |
|
console.warn('[Semester08] Failed to create shoreline layer') |
|
return |
|
} |
|
|
|
// Add second tileset for beach->grass and create grass layer |
|
const beachGrassTileset = map.addTilesetImage( |
|
beachGrassKey, |
|
beachGrassKey, |
|
TILE_SIZE, |
|
TILE_SIZE, |
|
0, |
|
0, |
|
0, |
|
) |
|
if (!beachGrassTileset) { |
|
console.warn('[Semester08] Beach->Grass tileset failed to register') |
|
} |
|
const grassLayer = beachGrassTileset |
|
? map.createBlankLayer('grass', beachGrassTileset) |
|
: null |
|
|
|
// --- Iteration 4 Enhancements --- |
|
// Irregular island parameters (moderate rugged) derived from base ellipse radii |
|
const centerX = cols / 2 |
|
const centerY = rows / 2 |
|
const baseRadiusX = 12 |
|
const baseRadiusY = 8 |
|
const BASE_BEACH_RING = 2 // base tiles inward before grass begins |
|
const BEACH_RING_VARIATION = 1 // +/-1 tile variation (enabled) |
|
const RADIAL_NOISE_AMPLITUDE = 0.2 // moderate rugged coastline amplitude |
|
const EDGE_WATER_MARGIN = 1 // guarantee at least this many tiles of water around island |
|
// User feedback (Iteration): Grass edge slightly pushed coastline outward on left side. |
|
// We introduce a LEFT-SIDE bias that increases beach ring thickness for angles facing West (theta≈π or -π) |
|
// pulling grass inward while leaving right side unaffected. |
|
// Additional left-side grass retraction to guarantee visible water + beach band. |
|
// This is a base boost; a dynamic component (scaled by leftFactor^2) is added later. |
|
const LEFT_GRASS_EXTRA = 0.9 |
|
const MIN_LEFT_BEACH_WIDTH = 2.25 // enforce at least this many tiles of beach on far left when coastline nears map edge |
|
|
|
// Deterministic seeds |
|
const SEED_COAST = 137.17 |
|
const SEED_VARIATION = 911.42 |
|
|
|
// Angular noise function for coastline (blend of sines for smoothness) |
|
function angularNoise(theta: number, seed: number): number { |
|
const n1 = Math.sin(theta * 3.1 + seed) |
|
const n2 = Math.sin(theta * 5.7 + seed * 1.37) |
|
const n3 = Math.sin(theta * 9.2 + seed * 2.11) * 0.5 |
|
// Combine and normalize to [-1,1] |
|
return (n1 + n2 * 0.6 + n3 * 0.35) / (1 + 0.6 + 0.35) |
|
} |
|
|
|
// Compute maximum allowed radius along an angle for base ellipse: r = 1 / sqrt((cos^2)/a^2 + (sin^2)/b^2) |
|
function ellipticalRadiusAt(theta: number, rx: number, ry: number): number { |
|
const cosT = Math.cos(theta) |
|
const sinT = Math.sin(theta) |
|
const denom = Math.sqrt((cosT * cosT) / (rx * rx) + (sinT * sinT) / (ry * ry)) |
|
return denom === 0 ? 0 : 1 / denom |
|
} |
|
|
|
// Determine if a point (continuous tile coordinate) is inside irregular island |
|
function isInsideIsland(px: number, py: number): boolean { |
|
const dx = px - centerX |
|
const dy = py - centerY |
|
const theta = Math.atan2(dy, dx) |
|
const dist = Math.sqrt(dx * dx + dy * dy) |
|
const baseR = ellipticalRadiusAt(theta, baseRadiusX, baseRadiusY) |
|
const noise = angularNoise(theta, SEED_COAST) * RADIAL_NOISE_AMPLITUDE |
|
const desiredR = baseR * (1 + noise) |
|
|
|
// Compute max radius before hitting map boundary along this angle |
|
const cosT = Math.cos(theta) |
|
const sinT = Math.sin(theta) |
|
let maxR = Infinity |
|
// Left boundary (x = 0) |
|
if (cosT < 0) { |
|
const rToLeft = (0 - centerX) / cosT |
|
if (rToLeft > 0) maxR = Math.min(maxR, rToLeft) |
|
} else if (cosT > 0) { |
|
// Right boundary (x = cols) |
|
const rToRight = (cols - centerX) / cosT |
|
if (rToRight > 0) maxR = Math.min(maxR, rToRight) |
|
} |
|
if (sinT < 0) { |
|
const rToTop = (0 - centerY) / sinT |
|
if (rToTop > 0) maxR = Math.min(maxR, rToTop) |
|
} else if (sinT > 0) { |
|
const rToBottom = (rows - centerY) / sinT |
|
if (rToBottom > 0) maxR = Math.min(maxR, rToBottom) |
|
} |
|
if (!isFinite(maxR)) return false |
|
const clampedR = Math.max(0, Math.min(desiredR, maxR - EDGE_WATER_MARGIN)) |
|
return dist <= clampedR |
|
} |
|
|
|
// Beach ring thickness variation by angle (used for grass shrink radii) |
|
function beachRingThicknessAt(theta: number): number { |
|
const n = angularNoise(theta + Math.PI * 0.25, SEED_VARIATION) |
|
const delta = Math.round(n * BEACH_RING_VARIATION) // -1,0,1 typically |
|
return Math.max(1, BASE_BEACH_RING + delta) |
|
} |
|
|
|
// pickFrame already defined above using dynamic metadata |
|
|
|
// Precompute island occupancy (for potential future path/pagoda placement) |
|
const islandMask: boolean[][] = Array.from({ length: rows }, () => Array(cols).fill(false)) |
|
for (let ty = 0; ty < rows; ty++) { |
|
for (let tx = 0; tx < cols; tx++) { |
|
const inNW = isInsideIsland(tx, ty) |
|
const inNE = isInsideIsland(tx + 1, ty) |
|
const inSW = isInsideIsland(tx, ty + 1) |
|
const inSE = isInsideIsland(tx + 1, ty + 1) |
|
if (inNW || inNE || inSW || inSE) islandMask[ty][tx] = true |
|
} |
|
} |
|
|
|
// --- SHORELINE: Perimeter-first marching-squares implementation (Strategy A) --- |
|
if (shoreLayer) { |
|
// 1. Corner occupancy grid |
|
const vertsX = cols + 1 |
|
const vertsY = rows + 1 |
|
const cornerInside: boolean[][] = Array.from({ length: vertsY }, (_, vy) => |
|
Array.from({ length: vertsX }, (_, vx) => isInsideIsland(vx, vy)), |
|
) |
|
|
|
// 2. Initial mask assignment (marching squares) |
|
const tileMask: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)) |
|
const originalMask: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)) |
|
for (let ty = 0; ty < rows; ty++) { |
|
for (let tx = 0; tx < cols; tx++) { |
|
let m = 0 |
|
if (cornerInside[ty][tx]) m |= 1 |
|
if (cornerInside[ty][tx + 1]) m |= 2 |
|
if (cornerInside[ty + 1][tx]) m |= 4 |
|
if (cornerInside[ty + 1][tx + 1]) m |= 8 |
|
tileMask[ty][tx] = m |
|
originalMask[ty][tx] = m |
|
} |
|
} |
|
|
|
// 3. Perimeter identification & first pass frame placement |
|
for (let ty = 0; ty < rows; ty++) { |
|
for (let tx = 0; tx < cols; tx++) { |
|
const m = tileMask[ty][tx] |
|
if (m === 0) continue // pure water |
|
// We will fill interior (m==15) as beach; perimeter and interior both beach layer. |
|
shoreLayer.putTileAt(pickFrame(m), tx, ty) |
|
} |
|
} |
|
|
|
// 4. Ocean classification BEFORE promotions (copy current water set) for exterior detection |
|
const ocean: boolean[][] = Array.from({ length: rows }, () => Array(cols).fill(false)) |
|
const queueOcean: [number, number][] = [] |
|
for (let x = 0; x < cols; x++) { |
|
if (tileMask[0][x] === 0) { |
|
ocean[0][x] = true |
|
queueOcean.push([x, 0]) |
|
} |
|
if (tileMask[rows - 1][x] === 0) { |
|
ocean[rows - 1][x] = true |
|
queueOcean.push([x, rows - 1]) |
|
} |
|
} |
|
for (let y = 0; y < rows; y++) { |
|
if (tileMask[y][0] === 0) { |
|
ocean[y][0] = true |
|
queueOcean.push([0, y]) |
|
} |
|
if (tileMask[y][cols - 1] === 0) { |
|
ocean[y][cols - 1] = true |
|
queueOcean.push([cols - 1, y]) |
|
} |
|
} |
|
let oqi = 0 |
|
while (oqi < queueOcean.length) { |
|
const [x, y] = queueOcean[oqi++] |
|
const n4 = [ |
|
[x - 1, y], |
|
[x + 1, y], |
|
[x, y - 1], |
|
[x, y + 1], |
|
] as const |
|
for (const [nx, ny] of n4) { |
|
if (ny < 0 || ny >= rows || nx < 0 || nx >= cols) continue |
|
if (tileMask[ny][nx] !== 0 || ocean[ny][nx]) continue |
|
ocean[ny][nx] = true |
|
queueOcean.push([nx, ny]) |
|
} |
|
} |
|
|
|
// 5. Promote interior single-corner spikes only (not touching ocean directly on their water corner) |
|
const singleCornerMasks = new Set([1, 2, 4, 8]) |
|
for (let ty = 0; ty < rows; ty++) { |
|
for (let tx = 0; tx < cols; tx++) { |
|
const m = tileMask[ty][tx] |
|
if (!singleCornerMasks.has(m)) continue |
|
// Identify water corner |
|
const waterNW = (m & 1) === 0 |
|
const waterNE = (m & 2) === 0 |
|
const waterSW = (m & 4) === 0 |
|
const waterSE = (m & 8) === 0 |
|
// If that water corner corresponds to an ocean water tile (any adjacent tileMask==0 & ocean) then KEEP as corner |
|
let touchesOcean = false |
|
const checkOcean = (cx: number, cy: number) => { |
|
if (cy < 0 || cy >= rows || cx < 0 || cx >= cols) return false |
|
return tileMask[cy][cx] === 0 && ocean[cy][cx] |
|
} |
|
if ( |
|
waterNW && |
|
(checkOcean(tx - 1, ty - 1) || checkOcean(tx - 1, ty) || checkOcean(tx, ty - 1)) |
|
) |
|
touchesOcean = true |
|
if ( |
|
waterNE && |
|
(checkOcean(tx + 1, ty - 1) || checkOcean(tx + 1, ty) || checkOcean(tx, ty - 1)) |
|
) |
|
touchesOcean = true |
|
if ( |
|
waterSW && |
|
(checkOcean(tx - 1, ty + 1) || checkOcean(tx - 1, ty) || checkOcean(tx, ty + 1)) |
|
) |
|
touchesOcean = true |
|
if ( |
|
waterSE && |
|
(checkOcean(tx + 1, ty + 1) || checkOcean(tx + 1, ty) || checkOcean(tx, ty + 1)) |
|
) |
|
touchesOcean = true |
|
if (touchesOcean) continue // keep the articulated exterior corner |
|
// Interior notch -> promote if orthogonal neighbors are land-ish (non-zero mask) |
|
let orthCount = 0 |
|
const n4 = [ |
|
[tx - 1, ty], |
|
[tx + 1, ty], |
|
[tx, ty - 1], |
|
[tx, ty + 1], |
|
] as const |
|
for (const [nx, ny] of n4) { |
|
if (ny < 0 || ny >= rows || nx < 0 || nx >= cols) continue |
|
if (tileMask[ny][nx] !== 0) orthCount++ |
|
} |
|
if (orthCount >= 2) { |
|
tileMask[ty][tx] = 15 |
|
shoreLayer.putTileAt(pickFrame(15), tx, ty) |
|
} |
|
} |
|
} |
|
|
|
// 6. Revert any previously over-promoted 3-corner (convex) tiles (we removed earlier dent fill so this acts mainly as safeguard) |
|
const threeCornerMasks = new Set([7, 11, 13, 14]) |
|
for (let ty = 0; ty < rows; ty++) { |
|
for (let tx = 0; tx < cols; tx++) { |
|
const orig = originalMask[ty][tx] |
|
if (!threeCornerMasks.has(orig)) continue |
|
// Ensure stored tile remains original; if changed to 15 revert |
|
if (tileMask[ty][tx] === 15) { |
|
tileMask[ty][tx] = orig |
|
shoreLayer.putTileAt(pickFrame(orig), tx, ty) |
|
} |
|
} |
|
} |
|
|
|
// 7. Final safeguard: revert any full-beach tile that was originally a transition (including 3-corner) and touches ocean |
|
// First classify ocean water via BFS from boundary water tiles. |
|
for (let ty = 0; ty < rows; ty++) { |
|
for (let tx = 0; tx < cols; tx++) { |
|
if (tileMask[ty][tx] !== 15) continue |
|
const orig = originalMask[ty][tx] |
|
if (orig === 15 || orig === 0) continue |
|
// If any adjacent water tile is ocean, consider reverting to maintain coastline articulation |
|
let adjacentOcean = false |
|
const n4 = [ |
|
[tx - 1, ty], |
|
[tx + 1, ty], |
|
[tx, ty - 1], |
|
[tx, ty + 1], |
|
] as const |
|
for (const [nx, ny] of n4) { |
|
if (ny < 0 || ny >= rows || nx < 0 || nx >= cols) continue |
|
if (tileMask[ny][nx] === 0 && ocean[ny][nx]) { |
|
adjacentOcean = true |
|
break |
|
} |
|
} |
|
if (adjacentOcean) { |
|
tileMask[ty][tx] = orig |
|
shoreLayer.putTileAt(pickFrame(orig), tx, ty) |
|
} |
|
} |
|
} |
|
|
|
console.warn( |
|
'[Semester08] Shoreline method: perimeter-marching-squares (dynamic Wang mapping)', |
|
) |
|
} |
|
|
|
// --- GRASS PERIMETER: Apply same marching-squares technique for clean beach->grass edge --- |
|
if (grassLayer) { |
|
type TileMetaBG = { |
|
corners: { |
|
NW: 'lower' | 'upper' |
|
NE: 'lower' | 'upper' |
|
SW: 'lower' | 'upper' |
|
SE: 'lower' | 'upper' |
|
} |
|
bounding_box: { x: number; y: number } |
|
} |
|
const bgFrameForMask: Record<number, number> = {} |
|
const TILESET_BG_COLS = 4 |
|
if ( |
|
beachGrassMeta && |
|
(beachGrassMeta as any).tileset_data && |
|
Array.isArray((beachGrassMeta as any).tileset_data.tiles) |
|
) { |
|
const tiles = (beachGrassMeta as any).tileset_data.tiles as TileMetaBG[] |
|
for (const t of tiles) { |
|
const col = Math.round(t.bounding_box.x / 16) |
|
const row = Math.round(t.bounding_box.y / 16) |
|
const frame = row * TILESET_BG_COLS + col |
|
let mask = 0 |
|
if (t.corners.NW === 'upper') mask |= 1 |
|
if (t.corners.NE === 'upper') mask |= 2 |
|
if (t.corners.SW === 'upper') mask |= 4 |
|
if (t.corners.SE === 'upper') mask |= 8 |
|
bgFrameForMask[mask] = frame |
|
} |
|
} else { |
|
console.warn( |
|
'[Semester08] Missing or invalid beachGrassMeta; grass perimeter may be incorrect', |
|
) |
|
} |
|
function pickGrassFrame(mask: number): number { |
|
return bgFrameForMask[mask] ?? bgFrameForMask[15] ?? 12 |
|
} |
|
|
|
// Build grass corner occupancy grid (subset of island, inset by angle-dependent beach ring thickness) |
|
const gvx = cols + 1 |
|
const gvy = rows + 1 |
|
const grassCornerInside: boolean[][] = Array.from({ length: gvy }, (_, vy) => |
|
Array.from({ length: gvx }, (_, vx) => { |
|
const dx = vx - centerX |
|
const dy = vy - centerY |
|
const theta = Math.atan2(dy, dx) |
|
const baseR = ellipticalRadiusAt(theta, baseRadiusX, baseRadiusY) |
|
const noise = angularNoise(theta, SEED_COAST) * RADIAL_NOISE_AMPLITUDE |
|
// Coastline radius before beach ring inset |
|
const coastR = baseR * (1 + noise) |
|
|
|
// Base variable beach ring thickness |
|
const thicknessVar = beachRingThicknessAt(theta) |
|
const leftFactor = Math.max(0, -Math.cos(theta)) // 0 on right, 1 on far left |
|
|
|
// Dynamic left bias: quadratic scaling produces stronger pull at extreme west, gentle near-west. |
|
const dynamicLeftBoost = leftFactor * (LEFT_GRASS_EXTRA + 0.4 * leftFactor) |
|
let effectiveThickness = thicknessVar + dynamicLeftBoost |
|
|
|
// Boundary-aware minimum beach width enforcement (only for left hemisphere) |
|
if (leftFactor > 0) { |
|
// Compute maximum travel to boundary along theta (mirrors logic in isInsideIsland) |
|
const cosT = Math.cos(theta) |
|
const sinT = Math.sin(theta) |
|
let maxR = Infinity |
|
if (cosT < 0) { |
|
const rToLeft = (0 - centerX) / cosT |
|
if (rToLeft > 0) maxR = Math.min(maxR, rToLeft) |
|
} else if (cosT > 0) { |
|
const rToRight = (cols - centerX) / cosT |
|
if (rToRight > 0) maxR = Math.min(maxR, rToRight) |
|
} |
|
if (sinT < 0) { |
|
const rToTop = (0 - centerY) / sinT |
|
if (rToTop > 0) maxR = Math.min(maxR, rToTop) |
|
} else if (sinT > 0) { |
|
const rToBottom = (rows - centerY) / sinT |
|
if (rToBottom > 0) maxR = Math.min(maxR, rToBottom) |
|
} |
|
if (isFinite(maxR)) { |
|
const boundaryClamped = coastR >= maxR - EDGE_WATER_MARGIN - 0.05 |
|
// If coastline is clamped by boundary or leftFactor strong, ensure minimal beach width |
|
if (boundaryClamped || leftFactor > 0.65) { |
|
if (effectiveThickness < MIN_LEFT_BEACH_WIDTH) { |
|
effectiveThickness = MIN_LEFT_BEACH_WIDTH |
|
} |
|
} |
|
} |
|
} |
|
|
|
const grassR = Math.max(0, coastR - effectiveThickness) |
|
const dist = Math.sqrt(dx * dx + dy * dy) |
|
return dist <= grassR |
|
}), |
|
) |
|
|
|
// Generate masks & place frames |
|
for (let ty = 0; ty < rows; ty++) { |
|
for (let tx = 0; tx < cols; tx++) { |
|
let m = 0 |
|
if (grassCornerInside[ty][tx]) m |= 1 |
|
if (grassCornerInside[ty][tx + 1]) m |= 2 |
|
if (grassCornerInside[ty + 1][tx]) m |= 4 |
|
if (grassCornerInside[ty + 1][tx + 1]) m |= 8 |
|
if (m === 0) continue // no grass |
|
grassLayer.putTileAt(pickGrassFrame(m), tx, ty) |
|
} |
|
} |
|
console.warn('[Semester08] Grass perimeter: marching-squares (dynamic Wang mapping)') |
|
|
|
// ---------------- Iteration 5 (Refined): Path Network with smooth marching-squares ---------------- |
|
// Replace coarse tile dilation with corner-distance field producing smooth grass↔sand transitions. |
|
let debugPaths = false |
|
try { |
|
const params = new URLSearchParams(window.location.search) |
|
const dbg = params.get('debug') |
|
if (dbg) debugPaths = dbg.split(',').includes('paths') |
|
} catch {} |
|
|
|
const HUB = { name: 'central', x: 12, y: 10 } |
|
const FOUNDATION_POINTS = [ |
|
HUB, |
|
{ name: 'west', x: 6, y: 7 }, |
|
{ name: 'northEast', x: 17, y: 6 }, |
|
{ name: 'east', x: 20, y: 10 }, |
|
{ name: 'southEast', x: 17, y: 13 }, |
|
] as const |
|
|
|
// Precompute line segments (in corner space; add 0.5 to center them through tile centers) |
|
interface Segment { |
|
x1: number |
|
y1: number |
|
x2: number |
|
y2: number |
|
} |
|
const segments: Segment[] = [] |
|
for (const pt of FOUNDATION_POINTS) { |
|
if (pt === HUB) continue |
|
segments.push({ |
|
x1: HUB.x + 0.5, |
|
y1: HUB.y + 0.5, |
|
x2: pt.x + 0.5, |
|
y2: pt.y + 0.5, |
|
}) |
|
} |
|
|
|
function distPointToSegment(px: number, py: number, s: Segment): number { |
|
const { x1, y1, x2, y2 } = s |
|
const dx = x2 - x1 |
|
const dy = y2 - y1 |
|
const len2 = dx * dx + dy * dy |
|
if (len2 === 0) return Math.hypot(px - x1, py - y1) |
|
let t = ((px - x1) * dx + (py - y1) * dy) / len2 |
|
t = Math.max(0, Math.min(1, t)) |
|
const cx = x1 + t * dx |
|
const cy = y1 + t * dy |
|
return Math.hypot(px - cx, py - cy) |
|
} |
|
|
|
const PATH_HALF_WIDTH = 0.9 // width in corner-space (~tiles); increase for thicker paths |
|
const HUB_RADIUS = 2.0 // circular plaza radius (corner-space) |
|
const pathCornerInside: boolean[][] = Array.from({ length: rows + 1 }, () => |
|
Array(cols + 1).fill(false), |
|
) |
|
const hubCx = HUB.x + 0.5 |
|
const hubCy = HUB.y + 0.5 |
|
|
|
for (let vy = 0; vy <= rows; vy++) { |
|
for (let vx = 0; vx <= cols; vx++) { |
|
if (!grassCornerInside[vy][vx]) continue // never carve outside grass interior |
|
// Distance to hub circle |
|
const dh = Math.hypot(vx - hubCx, vy - hubCy) |
|
if (dh <= HUB_RADIUS) { |
|
pathCornerInside[vy][vx] = true |
|
continue |
|
} |
|
// Min distance to any spoke segment |
|
let minD = Infinity |
|
for (const s of segments) { |
|
const d = distPointToSegment(vx, vy, s) |
|
if (d < minD) minD = d |
|
if (minD <= PATH_HALF_WIDTH) break |
|
} |
|
if (minD <= PATH_HALF_WIDTH) pathCornerInside[vy][vx] = true |
|
} |
|
} |
|
|
|
// Optional mild smoothing: fill isolated gaps where 3 of 4 neighboring corners are inside |
|
for (let vy = 1; vy < rows; vy++) { |
|
for (let vx = 1; vx < cols; vx++) { |
|
if (pathCornerInside[vy][vx]) continue |
|
let cnt = 0 |
|
if (pathCornerInside[vy - 1][vx]) cnt++ |
|
if (pathCornerInside[vy + 1][vx]) cnt++ |
|
if (pathCornerInside[vy][vx - 1]) cnt++ |
|
if (pathCornerInside[vy][vx + 1]) cnt++ |
|
if (cnt >= 3) pathCornerInside[vy][vx] = true |
|
} |
|
} |
|
|
|
const pathLayer = beachGrassTileset |
|
? map.createBlankLayer('paths', beachGrassTileset) |
|
: null |
|
if (!pathLayer) { |
|
console.warn('[Semester08] Failed to create path layer (refined)') |
|
} else { |
|
for (let ty = 0; ty < rows; ty++) { |
|
for (let tx = 0; tx < cols; tx++) { |
|
// Determine if this tile participates in path (any corner inside path) |
|
const cNW = pathCornerInside[ty][tx] |
|
const cNE = pathCornerInside[ty][tx + 1] |
|
const cSW = pathCornerInside[ty + 1][tx] |
|
const cSE = pathCornerInside[ty + 1][tx + 1] |
|
if (!(cNW || cNE || cSW || cSE)) continue |
|
// Build Wang mask: bit=1 => GRASS (upper) at that corner (not path & grass exists) |
|
let mask = 0 |
|
if (!cNW && grassCornerInside[ty][tx]) mask |= 1 |
|
if (!cNE && grassCornerInside[ty][tx + 1]) mask |= 2 |
|
if (!cSW && grassCornerInside[ty + 1][tx]) mask |= 4 |
|
if (!cSE && grassCornerInside[ty + 1][tx + 1]) mask |= 8 |
|
pathLayer.putTileAt(pickGrassFrame(mask), tx, ty) |
|
} |
|
} |
|
if (debugPaths) pathLayer.setAlpha(0.85) |
|
console.warn('[Semester08] Paths layer generated (Iteration 5 refined)') |
|
} |
|
// ------------------------------------------------------------------------------------------- |
|
} |
|
|
|
// --- Post-pass: Clustered shoreline refinement (coves & layered sand) --- |
|
// (Legacy grass variation / distance layering removed to favor deterministic perimeter approach) |
|
|
|
// (Pagodas removed for Iteration 4 focus on sand↔grass transitions) |
|
|
|
// Scale layers for visibility (water bottom, shoreline, then grass) |
|
waterLayer.setScale(SCALE).setPosition(0, 0) |
|
shoreLayer.setScale(SCALE).setPosition(0, 0) |
|
if (grassLayer) { |
|
grassLayer.setScale(SCALE).setPosition(0, 0) |
|
const pathLayer = map.getLayer('paths')?.tilemapLayer |
|
if (pathLayer) pathLayer.setScale(SCALE).setPosition(0, 0) |
|
} |
|
}, |
|
|
|
/** |
|
* UPDATE PHASE: Game loop that runs every frame (usually 60 times per second) |
|
* This is where you'd put game logic that needs to run continuously |
|
* Currently empty since this is a static scene |
|
*/ |
|
update() {}, |
|
}, |
|
} |
|
|
|
// Create and return a new Phaser game instance with the above configuration |
|
return new Phaser.Game(config) |
|
} |
|
|
|
/** |
|
* React component that wraps a Phaser game scene |
|
* This component handles the integration between React and Phaser by: |
|
* 1. Providing a DOM container for Phaser to render into |
|
* 2. Managing the Phaser game lifecycle (creation and cleanup) |
|
* 3. Ensuring proper cleanup when the component unmounts |
|
*/ |
|
export default function GameSemester08() { |
|
// useRef creates a mutable reference that persists across re-renders |
|
// This will hold a reference to the HTML div that contains the Phaser canvas |
|
const containerRef = useRef<HTMLDivElement | null>(null) |
|
|
|
// This will hold a reference to the actual Phaser.Game instance |
|
// We need this reference so we can properly destroy the game when the component unmounts |
|
const gameRef = useRef<Phaser.Game | null>(null) |
|
|
|
// useEffect runs side effects and handles component lifecycle |
|
// The empty dependency array [] means this only runs once when the component mounts |
|
useEffect(() => { |
|
// Only create the game if we have a valid container element |
|
if (containerRef.current) { |
|
// Create the Phaser game and store the reference |
|
gameRef.current = createGame(containerRef.current) |
|
} |
|
|
|
// Return a cleanup function that will run when the component unmounts |
|
// This is crucial to prevent memory leaks and multiple game instances |
|
return () => { |
|
if (gameRef.current) { |
|
// Destroy the Phaser game instance and clean up all its resources |
|
// The 'true' parameter removes the canvas from the DOM |
|
gameRef.current.destroy(true) |
|
// Clear our reference to indicate the game is no longer active |
|
gameRef.current = null |
|
} |
|
} |
|
}, []) // Empty dependency array means this effect only runs on mount/unmount |
|
|
|
// Render a simple div that will serve as the container for the Phaser canvas |
|
// The ref={containerRef} gives us a reference to this DOM element |
|
// Phaser will inject its canvas as a child of this div |
|
return <div className="game-container" ref={containerRef} /> |
|
} |