refactor: migrate world rendering from programmatic sprites to PNG assets
Replace pixel-art drawing with pre-rendered PNG map backgrounds and character/NPC sprite images from pokemon-js reference. Maps now use coordinate-based wall arrays instead of tile grids. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BIN
assets/character/back-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/back-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/character/back-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/map/house-a-1f.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/map/lab.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/map/pallet-town.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/map/route-1.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/npcs/a-down.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/npcs/a-left.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/npcs/a-right.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/npcs/a-up.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
459
js/world/maps.js
@@ -1,271 +1,233 @@
|
||||
/**
|
||||
* maps.js - Tile-based world map definitions
|
||||
* maps.js - PNG-based world maps with wall coordinate arrays
|
||||
*
|
||||
* Tile types:
|
||||
* 0 = floor
|
||||
* 1 = wall
|
||||
* 2 = grass
|
||||
* 3 = workshop table
|
||||
* 4 = puzzle door locked
|
||||
* 5 = puzzle door open
|
||||
* 6 = NPC spot
|
||||
* 7 = path
|
||||
* 8 = water
|
||||
* 9 = terminal
|
||||
* Each map has a pre-rendered PNG background image and defines:
|
||||
* - walls: coordinate-based collision data { row: [col1, col2, ...] }
|
||||
* - npcs, interactions, exits as position-based objects
|
||||
*
|
||||
* Map images are drawn at 3x scale (16px native → 48px on screen)
|
||||
*/
|
||||
|
||||
// Helper to fill a rectangular area with a tile type
|
||||
function fillRect(tiles, x, y, w, h, tileType) {
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
if (row >= 0 && row < tiles.length && col >= 0 && col < tiles[0].length) {
|
||||
tiles[row][col] = tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to place a horizontal line
|
||||
function hline(tiles, x, y, length, tileType) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (y >= 0 && y < tiles.length && x + i >= 0 && x + i < tiles[0].length) {
|
||||
tiles[y][x + i] = tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to place a vertical line
|
||||
function vline(tiles, x, y, length, tileType) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (x >= 0 && x < tiles[0].length && y + i >= 0 && y + i < tiles.length) {
|
||||
tiles[y + i][x] = tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ==================== Map definitions ====================
|
||||
|
||||
/**
|
||||
* MAP 1: CIRCUIT LAB (20×15)
|
||||
* Indoor tech lab with workstations, terminals, and a puzzle door
|
||||
* MAP: LAB (10×12 tiles — lab.png is 160×192)
|
||||
* Pokemon professor's lab interior
|
||||
*/
|
||||
function createLabMap() {
|
||||
const width = 20;
|
||||
const height = 15;
|
||||
const labMap = {
|
||||
id: 'lab',
|
||||
name: 'Circuit Lab',
|
||||
image: 'map:lab',
|
||||
widthTiles: 10,
|
||||
heightTiles: 12,
|
||||
spawn: { x: 4, y: 10 },
|
||||
|
||||
// Start with all floor
|
||||
const tiles = Array(height).fill(null).map(() => Array(width).fill(0));
|
||||
// Walls: { row: [col, col, ...] }
|
||||
// Row 0-1: top shelves/machines
|
||||
walls: {
|
||||
0: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
1: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
2: [0, 1, 2, 7, 8, 9],
|
||||
3: [0, 3, 4, 5, 6, 9],
|
||||
4: [0, 3, 4, 5, 6, 9],
|
||||
5: [0, 3, 4, 5, 6, 9],
|
||||
6: [0, 9],
|
||||
7: [0, 9],
|
||||
8: [0, 1, 2, 7, 8, 9],
|
||||
9: [0, 1, 8, 9],
|
||||
10: [0, 1, 2, 3, 6, 7, 8, 9],
|
||||
11: [0, 1, 2, 3, 4, 6, 7, 8, 9]
|
||||
},
|
||||
|
||||
// Border walls
|
||||
for (let i = 0; i < width; i++) {
|
||||
tiles[0][i] = 1; // top
|
||||
tiles[height - 1][i] = 1; // bottom
|
||||
}
|
||||
for (let i = 0; i < height; i++) {
|
||||
tiles[i][0] = 1; // left
|
||||
tiles[i][width - 1] = 1; // right
|
||||
}
|
||||
exits: [
|
||||
// Exit at bottom center — door to town
|
||||
{ x: 4, y: 11, targetMap: 'town', targetX: 12, targetY: 10 },
|
||||
{ x: 5, y: 11, targetMap: 'town', targetX: 12, targetY: 10 }
|
||||
],
|
||||
|
||||
// Internal wall structure - create lab rooms
|
||||
// Vertical divider wall down the middle area
|
||||
vline(tiles, 13, 1, 10, 1);
|
||||
npcs: [
|
||||
{
|
||||
id: 'professor',
|
||||
x: 4, y: 3,
|
||||
facing: 'down',
|
||||
dialog: [
|
||||
'Welcome to the Circuit Lab!',
|
||||
'I\'m the Professor. We study logic gates here.',
|
||||
'Use the workshop tables to design circuits.',
|
||||
'Press TAB to open the Workshop anytime!'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// Horizontal divider
|
||||
hline(tiles, 1, 7, 12, 1);
|
||||
|
||||
// Workshop area - left side with tables
|
||||
tiles[4][3] = 3; // workshop table 1
|
||||
tiles[4][5] = 3; // workshop table 2
|
||||
tiles[4][7] = 3; // workshop table 3
|
||||
tiles[6][3] = 3; // workshop table 4
|
||||
tiles[6][5] = 3; // workshop table 5
|
||||
|
||||
// Terminal near workshop area
|
||||
tiles[5][10] = 9; // terminal
|
||||
|
||||
// Professor NPC spot at top
|
||||
tiles[2][10] = 6; // NPC spawn location
|
||||
|
||||
// Puzzle door leading to back room (top-right area)
|
||||
tiles[2][16] = 4; // locked puzzle door
|
||||
|
||||
// Door opening in the internal wall for navigation
|
||||
tiles[7][7] = 0; // create passage
|
||||
|
||||
// Exit point at bottom (to town)
|
||||
tiles[13][10] = 0; // clear exit path
|
||||
|
||||
return {
|
||||
id: 'lab',
|
||||
name: 'Circuit Lab',
|
||||
width: width,
|
||||
height: height,
|
||||
tiles: tiles,
|
||||
spawn: { x: 10, y: 12 },
|
||||
|
||||
exits: [
|
||||
{ x: 10, y: 13, targetMap: 'town', targetX: 15, targetY: 1 }
|
||||
],
|
||||
|
||||
npcs: [
|
||||
{
|
||||
id: 'professor',
|
||||
type: 0,
|
||||
x: 10,
|
||||
y: 2,
|
||||
facing: 'down',
|
||||
dialog: [
|
||||
'Welcome to the Circuit Lab!',
|
||||
'I\'m the Professor. We study logic gates here.',
|
||||
'Try using the workshop tables to design circuits.',
|
||||
'Once you\'ve created some components, you can use them to solve puzzles.'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
interactions: [
|
||||
{ x: 3, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
|
||||
{ x: 5, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
|
||||
{ x: 7, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
|
||||
{ x: 10, y: 5, type: 'terminal', action: 'openTerminal', label: 'Terminal' },
|
||||
{ x: 16, y: 2, type: 'puzzle_door', puzzleId: 'lab_door_1', requiredOutputs: [1, 0, 1, 1], label: 'Locked Door' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAP 2: NEON TOWN (30×20)
|
||||
* Outdoor town with buildings, NPCs, water feature, and puzzle areas
|
||||
*/
|
||||
function createTownMap() {
|
||||
const width = 30;
|
||||
const height = 20;
|
||||
|
||||
// Start with grass
|
||||
const tiles = Array(height).fill(null).map(() => Array(width).fill(2));
|
||||
|
||||
// Add some paths (lighter grass/paths)
|
||||
hline(tiles, 0, 10, width, 7); // horizontal path
|
||||
vline(tiles, 15, 0, height, 7); // vertical path
|
||||
|
||||
// Water feature on the left (pond)
|
||||
fillRect(tiles, 2, 5, 5, 6, 8);
|
||||
|
||||
// Lab entrance at top
|
||||
tiles[0][15] = 0; // entrance floor
|
||||
tiles[1][15] = 6; // NPC spawn for entrance
|
||||
|
||||
// Building 1 (top-left) - House structure
|
||||
fillRect(tiles, 5, 2, 7, 5, 1); // walls
|
||||
fillRect(tiles, 6, 3, 5, 3, 0); // interior floor
|
||||
tiles[4][8] = 0; // door to building 1
|
||||
|
||||
// Building 2 (top-right) - Shop
|
||||
fillRect(tiles, 20, 2, 7, 5, 1); // walls
|
||||
fillRect(tiles, 21, 3, 5, 3, 0); // interior floor
|
||||
tiles[4][23] = 0; // door to building 2
|
||||
|
||||
// Building 3 (bottom-left) - Guard post
|
||||
fillRect(tiles, 5, 14, 7, 5, 1); // walls
|
||||
fillRect(tiles, 6, 15, 5, 3, 0); // interior floor
|
||||
tiles[13][8] = 0; // door to guard post
|
||||
|
||||
// Building 4 (bottom-right) - Town Hall
|
||||
fillRect(tiles, 20, 14, 7, 5, 1); // walls
|
||||
fillRect(tiles, 21, 15, 5, 3, 0); // interior floor
|
||||
tiles[13][23] = 0; // door to town hall
|
||||
|
||||
// Merchant NPC in center town square
|
||||
tiles[10][15] = 6; // NPC spawn
|
||||
|
||||
// Guard NPC at guard post entrance
|
||||
tiles[13][8] = 6; // NPC spawn (overlays door, but NPC takes priority)
|
||||
|
||||
// Puzzle door to eastern area (locked)
|
||||
tiles[10][28] = 4; // locked puzzle door
|
||||
|
||||
return {
|
||||
id: 'town',
|
||||
name: 'Neon Town',
|
||||
width: width,
|
||||
height: height,
|
||||
tiles: tiles,
|
||||
spawn: { x: 15, y: 2 },
|
||||
|
||||
exits: [
|
||||
{ x: 15, y: 0, targetMap: 'lab', targetX: 10, targetY: 13 }
|
||||
],
|
||||
|
||||
npcs: [
|
||||
{
|
||||
id: 'merchant',
|
||||
type: 0,
|
||||
x: 15,
|
||||
y: 10,
|
||||
facing: 'down',
|
||||
dialog: [
|
||||
'Welcome to Neon Town!',
|
||||
'I trade in rare logic components.',
|
||||
'Show me what circuits you\'ve designed, and maybe we can make a deal.',
|
||||
'Some items are only available if you\'ve solved certain puzzles.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'guard',
|
||||
type: 0,
|
||||
x: 8,
|
||||
y: 13,
|
||||
facing: 'right',
|
||||
dialog: [
|
||||
'I guard the eastern territories.',
|
||||
'You need to solve the puzzle at the gate before you can pass.',
|
||||
'Bring me a component that produces the right output pattern!'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
interactions: [
|
||||
{ x: 8, y: 4, type: 'door', action: 'openBuilding', label: 'House', buildingId: 'house_1' },
|
||||
{ x: 23, y: 4, type: 'door', action: 'openBuilding', label: 'Shop', buildingId: 'shop_1' },
|
||||
{ x: 8, y: 13, type: 'door', action: 'openBuilding', label: 'Guard Post', buildingId: 'guardpost_1' },
|
||||
{ x: 23, y: 13, type: 'door', action: 'openBuilding', label: 'Town Hall', buildingId: 'townhall_1' },
|
||||
{ x: 28, y: 10, type: 'puzzle_door', puzzleId: 'town_gate_1', requiredOutputs: [0, 1, 1, 0], label: 'Locked Gate' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Map registry
|
||||
const maps = {
|
||||
lab: createLabMap(),
|
||||
town: createTownMap()
|
||||
interactions: [
|
||||
// Workshop tables (the big table in the middle of lab)
|
||||
{ x: 3, y: 5, type: 'workshop', label: 'Workshop Table' },
|
||||
{ x: 4, y: 5, type: 'workshop', label: 'Workshop Table' },
|
||||
{ x: 5, y: 5, type: 'workshop', label: 'Workshop Table' },
|
||||
{ x: 6, y: 5, type: 'workshop', label: 'Workshop Table' },
|
||||
// Machine/bookshelf
|
||||
{ x: 8, y: 2, type: 'terminal', label: 'Terminal',
|
||||
dialog: ['Circuit analysis terminal.', 'Connect components to solve puzzles.'] },
|
||||
// Puzzle door in top-right area
|
||||
{ x: 8, y: 3, type: 'puzzle_door', puzzleId: 'lab_door_1',
|
||||
requiredOutputs: [1, 0, 1, 1], label: 'Locked Door' }
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a complete map by ID
|
||||
* MAP: TOWN (20×18 tiles — pallet-town.png is 320×288)
|
||||
* Pokemon-style starting town with houses and paths
|
||||
*/
|
||||
const townMap = {
|
||||
id: 'town',
|
||||
name: 'Neon Town',
|
||||
image: 'map:pallet-town',
|
||||
widthTiles: 20,
|
||||
heightTiles: 18,
|
||||
spawn: { x: 9, y: 9 },
|
||||
|
||||
// Walls based on pallet-town visual layout
|
||||
// Trees around border, houses, fences, water
|
||||
walls: (() => {
|
||||
const w = {};
|
||||
// Helper to add walls
|
||||
function addWall(row, cols) {
|
||||
if (!w[row]) w[row] = [];
|
||||
w[row].push(...cols);
|
||||
}
|
||||
function addRange(row, from, to) {
|
||||
const cols = [];
|
||||
for (let c = from; c <= to; c++) cols.push(c);
|
||||
addWall(row, cols);
|
||||
}
|
||||
function addRect(rowStart, rowEnd, colStart, colEnd) {
|
||||
for (let r = rowStart; r <= rowEnd; r++) addRange(r, colStart, colEnd);
|
||||
}
|
||||
|
||||
// Top border (trees/fence) — rows 0-1
|
||||
addRange(0, 0, 19);
|
||||
addRange(1, 0, 19);
|
||||
|
||||
// Left border trees
|
||||
for (let r = 2; r <= 17; r++) addWall(r, [0, 1]);
|
||||
|
||||
// Right border trees
|
||||
for (let r = 2; r <= 17; r++) addWall(r, [18, 19]);
|
||||
|
||||
// Bottom border
|
||||
addRange(17, 0, 19);
|
||||
|
||||
// House 1 (top-left area) — roughly rows 3-6, cols 3-7
|
||||
addRect(3, 5, 3, 7);
|
||||
|
||||
// House 2 (top-right area) — rows 3-6, cols 12-16
|
||||
addRect(3, 5, 12, 16);
|
||||
|
||||
// Fence segments
|
||||
addRange(7, 2, 7);
|
||||
addRange(7, 12, 17);
|
||||
|
||||
// Water/pond (bottom-left)
|
||||
addRect(13, 15, 2, 5);
|
||||
|
||||
// Some trees/obstacles in bottom area
|
||||
addWall(16, [2, 3, 4, 5, 6, 7]);
|
||||
addWall(16, [12, 13, 14, 15, 16, 17]);
|
||||
|
||||
return w;
|
||||
})(),
|
||||
|
||||
exits: [
|
||||
// North exit — goes to lab
|
||||
{ x: 12, y: 9, targetMap: 'lab', targetX: 4, targetY: 10 },
|
||||
// Route 1 south (future)
|
||||
// { x: 9, y: 17, targetMap: 'route1', targetX: 10, targetY: 0 }
|
||||
],
|
||||
|
||||
npcs: [
|
||||
{
|
||||
id: 'merchant',
|
||||
x: 9, y: 11,
|
||||
facing: 'down',
|
||||
dialog: [
|
||||
'Welcome to Neon Town!',
|
||||
'I trade in rare logic components.',
|
||||
'Craft some circuits in the Lab workshop!',
|
||||
'Some doors need special output patterns to open.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'guide',
|
||||
x: 14, y: 9,
|
||||
facing: 'left',
|
||||
dialog: [
|
||||
'The Circuit Lab is just up ahead.',
|
||||
'Professor Oak.. I mean, the Professor can teach you about logic gates!',
|
||||
'Press TAB anytime to open your Workshop.'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
interactions: [
|
||||
// House 1 door
|
||||
{ x: 5, y: 6, type: 'door', label: 'House',
|
||||
dialog: ['The door is locked.', 'Nobody seems to be home.'] },
|
||||
// House 2 door
|
||||
{ x: 14, y: 6, type: 'door', label: 'House',
|
||||
dialog: ['This is the component shop.', 'Coming soon!'] },
|
||||
// Sign
|
||||
{ x: 10, y: 8, type: 'sign', label: 'Sign',
|
||||
dialog: ['Welcome to Neon Town!', 'Circuit Lab ↑'] }
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
// ==================== Map registry ====================
|
||||
|
||||
const maps = {
|
||||
lab: labMap,
|
||||
town: townMap
|
||||
};
|
||||
|
||||
// ==================== Public API ====================
|
||||
|
||||
export function getMap(id) {
|
||||
return maps[id] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tile type at position
|
||||
* Check if a tile position is a wall
|
||||
*/
|
||||
export function getTile(mapId, x, y) {
|
||||
export function isWall(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
if (x < 0 || x >= map.width || y < 0 || y >= map.height) return null;
|
||||
return map.tiles[y][x];
|
||||
if (!map) return true;
|
||||
// Out of bounds = wall
|
||||
if (x < 0 || x >= map.widthTiles || y < 0 || y >= map.heightTiles) return true;
|
||||
const row = map.walls[y];
|
||||
if (!row) return false;
|
||||
return row.includes(x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interaction at position (if any)
|
||||
* Check if a tile is walkable (not a wall and no NPC blocking)
|
||||
*/
|
||||
export function isWalkable(mapId, x, y) {
|
||||
if (isWall(mapId, x, y)) return false;
|
||||
if (getNPC(mapId, x, y)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interaction at position
|
||||
*/
|
||||
export function getInteraction(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
return map.interactions.find(inter => inter.x === x && inter.y === y) || null;
|
||||
return map.interactions.find(i => i.x === x && i.y === y) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NPC at position (if any)
|
||||
* Get NPC at position
|
||||
*/
|
||||
export function getNPC(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
@@ -274,32 +236,17 @@ export function getNPC(mapId, x, y) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exit at position (if any)
|
||||
* Get exit at position
|
||||
*/
|
||||
export function getExit(mapId, x, y) {
|
||||
const map = maps[mapId];
|
||||
if (!map) return null;
|
||||
return map.exits.find(exit => exit.x === x && exit.y === y) || null;
|
||||
return map.exits.find(e => e.x === x && e.y === y) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tile is walkable
|
||||
* Walls (1), water (8), workshop tables (3), terminals (9), locked puzzle doors (4), and NPCs are not walkable
|
||||
*/
|
||||
export function isWalkable(mapId, x, y) {
|
||||
const tile = getTile(mapId, x, y);
|
||||
|
||||
// Out of bounds
|
||||
if (tile === null) return false;
|
||||
|
||||
// Non-walkable tiles
|
||||
const nonWalkable = [1, 3, 4, 8, 9];
|
||||
if (nonWalkable.includes(tile)) return false;
|
||||
|
||||
// Check for NPC
|
||||
if (getNPC(mapId, x, y)) return false;
|
||||
|
||||
return true;
|
||||
// No longer needed but keep for compat — returns null always
|
||||
export function getTile(mapId, x, y) {
|
||||
return isWall(mapId, x, y) ? 1 : 0;
|
||||
}
|
||||
|
||||
export { maps };
|
||||
|
||||
@@ -1,694 +1,238 @@
|
||||
// Cyberpunk pixel-art sprite system
|
||||
// All sprites drawn on canvas, no image assets
|
||||
// 16x16 tile size with 3x scaling for screen rendering
|
||||
// sprites.js - PNG image-based sprite system
|
||||
// Uses pre-rendered assets from assets/ directory
|
||||
// 16px native tile size, 3x scale for screen rendering
|
||||
|
||||
export const TILE_SIZE = 16;
|
||||
export const TILE = 16;
|
||||
export const SCALE = 3;
|
||||
export const TILE_PX = TILE * SCALE; // 48px on screen
|
||||
|
||||
// Color palette
|
||||
const COLORS = {
|
||||
// Neon palette
|
||||
neonGreen: '#00e599',
|
||||
neonPink: '#ff44aa',
|
||||
neonPurple: '#9900ff',
|
||||
neonCyan: '#44ddff',
|
||||
neonOrange: '#ff8844',
|
||||
// Also export as TILE_SIZE for backward compat
|
||||
export const TILE_SIZE = TILE;
|
||||
|
||||
// Dark palette
|
||||
black: '#0a0e27',
|
||||
darkGray: '#1a1f3a',
|
||||
gray: '#3a3f5a',
|
||||
lightGray: '#5a5f7a',
|
||||
// ==================== Image cache ====================
|
||||
|
||||
// Skin tones & details
|
||||
skinLight: '#d4a574',
|
||||
skinMid: '#c89860',
|
||||
skinDark: '#a0704c',
|
||||
const imageCache = {};
|
||||
let assetsLoaded = false;
|
||||
let onAssetsReady = null;
|
||||
|
||||
// Material colors
|
||||
metalDark: '#2a2f4a',
|
||||
metalLight: '#4a4f6a',
|
||||
copper: '#b87333',
|
||||
blue: '#4488dd',
|
||||
red: '#ee4444',
|
||||
green: '#44aa44',
|
||||
white: '#ffffff',
|
||||
};
|
||||
function loadImage(key, src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (imageCache[key]) { resolve(imageCache[key]); return; }
|
||||
const img = new Image();
|
||||
img.onload = () => { imageCache[key] = img; resolve(img); };
|
||||
img.onerror = () => { console.warn(`[sprites] failed to load: ${src}`); resolve(null); };
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to draw a single scaled pixel
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} baseX - Base X position (in pixels on screen)
|
||||
* @param {number} baseY - Base Y position (in pixels on screen)
|
||||
* @param {number} px - Pixel X offset (0-15 within tile)
|
||||
* @param {number} py - Pixel Y offset (0-15 within tile)
|
||||
* @param {string} color - Color hex code
|
||||
*/
|
||||
function pixel(ctx, baseX, baseY, px, py, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(baseX + px * SCALE, baseY + py * SCALE, SCALE, SCALE);
|
||||
export function getImage(key) {
|
||||
return imageCache[key] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a filled rectangle in tile space
|
||||
* Preload all game assets. Returns a promise that resolves when done.
|
||||
*/
|
||||
function rect(ctx, baseX, baseY, x, y, w, h, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(baseX + x * SCALE, baseY + y * SCALE, w * SCALE, h * SCALE);
|
||||
export async function preloadAssets() {
|
||||
if (assetsLoaded) return;
|
||||
|
||||
const loads = [];
|
||||
|
||||
// Map backgrounds
|
||||
loads.push(loadImage('map:lab', 'assets/map/lab.png'));
|
||||
loads.push(loadImage('map:pallet-town', 'assets/map/pallet-town.png'));
|
||||
loads.push(loadImage('map:house-a-1f', 'assets/map/house-a-1f.png'));
|
||||
loads.push(loadImage('map:route-1', 'assets/map/route-1.png'));
|
||||
|
||||
// Character sprites (32x32 each)
|
||||
const dirs = ['front', 'back', 'left', 'right'];
|
||||
const frames = ['still', 'walk-1', 'walk-2'];
|
||||
for (const dir of dirs) {
|
||||
for (const frame of frames) {
|
||||
const key = `char:${dir}-${frame}`;
|
||||
loads.push(loadImage(key, `assets/character/${dir}-${frame}.png`));
|
||||
}
|
||||
}
|
||||
|
||||
// NPC sprites (16x16 each)
|
||||
const npcDirs = ['down', 'up', 'left', 'right'];
|
||||
for (const d of npcDirs) {
|
||||
loads.push(loadImage(`npc:a-${d}`, `assets/npcs/a-${d}.png`));
|
||||
}
|
||||
|
||||
await Promise.all(loads);
|
||||
assetsLoaded = true;
|
||||
console.log('[sprites] all assets loaded');
|
||||
}
|
||||
|
||||
// ==================== Direction mapping ====================
|
||||
|
||||
// Map game direction to character sprite prefix
|
||||
const DIR_TO_SPRITE = {
|
||||
down: 'front',
|
||||
up: 'back',
|
||||
left: 'left',
|
||||
right: 'right'
|
||||
};
|
||||
|
||||
// Map game direction to NPC sprite suffix
|
||||
const DIR_TO_NPC = {
|
||||
down: 'down',
|
||||
up: 'up',
|
||||
left: 'left',
|
||||
right: 'right'
|
||||
};
|
||||
|
||||
// ==================== Drawing functions ====================
|
||||
|
||||
/**
|
||||
* Draw a map background image
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} mapImageKey - key in imageCache (e.g. 'map:lab')
|
||||
* @param {number} offsetX - pixel offset for camera
|
||||
* @param {number} offsetY - pixel offset for camera
|
||||
*/
|
||||
export function drawMapImage(ctx, mapImageKey, offsetX, offsetY) {
|
||||
const img = imageCache[mapImageKey];
|
||||
if (!img) return;
|
||||
// Draw scaled: native pixels * SCALE
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(img, offsetX, offsetY, img.width * SCALE, img.height * SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the player character
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - Screen X position
|
||||
* @param {number} y - Screen Y position
|
||||
* @param {string} direction - 'up', 'down', 'left', 'right'
|
||||
* @param {number} frame - Animation frame (0 or 1)
|
||||
* @param {number} screenX - top-left X on screen
|
||||
* @param {number} screenY - top-left Y on screen
|
||||
* @param {string} direction - 'up'|'down'|'left'|'right'
|
||||
* @param {number} walkFrame - 0=still, 1=walk-1, 2=walk-2
|
||||
*/
|
||||
export function drawPlayer(ctx, x, y, direction, frame) {
|
||||
const baseX = x * SCALE;
|
||||
const baseY = y * SCALE;
|
||||
|
||||
// Idle position or walking offset
|
||||
const walkOffset = frame === 1 ? 1 : 0;
|
||||
|
||||
// Head position shifts slightly with walk cycle
|
||||
let headY = 2;
|
||||
let legOffset = 0;
|
||||
if (frame === 1 && direction === 'down') legOffset = 1;
|
||||
if (frame === 1 && direction === 'up') legOffset = -1;
|
||||
|
||||
if (direction === 'down') {
|
||||
// Facing down
|
||||
// Hair/head
|
||||
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.skinLight);
|
||||
|
||||
// Hair back
|
||||
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.darkGray);
|
||||
|
||||
// Eyes (neon glow)
|
||||
pixel(ctx, baseX, baseY, 7, headY + 2, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 2, COLORS.neonGreen);
|
||||
|
||||
// Mouth
|
||||
pixel(ctx, baseX, baseY, 7, headY + 3, COLORS.red);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 3, COLORS.red);
|
||||
|
||||
// Torso - black outfit with neon trim
|
||||
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.neonGreen);
|
||||
|
||||
// Chest neon accent
|
||||
pixel(ctx, baseX, baseY, 7, headY + 6, COLORS.neonPink);
|
||||
pixel(ctx, baseY, baseY, 8, headY + 6, COLORS.neonPink);
|
||||
|
||||
// Arms
|
||||
pixel(ctx, baseX, baseY, 5, headY + 5, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 5, COLORS.skinDark);
|
||||
|
||||
// Gloves/wrists - neon
|
||||
pixel(ctx, baseX, baseY, 5, headY + 6, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 6, COLORS.neonCyan);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 6, headY + 9 + legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 9 + legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 9 + legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 9 + legOffset, COLORS.black);
|
||||
|
||||
// Feet - boots with neon
|
||||
pixel(ctx, baseX, baseY, 6, headY + 11 + legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 11 + legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 11 + legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 11 + legOffset, COLORS.neonGreen);
|
||||
|
||||
} else if (direction === 'up') {
|
||||
// Facing up - back view
|
||||
// Hair
|
||||
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.darkGray);
|
||||
|
||||
// Back of head
|
||||
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.skinDark);
|
||||
|
||||
// Neck
|
||||
pixel(ctx, baseX, baseY, 7, headY + 2, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 2, COLORS.skinLight);
|
||||
|
||||
// Jacket back with neon stripe
|
||||
pixel(ctx, baseX, baseY, 6, headY + 3, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 3, COLORS.black);
|
||||
|
||||
// Torso
|
||||
pixel(ctx, baseX, baseY, 6, headY + 4, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 4, COLORS.darkGray);
|
||||
|
||||
// Waist - neon bands
|
||||
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.neonPink);
|
||||
|
||||
// Arms back
|
||||
pixel(ctx, baseX, baseY, 5, headY + 4, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 4, COLORS.darkGray);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 6, headY + 9 - legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 9 - legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 9 - legOffset, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 9 - legOffset, COLORS.black);
|
||||
|
||||
// Feet
|
||||
pixel(ctx, baseX, baseY, 6, headY + 11 - legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 11 - legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 11 - legOffset, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 11 - legOffset, COLORS.neonGreen);
|
||||
|
||||
} else if (direction === 'left') {
|
||||
// Facing left
|
||||
// Hair
|
||||
pixel(ctx, baseX, baseY, 6, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.darkGray);
|
||||
|
||||
// Eye (neon)
|
||||
pixel(ctx, baseX, baseY, 6, headY + 2, COLORS.neonGreen);
|
||||
|
||||
// Mouth
|
||||
pixel(ctx, baseX, baseY, 6, headY + 3, COLORS.red);
|
||||
|
||||
// Torso with side view
|
||||
pixel(ctx, baseX, baseY, 5, headY + 4, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.darkGray);
|
||||
|
||||
pixel(ctx, baseX, baseY, 5, headY + 5, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.black);
|
||||
|
||||
// Left arm
|
||||
pixel(ctx, baseX, baseY, 4, headY + 5 - walkOffset, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 4, headY + 6, COLORS.neonCyan);
|
||||
|
||||
// Right arm (back)
|
||||
pixel(ctx, baseX, baseY, 8, headY + 5 + walkOffset, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 8, headY + 6, COLORS.darkGray);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 5, headY + 9, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 9, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 9, COLORS.darkGray);
|
||||
|
||||
// Feet
|
||||
pixel(ctx, baseX, baseY, 5, headY + 11, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 6, headY + 11, COLORS.neonGreen);
|
||||
|
||||
} else if (direction === 'right') {
|
||||
// Facing right
|
||||
// Hair
|
||||
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY, COLORS.darkGray);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.skinLight);
|
||||
|
||||
// Eye (neon)
|
||||
pixel(ctx, baseX, baseY, 9, headY + 2, COLORS.neonGreen);
|
||||
|
||||
// Mouth
|
||||
pixel(ctx, baseX, baseY, 9, headY + 3, COLORS.red);
|
||||
|
||||
// Torso with side view
|
||||
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 4, COLORS.neonGreen);
|
||||
|
||||
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 5, COLORS.neonGreen);
|
||||
|
||||
// Left arm (back)
|
||||
pixel(ctx, baseX, baseY, 7, headY + 5 + walkOffset, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 7, headY + 6, COLORS.darkGray);
|
||||
|
||||
// Right arm
|
||||
pixel(ctx, baseX, baseY, 11, headY + 5 - walkOffset, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 11, headY + 6, COLORS.neonCyan);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 8, headY + 9, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, headY + 9, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 9, COLORS.black);
|
||||
|
||||
// Feet
|
||||
pixel(ctx, baseX, baseY, 9, headY + 11, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 10, headY + 11, COLORS.neonGreen);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a tile
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - Tile X position
|
||||
* @param {number} y - Tile Y position
|
||||
* @param {number} tileType - Tile type (0-9)
|
||||
*/
|
||||
export function drawTile(ctx, x, y, tileType) {
|
||||
const baseX = x * SCALE;
|
||||
const baseY = y * SCALE;
|
||||
|
||||
switch (tileType) {
|
||||
case 0: // Floor - metal grid pattern
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalDark);
|
||||
// Grid pattern
|
||||
for (let i = 0; i < 16; i += 4) {
|
||||
rect(ctx, baseX, baseY, i, 0, 1, 16, COLORS.metalLight);
|
||||
rect(ctx, baseX, baseY, 0, i, 16, 1, COLORS.metalLight);
|
||||
}
|
||||
// Corner accents
|
||||
pixel(ctx, baseX, baseY, 0, 0, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 15, 0, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 0, 15, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 15, 15, COLORS.neonGreen);
|
||||
break;
|
||||
|
||||
case 1: // Wall - solid dark with neon trim
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.darkGray);
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.black);
|
||||
// Neon edges
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 1, COLORS.neonCyan);
|
||||
rect(ctx, baseX, baseY, 0, 15, 16, 1, COLORS.neonPink);
|
||||
rect(ctx, baseX, baseY, 0, 0, 1, 16, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 15, 0, 1, 16, COLORS.neonOrange);
|
||||
break;
|
||||
|
||||
case 2: // Grass/outdoor ground
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.green);
|
||||
// Grass tufts
|
||||
for (let i = 0; i < 16; i += 4) {
|
||||
for (let j = 0; j < 16; j += 4) {
|
||||
if ((i + j) % 8 === 0) {
|
||||
pixel(ctx, baseX, baseY, i, j, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, i + 1, j, COLORS.neonGreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 3: // Workshop table
|
||||
// Table surface
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 10, COLORS.copper);
|
||||
rect(ctx, baseX, baseY, 2, 2, 12, 8, COLORS.lightGray);
|
||||
|
||||
// Electronic components on table
|
||||
rect(ctx, baseX, baseY, 3, 3, 3, 3, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 10, 3, 3, 3, COLORS.neonCyan);
|
||||
rect(ctx, baseX, baseY, 6, 5, 4, 2, COLORS.neonGreen);
|
||||
|
||||
// Table legs
|
||||
rect(ctx, baseX, baseY, 2, 11, 2, 5, COLORS.gray);
|
||||
rect(ctx, baseX, baseY, 12, 11, 2, 5, COLORS.gray);
|
||||
break;
|
||||
|
||||
case 4: // Puzzle door - locked
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalDark);
|
||||
// Door frame
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.darkGray);
|
||||
|
||||
// LED indicators (locked - red)
|
||||
pixel(ctx, baseX, baseY, 4, 4, COLORS.red);
|
||||
pixel(ctx, baseX, baseY, 12, 4, COLORS.red);
|
||||
pixel(ctx, baseX, baseY, 4, 12, COLORS.red);
|
||||
pixel(ctx, baseX, baseY, 12, 12, COLORS.red);
|
||||
|
||||
// Center lock symbol
|
||||
rect(ctx, baseX, baseY, 6, 6, 4, 4, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 8, COLORS.black);
|
||||
break;
|
||||
|
||||
case 5: // Puzzle door - open
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalLight);
|
||||
// Door frame
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.darkGray);
|
||||
|
||||
// LED indicators (unlocked - green)
|
||||
pixel(ctx, baseX, baseY, 4, 4, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 12, 4, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 4, 12, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 12, 12, COLORS.neonGreen);
|
||||
|
||||
// Open door effect
|
||||
rect(ctx, baseX, baseY, 6, 6, 4, 4, COLORS.neonGreen);
|
||||
break;
|
||||
|
||||
case 6: // NPC spot - empty, marked with neon circle
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.darkGray);
|
||||
// Neon circle outline
|
||||
for (let i = 3; i < 13; i++) {
|
||||
pixel(ctx, baseX, baseY, i, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, i, 12, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 3, i, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 12, i, COLORS.neonCyan);
|
||||
}
|
||||
break;
|
||||
|
||||
case 7: // Path/road
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.gray);
|
||||
// Road markings
|
||||
rect(ctx, baseX, baseY, 6, 0, 4, 16, COLORS.neonOrange);
|
||||
// Dashes
|
||||
for (let i = 0; i < 16; i += 4) {
|
||||
rect(ctx, baseX, baseY, 7, i, 2, 2, COLORS.black);
|
||||
}
|
||||
break;
|
||||
|
||||
case 8: // Water/void
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
|
||||
// Ripple effect (animated-looking)
|
||||
for (let i = 2; i < 14; i += 3) {
|
||||
for (let j = 2; j < 14; j += 3) {
|
||||
if ((i + j) % 6 === 0) {
|
||||
pixel(ctx, baseX, baseY, i, j, COLORS.neonCyan);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Neon glow edges
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 1, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 0, 15, 16, 1, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 0, 0, 1, 16, COLORS.neonPurple);
|
||||
rect(ctx, baseX, baseY, 15, 0, 1, 16, COLORS.neonPurple);
|
||||
break;
|
||||
|
||||
case 9: // Terminal/computer
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
|
||||
// Screen border
|
||||
rect(ctx, baseX, baseY, 1, 1, 14, 12, COLORS.metalDark);
|
||||
rect(ctx, baseX, baseY, 2, 2, 12, 10, COLORS.neonGreen);
|
||||
|
||||
// Screen display with scanlines effect
|
||||
for (let i = 0; i < 10; i += 2) {
|
||||
rect(ctx, baseX, baseY, 3, 3 + i, 10, 1, COLORS.darkGray);
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
rect(ctx, baseX, baseY, 2, 13, 12, 2, COLORS.gray);
|
||||
pixel(ctx, baseX, baseY, 4, 14, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 14, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 12, 14, COLORS.neonPink);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Default: empty space
|
||||
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an NPC character
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - Screen X position
|
||||
* @param {number} y - Screen Y position
|
||||
* @param {number} npcType - NPC type (0 = scientist, 1 = guard, 2 = merchant)
|
||||
* @param {number} frame - Animation frame (0 or 1)
|
||||
*/
|
||||
export function drawNPC(ctx, x, y, npcType, frame) {
|
||||
const baseX = x * SCALE;
|
||||
const baseY = y * SCALE;
|
||||
const wobble = frame === 1 ? 1 : 0;
|
||||
|
||||
if (npcType === 0) {
|
||||
// Scientist - lab coat, goggles
|
||||
// Hair
|
||||
pixel(ctx, baseX, baseY, 7, 2, COLORS.lightGray);
|
||||
pixel(ctx, baseX, baseY, 8, 2, COLORS.lightGray);
|
||||
|
||||
// Goggles
|
||||
pixel(ctx, baseX, baseY, 6, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 7, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 8, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 9, 3, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 6, 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, 4, COLORS.black);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 7, 4, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, 4, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 7, 5, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, 5, COLORS.skinLight);
|
||||
|
||||
// Nose
|
||||
pixel(ctx, baseX, baseY, 7, 5, COLORS.skinMid);
|
||||
|
||||
// Lab coat - white with neon trim
|
||||
pixel(ctx, baseX, baseY, 5, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 6, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 7, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 8, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 9, 6, COLORS.white);
|
||||
pixel(ctx, baseX, baseY, 10, 6, COLORS.white);
|
||||
|
||||
// Coat buttons - neon
|
||||
pixel(ctx, baseX, baseY, 7, 7, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 8, 7, COLORS.neonGreen);
|
||||
|
||||
// Arms
|
||||
pixel(ctx, baseX, baseY, 4, 7, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 11, 7, COLORS.skinDark);
|
||||
|
||||
// Hands
|
||||
pixel(ctx, baseX, baseY, 4, 8 + wobble, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 11, 8 + wobble, COLORS.skinLight);
|
||||
|
||||
// Legs
|
||||
pixel(ctx, baseX, baseY, 6, 10, COLORS.gray);
|
||||
pixel(ctx, baseX, baseY, 7, 10, COLORS.gray);
|
||||
pixel(ctx, baseX, baseY, 8, 10, COLORS.gray);
|
||||
pixel(ctx, baseX, baseY, 9, 10, COLORS.gray);
|
||||
|
||||
// Feet
|
||||
pixel(ctx, baseX, baseY, 6, 12, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 7, 12, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, 12, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 9, 12, COLORS.black);
|
||||
|
||||
} else if (npcType === 1) {
|
||||
// Guard - helmet, armor
|
||||
// Helmet with visor
|
||||
pixel(ctx, baseX, baseY, 7, 2, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 8, 2, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 6, 3, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 7, 3, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 3, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 9, 3, COLORS.metalLight);
|
||||
|
||||
// Face hidden by visor
|
||||
pixel(ctx, baseX, baseY, 7, 4, COLORS.black);
|
||||
pixel(ctx, baseX, baseY, 8, 4, COLORS.black);
|
||||
|
||||
// Armor - angular, metallic
|
||||
pixel(ctx, baseX, baseY, 5, 5, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 6, 5, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 7, 5, COLORS.metalDark);
|
||||
pixel(ctx, baseX, baseY, 8, 5, COLORS.metalDark);
|
||||
pixel(ctx, baseX, baseY, 9, 5, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 10, 5, COLORS.metalLight);
|
||||
|
||||
// Chest plate
|
||||
pixel(ctx, baseX, baseY, 5, 6, COLORS.neonPurple);
|
||||
pixel(ctx, baseX, baseY, 6, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 7, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 8, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 9, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 10, 6, COLORS.neonPurple);
|
||||
|
||||
// Arms - armored
|
||||
pixel(ctx, baseX, baseY, 4, 6, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 11, 6, COLORS.metalLight);
|
||||
|
||||
// Gauntlets - neon edge
|
||||
pixel(ctx, baseX, baseY, 4, 7, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 11, 7, COLORS.neonCyan);
|
||||
|
||||
// Legs - armored
|
||||
pixel(ctx, baseX, baseY, 6, 10, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 7, 10, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 8, 10, COLORS.metalLight);
|
||||
pixel(ctx, baseX, baseY, 9, 10, COLORS.metalLight);
|
||||
|
||||
// Boots
|
||||
pixel(ctx, baseX, baseY, 6, 12, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 7, 12, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 8, 12, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 9, 12, COLORS.neonOrange);
|
||||
|
||||
} else if (npcType === 2) {
|
||||
// Merchant - fancy outfit, hat
|
||||
// Hat
|
||||
pixel(ctx, baseX, baseY, 6, 1, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 7, 1, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 1, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 9, 1, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 6, 2, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 9, 2, COLORS.neonPink);
|
||||
|
||||
// Face
|
||||
pixel(ctx, baseX, baseY, 7, 3, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, 3, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 7, 4, COLORS.skinLight);
|
||||
pixel(ctx, baseX, baseY, 8, 4, COLORS.skinLight);
|
||||
|
||||
// Mustache (fancy)
|
||||
pixel(ctx, baseX, baseY, 6, 4, COLORS.darkGray);
|
||||
pixel(ctx, baseX, baseY, 9, 4, COLORS.darkGray);
|
||||
|
||||
// Fancy jacket - colorful
|
||||
pixel(ctx, baseX, baseY, 5, 5, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 6, 5, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 7, 5, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 8, 5, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 9, 5, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 10, 5, COLORS.neonOrange);
|
||||
|
||||
// Vest with gems
|
||||
pixel(ctx, baseX, baseY, 5, 6, COLORS.neonGreen);
|
||||
pixel(ctx, baseX, baseY, 6, 6, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 7, 6, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 8, 6, COLORS.neonCyan);
|
||||
pixel(ctx, baseX, baseY, 9, 6, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 10, 6, COLORS.neonGreen);
|
||||
|
||||
// Arms
|
||||
pixel(ctx, baseX, baseY, 4, 6, COLORS.skinDark);
|
||||
pixel(ctx, baseX, baseY, 11, 6, COLORS.skinDark);
|
||||
|
||||
// Rings on fingers - neon
|
||||
pixel(ctx, baseX, baseY, 4, 7, COLORS.neonOrange);
|
||||
pixel(ctx, baseX, baseY, 11, 7, COLORS.neonOrange);
|
||||
|
||||
// Legs - fancy pants
|
||||
pixel(ctx, baseX, baseY, 6, 10, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 7, 10, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 8, 10, COLORS.blue);
|
||||
pixel(ctx, baseX, baseY, 9, 10, COLORS.blue);
|
||||
|
||||
// Fancy shoes
|
||||
pixel(ctx, baseX, baseY, 6, 12, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 7, 12, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 8, 12, COLORS.neonPink);
|
||||
pixel(ctx, baseX, baseY, 9, 12, COLORS.neonPink);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw interaction prompt
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x - Screen X position
|
||||
* @param {number} y - Screen Y position
|
||||
*/
|
||||
export function drawInteractionPrompt(ctx, x, y) {
|
||||
const baseX = x * SCALE;
|
||||
const baseY = y * SCALE;
|
||||
|
||||
ctx.fillStyle = COLORS.neonGreen;
|
||||
ctx.font = `bold ${12 * SCALE}px monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('E', baseX + 8 * SCALE, baseY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a dialog box at the bottom of the screen
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} canvasWidth - Canvas width in pixels
|
||||
* @param {number} canvasHeight - Canvas height in pixels
|
||||
* @param {string} text - Dialog text
|
||||
* @param {string} speakerName - NPC name
|
||||
*/
|
||||
export function drawDialogBox(ctx, canvasWidth, canvasHeight, text, speakerName) {
|
||||
const padding = 20;
|
||||
const boxHeight = 120;
|
||||
const boxY = canvasHeight - boxHeight - padding;
|
||||
const boxX = padding;
|
||||
const boxWidth = canvasWidth - 2 * padding;
|
||||
|
||||
// Dialog box background
|
||||
ctx.fillStyle = COLORS.black;
|
||||
ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
|
||||
|
||||
// Neon border
|
||||
ctx.strokeStyle = COLORS.neonCyan;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(boxX, boxY, boxWidth, boxHeight);
|
||||
|
||||
// Corner accents
|
||||
ctx.fillStyle = COLORS.neonGreen;
|
||||
const cornerSize = 10;
|
||||
ctx.fillRect(boxX, boxY, cornerSize, 3);
|
||||
ctx.fillRect(boxX, boxY, 3, cornerSize);
|
||||
ctx.fillRect(boxX + boxWidth - cornerSize, boxY, cornerSize, 3);
|
||||
ctx.fillRect(boxX + boxWidth - 3, boxY, 3, cornerSize);
|
||||
|
||||
// Speaker name - neon green
|
||||
ctx.fillStyle = COLORS.neonGreen;
|
||||
ctx.font = `bold 16px monospace`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(speakerName, boxX + 15, boxY + 10);
|
||||
|
||||
// Dialog text - cyan
|
||||
ctx.fillStyle = COLORS.neonCyan;
|
||||
ctx.font = `14px monospace`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
// Word wrap
|
||||
const maxWidth = boxWidth - 30;
|
||||
const lineHeight = 18;
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
let lineNum = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = line + (line ? ' ' : '') + word;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && line) {
|
||||
ctx.fillText(line, boxX + 15, boxY + 40 + lineNum * lineHeight);
|
||||
line = word;
|
||||
lineNum++;
|
||||
} else {
|
||||
line = testLine;
|
||||
export function drawPlayer(ctx, screenX, screenY, direction, walkFrame) {
|
||||
const spriteDir = DIR_TO_SPRITE[direction] || 'front';
|
||||
const frameName = walkFrame === 0 ? 'still' : walkFrame === 1 ? 'walk-1' : 'walk-2';
|
||||
const key = `char:${spriteDir}-${frameName}`;
|
||||
const img = imageCache[key];
|
||||
if (!img) {
|
||||
// Fallback: colored rectangle
|
||||
ctx.fillStyle = '#00e599';
|
||||
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX * 2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (line) {
|
||||
ctx.fillText(line, boxX + 15, boxY + 40 + lineNum * lineHeight);
|
||||
}
|
||||
|
||||
// Cursor prompt - blinking indicator
|
||||
ctx.fillStyle = COLORS.neonPink;
|
||||
ctx.font = `bold 16px monospace`;
|
||||
ctx.fillText('▼', boxX + boxWidth - 30, boxY + boxHeight - 20);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
// Character is 32x32 native = 2x2 tiles, draw at SCALE
|
||||
ctx.drawImage(img, screenX, screenY, 32 * SCALE, 32 * SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an NPC
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} screenX - top-left X on screen
|
||||
* @param {number} screenY - top-left Y on screen
|
||||
* @param {string} facing - 'up'|'down'|'left'|'right'
|
||||
*/
|
||||
export function drawNPC(ctx, screenX, screenY, facing) {
|
||||
const dir = DIR_TO_NPC[facing] || 'down';
|
||||
const key = `npc:a-${dir}`;
|
||||
const img = imageCache[key];
|
||||
if (!img) {
|
||||
// Fallback
|
||||
ctx.fillStyle = '#ff44aa';
|
||||
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX);
|
||||
return;
|
||||
}
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
// NPC is 16x16 native = 1 tile
|
||||
ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the interaction prompt (E button hint) above a tile
|
||||
*/
|
||||
export function drawInteractionPrompt(ctx, screenX, screenY) {
|
||||
const cx = screenX + TILE_PX / 2;
|
||||
const cy = screenY - 12;
|
||||
|
||||
// Bubble background
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(cx - 18, cy - 12, 36, 22, 6);
|
||||
ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#00e599';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(cx - 18, cy - 12, 36, 22, 6);
|
||||
ctx.stroke();
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('[E]', cx, cy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the dialog box at the bottom of the screen
|
||||
*/
|
||||
export function drawDialogBox(ctx, canvasW, canvasH, text, speakerName) {
|
||||
const boxH = 100;
|
||||
const boxY = canvasH - boxH - 16;
|
||||
const boxX = 32;
|
||||
const boxW = canvasW - 64;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = 'rgba(10, 14, 39, 0.92)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(boxX, boxY, boxW, boxH, 10);
|
||||
ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#00e599';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(boxX, boxY, boxW, boxH, 10);
|
||||
ctx.stroke();
|
||||
|
||||
// Speaker name
|
||||
if (speakerName) {
|
||||
ctx.fillStyle = '#00e599';
|
||||
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(speakerName, boxX + 16, boxY + 12);
|
||||
}
|
||||
|
||||
// Text
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
const textY = speakerName ? boxY + 34 : boxY + 16;
|
||||
// Simple word wrap
|
||||
wrapText(ctx, text, boxX + 16, textY, boxW - 32, 20);
|
||||
|
||||
// Continue prompt
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('Press E to continue ▶', boxX + boxW - 16, boxY + boxH - 16);
|
||||
}
|
||||
|
||||
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
let currentY = y;
|
||||
for (const word of words) {
|
||||
const test = line + (line ? ' ' : '') + word;
|
||||
if (ctx.measureText(test).width > maxWidth && line) {
|
||||
ctx.fillText(line, x, currentY);
|
||||
line = word;
|
||||
currentY += lineHeight;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) ctx.fillText(line, x, currentY);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// worldRenderer.js - Renders the tile-based game world on canvas
|
||||
import { drawPlayer, drawTile, drawNPC, drawInteractionPrompt, drawDialogBox, TILE_SIZE, SCALE } from './sprites.js';
|
||||
// worldRenderer.js - Renders PNG-based game world on canvas
|
||||
import {
|
||||
drawMapImage, drawPlayer, drawNPC, drawInteractionPrompt,
|
||||
drawDialogBox, preloadAssets, TILE_PX, SCALE
|
||||
} from './sprites.js';
|
||||
import { worldState } from './worldState.js';
|
||||
import { getMap, getTile, getInteraction, getNPC } from './maps.js';
|
||||
import { getMap, getInteraction, getNPC } from './maps.js';
|
||||
import { updateMovement } from './worldInput.js';
|
||||
|
||||
let canvas = null;
|
||||
@@ -9,14 +12,11 @@ let ctx = null;
|
||||
let animFrameId = null;
|
||||
let lastTime = 0;
|
||||
|
||||
const TILE_PX = TILE_SIZE * SCALE; // 48px per tile on screen
|
||||
|
||||
export function initWorldRenderer() {
|
||||
canvas = document.getElementById('canvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
return true;
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
@@ -25,36 +25,30 @@ function resizeCanvas() {
|
||||
canvas.height = canvas.offsetHeight || window.innerHeight;
|
||||
}
|
||||
|
||||
/** Convert tile coords → screen pixel coords (camera-relative) */
|
||||
export function worldToScreen(tileX, tileY) {
|
||||
// ==================== Camera ====================
|
||||
|
||||
/** Get the pixel offset to draw the map so the player is centered */
|
||||
function getCameraOffset() {
|
||||
const p = worldState.player;
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
// Player world position with interpolation
|
||||
const pwx = (p.x + p.px) * TILE_PX;
|
||||
const pwy = (p.y + p.py) * TILE_PX;
|
||||
const playerWorldX = (p.x + p.px) * TILE_PX;
|
||||
const playerWorldY = (p.y + p.py) * TILE_PX;
|
||||
return {
|
||||
x: cx + (tileX * TILE_PX - pwx),
|
||||
y: cy + (tileY * TILE_PX - pwy)
|
||||
x: canvas.width / 2 - playerWorldX - TILE_PX / 2,
|
||||
y: canvas.height / 2 - playerWorldY - TILE_PX / 2
|
||||
};
|
||||
}
|
||||
|
||||
/** Visible tile range for culling */
|
||||
function getVisibleBounds() {
|
||||
const p = worldState.player;
|
||||
const halfW = canvas.width / 2;
|
||||
const halfH = canvas.height / 2;
|
||||
const pwx = (p.x + p.px) * TILE_PX;
|
||||
const pwy = (p.y + p.py) * TILE_PX;
|
||||
/** Convert tile position to screen position */
|
||||
function tileToScreen(tileX, tileY) {
|
||||
const cam = getCameraOffset();
|
||||
return {
|
||||
minX: Math.floor((pwx - halfW) / TILE_PX) - 1,
|
||||
minY: Math.floor((pwy - halfH) / TILE_PX) - 1,
|
||||
maxX: Math.ceil((pwx + halfW) / TILE_PX) + 1,
|
||||
maxY: Math.ceil((pwy + halfH) / TILE_PX) + 1
|
||||
x: tileX * TILE_PX + cam.x,
|
||||
y: tileY * TILE_PX + cam.y
|
||||
};
|
||||
}
|
||||
|
||||
/** Tile the player is facing */
|
||||
// ==================== Facing tile ====================
|
||||
|
||||
function getFacingTile() {
|
||||
const p = worldState.player;
|
||||
let x = p.x, y = p.y;
|
||||
@@ -65,12 +59,13 @@ function getFacingTile() {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Main render frame */
|
||||
// ==================== Main render ====================
|
||||
|
||||
export function renderWorld(timestamp) {
|
||||
const dt = (timestamp - lastTime) / 1000;
|
||||
lastTime = timestamp;
|
||||
|
||||
// Update movement interpolation
|
||||
// Update movement
|
||||
updateMovement(dt);
|
||||
|
||||
// Resize check
|
||||
@@ -85,34 +80,34 @@ export function renderWorld(timestamp) {
|
||||
const map = getMap(worldState.currentMap);
|
||||
if (!map) return;
|
||||
|
||||
const bounds = getVisibleBounds();
|
||||
const cam = getCameraOffset();
|
||||
|
||||
// === Layer 1: Tiles ===
|
||||
for (let ty = bounds.minY; ty <= bounds.maxY; ty++) {
|
||||
for (let tx = bounds.minX; tx <= bounds.maxX; tx++) {
|
||||
const tileType = getTile(worldState.currentMap, tx, ty);
|
||||
if (tileType === null) continue;
|
||||
const pos = worldToScreen(tx, ty);
|
||||
drawTile(ctx, pos.x, pos.y, tileType);
|
||||
}
|
||||
}
|
||||
// === Layer 1: Map background (PNG) ===
|
||||
drawMapImage(ctx, map.image, cam.x, cam.y);
|
||||
|
||||
// === Layer 2: NPCs ===
|
||||
if (map.npcs) {
|
||||
for (const npc of map.npcs) {
|
||||
const pos = worldToScreen(npc.x, npc.y);
|
||||
drawNPC(ctx, pos.x, pos.y, npc.type, 0);
|
||||
const pos = tileToScreen(npc.x, npc.y);
|
||||
drawNPC(ctx, pos.x, pos.y, npc.facing || 'down');
|
||||
}
|
||||
}
|
||||
|
||||
// === Layer 3: Player ===
|
||||
const pp = worldToScreen(worldState.player.x + worldState.player.px,
|
||||
worldState.player.y + worldState.player.py);
|
||||
// Adjust: worldToScreen already offsets from player, so player is always at center
|
||||
const pcx = canvas.width / 2 - TILE_PX / 2;
|
||||
const pcy = canvas.height / 2 - TILE_PX / 2;
|
||||
const frame = worldState.player.moving ? (Math.floor(Date.now() / 120) % 2) : 0;
|
||||
drawPlayer(ctx, pcx, pcy, worldState.player.direction, frame);
|
||||
// Character sprite is 32x32 native (2 tiles tall)
|
||||
// Position so bottom half aligns with player tile, top half overlaps above
|
||||
const playerScreen = tileToScreen(
|
||||
worldState.player.x + worldState.player.px,
|
||||
worldState.player.y + worldState.player.py
|
||||
);
|
||||
// Offset upward by 1 tile since character is 2 tiles tall
|
||||
const playerDrawX = playerScreen.x;
|
||||
const playerDrawY = playerScreen.y - TILE_PX;
|
||||
|
||||
const walkFrame = worldState.player.moving
|
||||
? (Math.floor(Date.now() / 150) % 2) + 1 // alternates 1, 2
|
||||
: 0;
|
||||
drawPlayer(ctx, playerDrawX, playerDrawY, worldState.player.direction, walkFrame);
|
||||
|
||||
// === Layer 4: Interaction prompt ===
|
||||
if (!worldState.dialog && !worldState.player.moving) {
|
||||
@@ -120,12 +115,12 @@ export function renderWorld(timestamp) {
|
||||
const inter = getInteraction(worldState.currentMap, ft.x, ft.y);
|
||||
const npc = getNPC(worldState.currentMap, ft.x, ft.y);
|
||||
if (inter || npc) {
|
||||
const pos = worldToScreen(ft.x, ft.y);
|
||||
const pos = tileToScreen(ft.x, ft.y);
|
||||
drawInteractionPrompt(ctx, pos.x, pos.y);
|
||||
}
|
||||
}
|
||||
|
||||
// === Layer 5: Dialog box ===
|
||||
// === Layer 5: Dialog ===
|
||||
if (worldState.dialog) {
|
||||
const line = worldState.dialog.lines[worldState.dialog.currentLine] || '';
|
||||
const speaker = worldState.dialog.speakerName || '';
|
||||
@@ -133,11 +128,10 @@ export function renderWorld(timestamp) {
|
||||
}
|
||||
|
||||
// === HUD ===
|
||||
drawHUD();
|
||||
drawHUD(map);
|
||||
}
|
||||
|
||||
function drawHUD() {
|
||||
const map = getMap(worldState.currentMap);
|
||||
function drawHUD(map) {
|
||||
const mapName = map ? map.name : worldState.currentMap;
|
||||
|
||||
// Background bar
|
||||
@@ -164,7 +158,12 @@ function drawHUD() {
|
||||
ctx.fillText('WASD: Move | E: Interact | TAB: Workshop', canvas.width / 2, 16);
|
||||
}
|
||||
|
||||
export function startWorldLoop() {
|
||||
// ==================== Loop control ====================
|
||||
|
||||
export async function startWorldLoop() {
|
||||
// Ensure assets are loaded before starting
|
||||
await preloadAssets();
|
||||
|
||||
lastTime = performance.now();
|
||||
function loop(ts) {
|
||||
renderWorld(ts);
|
||||
|
||||
@@ -11,8 +11,8 @@ export const worldState = {
|
||||
|
||||
// Player
|
||||
player: {
|
||||
x: 10,
|
||||
y: 12, // tile position in current map
|
||||
x: 4,
|
||||
y: 10, // tile position in current map
|
||||
px: 0,
|
||||
py: 0, // pixel offset for smooth movement (interpolation)
|
||||
direction: 'down', // 'up' | 'down' | 'left' | 'right'
|
||||
@@ -58,8 +58,8 @@ export const worldState = {
|
||||
*/
|
||||
export function resetWorldState() {
|
||||
worldState.mode = 'world';
|
||||
worldState.player.x = 10;
|
||||
worldState.player.y = 12;
|
||||
worldState.player.x = 4;
|
||||
worldState.player.y = 10;
|
||||
worldState.player.px = 0;
|
||||
worldState.player.py = 0;
|
||||
worldState.player.direction = 'down';
|
||||
|
||||