feat: dež po celi mapi + BuildingSystem [2026-03-02]

RAIN SYSTEM:
- Zamenjal sprite particle emitter z procedural canvas rain (Graphics)
- 160 naključnih kapljic kot kratke diagonalne črtice (8-18px)
- Hitrost 420-700 px/s, nagib v levo (kot pravi dež)
- Alpha 0.25-0.55 — prozorno, otok viden
- Brez vzorcev / 'stebrov' — pravo Math.random() razmestitev
- Odpravil bug: dež je zdaj po celi mapi (sledil Kai-u)
- Odpravil SyntaxError: viewW dvojna deklaracija → rainViewW

BUILDING SYSTEM (src/systems/BuildingSystem.js):
- Nov modul BuildingSystem.js
- [B] tipka odpre/zapre Build Mode
- Stranski meni z ikonami: Šotor, Ogenj, Zbirač dežja, Temelj
- Ghost preview (zelena = ok, rdeča = zunaj otoka)
- Postavitev z klikom na otok
- Y-sorting: Kai se skrije za stavbami ko gre zadaj
- Fizični collider (static body) — Kai ne more skozi stavbe
- Spawn animacija (Back.out)
- Preloadan foundation_concrete.png
This commit is contained in:
2026-03-02 20:42:49 +01:00
parent 1d7aaf9562
commit 4cbb198d7a
3 changed files with 524 additions and 18 deletions

View File

@@ -0,0 +1,434 @@
/**
* ============================================================
* BUILDING SYSTEM — Nova Farma
* ============================================================
* Handles:
* - Build Mode (toggle with [B] key or UI button)
* - Ghost preview image that follows the cursor
* - Click to place building at cursor position
* - Y-sort depth so Kai walks behind / in front of buildings
* - Physics static body so Kai cannot walk through walls
* - Side-panel Build UI with selectable building icons
* ============================================================
*/
export default class BuildingSystem {
/**
* @param {Phaser.Scene} scene - The scene this system runs in
* @param {Phaser.Physics.Arcade.Sprite} kai - Player sprite reference
*/
constructor(scene, kai) {
this.scene = scene;
this.kai = kai;
// Build mode state
this.active = false; // Is build mode ON?
this.selectedKey = null; // Currently selected building texture key
// Ghost preview sprite
this.ghost = null;
// Group of all placed buildings (static physics bodies)
this.buildingsGroup = scene.physics.add.staticGroup();
// Catalogue of buildable items
// { key, label, colliderW, colliderH, colliderOffsetX, colliderOffsetY, scale }
// Scale is % of Kai height (64px).
this.catalogue = [
{
key: 'sotor',
label: 'Šotor',
scale: 2.5, // 2.5× Kai → 160px tall
colliderW: 160,
colliderH: 40,
colliderOffsetX: -80,
colliderOffsetY: -40,
},
{
key: 'campfire',
label: 'Ogenj',
scale: 1.0, // Same height as Kai
colliderW: 50,
colliderH: 20,
colliderOffsetX: -25,
colliderOffsetY: -20,
},
{
key: 'rain_catcher',
label: 'Zbirač dežja',
scale: 2.0,
colliderW: 140,
colliderH: 40,
colliderOffsetX: -70,
colliderOffsetY: -40,
},
{
key: 'foundation_concrete',
label: 'Temelj',
scale: 2.0,
colliderW: 120,
colliderH: 20,
colliderOffsetX: -60,
colliderOffsetY: -20,
},
];
// Build UI panel (DOM container)
this.panel = null;
// Track placed buildings for saving/loading later
this.placed = [];
this._setupUI();
this._setupInput();
}
// ==========================================
// UI PANEL (Fixed to screen, top-right)
// ==========================================
_setupUI() {
const scene = this.scene;
const CAM_W = scene.cameras.main.width;
const PANEL_W = 80;
const PANEL_PADDING = 8;
const BTN_SIZE = 60;
const BTN_GAP = 12;
// ── Background Panel ─────────────────────────────────────────────
this.panel = scene.add.graphics()
.setScrollFactor(0)
.setDepth(20000);
const panelH = this.catalogue.length * (BTN_SIZE + BTN_GAP) + PANEL_PADDING * 2 + 40;
const panelX = CAM_W - PANEL_W - 16;
const panelY = 120;
// Dark translucent panel
this.panel.fillStyle(0x0a0a0f, 0.85);
this.panel.fillRoundedRect(panelX, panelY, PANEL_W, panelH, 10);
this.panel.lineStyle(2, 0x44ffaa, 0.6);
this.panel.strokeRoundedRect(panelX, panelY, PANEL_W, panelH, 10);
// "Build" label
scene.add.text(
panelX + PANEL_W / 2,
panelY + 10,
'[B]',
{ fontSize: '13px', color: '#44ffaa', fontFamily: 'Arial', align: 'center' }
).setOrigin(0.5, 0).setScrollFactor(0).setDepth(20001);
// ── Building Buttons ──────────────────────────────────────────────
this.catalogue.forEach((item, i) => {
const btnX = panelX + PANEL_PADDING;
const btnY = panelY + 40 + i * (BTN_SIZE + BTN_GAP);
const btnW = PANEL_W - PANEL_PADDING * 2;
const btnH = BTN_SIZE;
// Button background
const btnBg = scene.add.graphics()
.setScrollFactor(0)
.setDepth(20001);
btnBg.fillStyle(0x1a2a1a, 0.9);
btnBg.fillRoundedRect(btnX, btnY, btnW, btnH, 6);
btnBg.lineStyle(1, 0x224422, 1.0);
btnBg.strokeRoundedRect(btnX, btnY, btnW, btnH, 6);
// Icon (preview of the building texture)
const icon = scene.add.image(
btnX + btnW / 2,
btnY + btnH / 2 - 8,
item.key
)
.setScrollFactor(0)
.setDepth(20002)
.setDisplaySize(btnW - 8, btnH - 20);
// Label text
const label = scene.add.text(
btnX + btnW / 2,
btnY + btnH - 10,
item.label,
{ fontSize: '9px', color: '#aaffcc', fontFamily: 'Arial', align: 'center' }
).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(20002);
// Invisible hit zone for click detection
const hitZone = scene.add.zone(btnX, btnY, btnW, btnH)
.setOrigin(0, 0)
.setScrollFactor(0)
.setDepth(20003)
.setInteractive({ cursor: 'pointer' });
hitZone.on('pointerover', () => {
btnBg.clear();
btnBg.fillStyle(0x2a4a2a, 1.0);
btnBg.fillRoundedRect(btnX, btnY, btnW, btnH, 6);
btnBg.lineStyle(2, 0x44ffaa, 1.0);
btnBg.strokeRoundedRect(btnX, btnY, btnW, btnH, 6);
});
hitZone.on('pointerout', () => {
const isSelected = this.selectedKey === item.key;
btnBg.clear();
btnBg.fillStyle(isSelected ? 0x1a4a1a : 0x1a2a1a, 0.9);
btnBg.fillRoundedRect(btnX, btnY, btnW, btnH, 6);
btnBg.lineStyle(isSelected ? 2 : 1, isSelected ? 0x44ffaa : 0x224422, 1.0);
btnBg.strokeRoundedRect(btnX, btnY, btnW, btnH, 6);
});
hitZone.on('pointerdown', () => {
this.selectBuilding(item.key);
});
// Store references to update highlight state
item._btnBg = btnBg;
item._btnX = btnX;
item._btnY = btnY;
item._btnW = btnW;
item._btnH = btnH;
});
}
// ==========================================
// KEYBOARD: [B] toggles build mode
// ==========================================
_setupInput() {
const scene = this.scene;
// Toggle build mode with [B]
scene.input.keyboard.on('keydown-B', () => {
if (this.active) {
this.deactivate();
} else {
this.activate();
}
});
// Cancel / exit build mode with [Escape]
scene.input.keyboard.on('keydown-ESC', () => {
if (this.active) this.deactivate();
});
}
// ==========================================
// ACTIVATE BUILD MODE
// ==========================================
activate() {
this.active = true;
// If nothing selected yet, default to first item
if (!this.selectedKey) {
this.selectBuilding(this.catalogue[0].key);
} else {
this._createGhost(this.selectedKey);
}
// Show tip text
if (!this._modeTip) {
this._modeTip = this.scene.add.text(
this.scene.cameras.main.width / 2,
20,
'🏗️ NAČIN GRADNJE | Klik = postavi | B / Esc = izhod',
{
fontSize: '16px',
color: '#44ffaa',
stroke: '#000',
strokeThickness: 3,
fontFamily: 'Arial',
backgroundColor: '#0a0a0f99',
padding: { x: 10, y: 4 }
}
)
.setOrigin(0.5, 0)
.setScrollFactor(0)
.setDepth(20010);
}
this._modeTip.setVisible(true);
}
// ==========================================
// DEACTIVATE BUILD MODE
// ==========================================
deactivate() {
this.active = false;
this.selectedKey = null;
if (this.ghost) {
this.ghost.destroy();
this.ghost = null;
}
if (this._modeTip) this._modeTip.setVisible(false);
// Reset all button highlights
this.catalogue.forEach(item => this._drawBtn(item, false));
}
// ==========================================
// SELECT BUILDING TYPE
// ==========================================
selectBuilding(key) {
this.selectedKey = key;
if (!this.active) this.activate();
// Destroy old ghost
if (this.ghost) {
this.ghost.destroy();
this.ghost = null;
}
this._createGhost(key);
// Update button highlights
this.catalogue.forEach(item => {
this._drawBtn(item, item.key === key);
});
}
_drawBtn(item, selected) {
if (!item._btnBg) return;
item._btnBg.clear();
item._btnBg.fillStyle(selected ? 0x1a4a1a : 0x1a2a1a, 0.9);
item._btnBg.fillRoundedRect(item._btnX, item._btnY, item._btnW, item._btnH, 6);
item._btnBg.lineStyle(selected ? 2 : 1, selected ? 0x44ffaa : 0x224422, 1.0);
item._btnBg.strokeRoundedRect(item._btnX, item._btnY, item._btnW, item._btnH, 6);
}
// ==========================================
// GHOST PREVIEW SPRITE
// ==========================================
_createGhost(key) {
const def = this.catalogue.find(c => c.key === key);
if (!def) return;
const KAI_H = 64; // px
const targetH = KAI_H * def.scale;
const texture = this.scene.textures.get(key);
const srcH = texture.getSourceImage().height;
const scale = targetH / srcH;
this.ghost = this.scene.add.image(0, 0, key)
.setScale(scale)
.setOrigin(0.5, 1.0) // Feet at cursor
.setAlpha(0.6)
.setTint(0x44ffaa) // Green tint = valid placement
.setDepth(99999);
// Make ghost follow pointer in update()
}
// ==========================================
// UPDATE (call from scene's update())
// ==========================================
update(pointer) {
if (!this.active || !this.ghost) return;
// Ghost follows world-space cursor
const worldX = pointer.worldX;
const worldY = pointer.worldY;
this.ghost.setPosition(worldX, worldY);
// Check if placement is on the island (within physics bounds)
const bounds = this.scene.physics.world.bounds;
const onIsland = (
worldX > bounds.x &&
worldX < bounds.x + bounds.width &&
worldY > bounds.y &&
worldY < bounds.y + bounds.height
);
// Green = valid, Red = invalid
this.ghost.setTint(onIsland ? 0x44ffaa : 0xff4444);
}
// ==========================================
// PLACE BUILDING (called on click)
// ==========================================
placeBuilding(worldX, worldY) {
if (!this.active || !this.selectedKey) return;
const def = this.catalogue.find(c => c.key === this.selectedKey);
if (!def) return;
// Validate placement is on island
const bounds = this.scene.physics.world.bounds;
if (
worldX <= bounds.x || worldX >= bounds.x + bounds.width ||
worldY <= bounds.y || worldY >= bounds.y + bounds.height
) {
// Flash ghost red
this.scene.tweens.add({
targets: this.ghost,
alpha: { from: 0.9, to: 0.3 },
duration: 100,
yoyo: true,
repeat: 2,
});
return;
}
// ── Calculate final scale ─────────────────────────────────────────
const KAI_H = 64;
const targetH = KAI_H * def.scale;
const texture = this.scene.textures.get(this.selectedKey);
const srcH = texture.getSourceImage().height;
const scale = targetH / srcH;
// ── Create placed sprite ──────────────────────────────────────────
const building = this.scene.physics.add.staticImage(worldX, worldY, this.selectedKey)
.setScale(scale)
.setOrigin(0.5, 1.0);
// Y-sort: depth is based on the foot Y position (worldY) so Kai sorts properly
building.setDepth(worldY);
// ── Physics collider body ─────────────────────────────────────────
// Adjust physics body to cover just the base of the building
const bodyW = def.colliderW;
const bodyH = def.colliderH;
const offsetX = srcH * scale * 0 + def.colliderOffsetX; // center-ish
const offsetY = def.colliderOffsetY; // above feet
building.body.setSize(bodyW, bodyH);
building.body.setOffset(
(texture.getSourceImage().width * scale) / 2 + def.colliderOffsetX,
srcH * scale + def.colliderOffsetY
);
building.body.reset(worldX, worldY);
// ── Add to static group & collider ───────────────────────────────
this.buildingsGroup.add(building, true);
this.scene.physics.add.collider(this.kai, building);
// ── Spawn animation ───────────────────────────────────────────────
building.setAlpha(0);
this.scene.tweens.add({
targets: building,
alpha: 1,
scaleX: scale,
scaleY: scale,
duration: 300,
ease: 'Back.out',
});
// Save for later
this.placed.push({ key: this.selectedKey, x: worldX, y: worldY });
// ── Update depth every frame (for Y-sort) ────────────────────────
// Store ref so update() can sort depths
building._isBuilding = true;
this.scene._buildingSprites = this.scene._buildingSprites || [];
this.scene._buildingSprites.push(building);
// Small flash feedback
const flash = this.scene.add.rectangle(worldX, worldY - targetH * 0.5, 80, 80, 0x44ffaa, 0.7)
.setDepth(99998);
this.scene.tweens.add({
targets: flash,
alpha: 0,
scaleX: 2,
scaleY: 2,
duration: 400,
onComplete: () => flash.destroy(),
});
console.log(`🏗️ Placed: ${this.selectedKey} at (${Math.round(worldX)}, ${Math.round(worldY)})`);
}
}