diff --git a/frontend/src/game/GardenRenderer.ts b/frontend/src/game/GardenRenderer.ts index 89a732e..067606f 100644 --- a/frontend/src/game/GardenRenderer.ts +++ b/frontend/src/game/GardenRenderer.ts @@ -10,6 +10,7 @@ plantTexture, ringTexture, selectOutlineTexture, + shadowTexture, sparkleTexture, tileTexture, } from './textures' @@ -29,6 +30,7 @@ interface ItemView { root: Container + shadow: Sprite body: Sprite ring: Sprite | null sparkles: Sprite[] @@ -214,11 +216,18 @@ private buildHouse(): void { this.houseLayer.removeChildren().forEach((c) => c.destroy()) + // изометрическая тень — эллипс под основанием домика + const shadow = new Sprite(shadowTexture()) + shadow.anchor.set(0.5) + shadow.alpha = 0.5 + shadow.scale.set(116 / 22, 44 / 8) + shadow.position.set(this.worldWidth() / 2, this.worldHeight() / 2 + 8) + this.houseLayer.addChild(shadow) const house = new Sprite(houseTexture()) house.scale.set(PX) - const cols = Math.round(this.worldWidth() / CELL_WORLD) - const rows = Math.round(this.worldHeight() / CELL_WORLD) - house.position.set((cols / 2 - 2) * CELL_WORLD, (rows / 2 - 1.5) * CELL_WORLD) + // anchor — центр основания ромба в матрице HOUSE (27, 32) + house.anchor.set(27 / 56, 32 / 46) + house.position.set(this.worldWidth() / 2, this.worldHeight() / 2) this.houseLayer.addChild(house) } @@ -264,6 +273,12 @@ rootC.position.set(cx, cy) rootC.zIndex = (item.y ?? 0) * 1000 + (item.x ?? 0) + // мягкая тень под объектом (ширина — по телу) + const shadow = new Sprite(shadowTexture()) + shadow.anchor.set(0.5) + shadow.alpha = 0.45 + rootC.addChild(shadow) + let ring: Sprite | null = null if (item.kind === 'plant' && (item.rarity === 'rare' || item.rarity === 'epic')) { ring = new Sprite(ringTexture(item.rarity ?? 'rare')) @@ -282,6 +297,9 @@ body.on('pointerdown', (e) => this.beginDrag(item.id, e)) rootC.addChild(body) + const view: ItemView = { root: rootC, shadow, body, ring, sparkles: [], item: { ...item }, growing: true } + this.fitShadow(view) + const sparkles: Sprite[] = [] if (item.kind === 'plant' && item.rarity === 'epic') { for (let i = 0; i < 3; i++) { @@ -293,9 +311,9 @@ sparkles.push(s) } } + view.sparkles = sparkles this.items.addChild(rootC) - const view: ItemView = { root: rootC, body, ring, sparkles, item: { ...item }, growing: true } this.views.set(item.id, view) // появление: рост от земли (scale контейнера от 0.2 до 1) this.tweens.push({ @@ -321,6 +339,7 @@ } if (item.kind === 'plant') view.body.texture = plantTexture(item.item_key ?? '', item.stage) else if (item.item_key) view.body.texture = decorTexture(item.item_key) + this.fitShadow(view) } private removeView(id: number): void { @@ -339,6 +358,13 @@ return decorTexture(item.item_key ?? 'flowerbed') } + /** Подогнать тень под текущую текстуру тела (при смене стадии/вида). */ + private fitShadow(view: ItemView): void { + const w = view.body.texture.width * PX * 0.9 + 4 + view.shadow.scale.set(w / 24, (w / 24) * 0.7) + view.shadow.position.set(0, 2) + } + // --- Drag & drop на канвасе --- private beginDrag(id: number, e: FederatedPointerEvent): void { diff --git a/frontend/src/game/sprites.ts b/frontend/src/game/sprites.ts index aada2fb..146e9b5 100644 --- a/frontend/src/game/sprites.ts +++ b/frontend/src/game/sprites.ts @@ -67,6 +67,44 @@ for (let x = x0; x <= x1; x++) put(m, x, y, ch) } +/** Заливка полигона сканлайнами (even-odd) — для изометрических граней. */ +function fillPoly(m: Matrix, pts: [number, number][], ch: string): void { + const ys = pts.map((p) => p[1]) + const y0 = Math.max(0, Math.ceil(Math.min(...ys))) + const y1 = Math.min(m.length - 1, Math.floor(Math.max(...ys))) + for (let y = y0; y <= y1; y++) { + const xs: number[] = [] + for (let i = 0; i < pts.length; i++) { + const [x1, y1] = pts[i]! + const [x2, y2] = pts[(i + 1) % pts.length]! + if (y1 === y2) continue + if (y >= Math.min(y1, y2) && y < Math.max(y1, y2)) { + xs.push(x1 + ((y - y1) / (y2 - y1)) * (x2 - x1)) + } + } + xs.sort((a, b) => a - b) + for (let i = 0; i + 1 < xs.length; i += 2) { + const xa = Math.ceil(xs[i]!) + const xb = Math.floor(xs[i + 1]!) + for (let x = xa; x <= xb; x++) put(m, x, y, ch) + } + } +} + +/** Вертикальная грань-параллелограмм между двумя нижними точками (высота h вверх). */ +function wall(m: Matrix, x1: number, y1: number, x2: number, y2: number, h: number, ch: string): void { + fillPoly( + m, + [ + [x1, y1], + [x2, y2], + [x2, y2 - h], + [x1, y1 - h], + ], + ch, + ) +} + // --- Тайлы (16×16, бесшовные) --- function speckle(m: Matrix, seed: number, chs: string[]): void { @@ -130,43 +168,96 @@ return m })() -// --- Домик (64×48, footprint 4×3 клетки) --- +// --- Домик (56×46, изометрия 2:1, footprint ~3.5×2.5 клетки) --- +// Грани строятся из мировых координат: sx = wx - wy, sy = (wx + wy)/2 - wz. +// Видимые грани: нижне-левая (тёмная N) и нижне-правая (светлая n); +// скат над нижне-левой гранью — в тени (R), над верхне-правой — свет (r). export const HOUSE: Matrix = (() => { - const m = blank(64, 48) - // стена - for (let y = 22; y < 48; y++) m[y] = 'n'.repeat(64) - hline(m, 0, 63, 22, 'N') - hline(m, 0, 63, 46, 'N') - hline(m, 0, 63, 47, 'N') - vline(m, 0, 22, 47, 'N') - vline(m, 63, 22, 47, 'N') - // крыша-трапеция (y 0..21) - for (let i = 0; i <= 21; i++) { - const half = Math.round(10 + (i / 21) * 22) - for (let x = 32 - half; x < 32 + half; x++) put(m, x, i, 'r') - put(m, 32 - half, i, 'R') - put(m, 32 + half - 1, i, 'R') + const W = 56 + const H = 46 + const ox = 23 + const oy = 20 + // мировая точка → матрица + const p = (wx: number, wy: number, wz: number): [number, number] => [ + Math.round(wx - wy + ox), + Math.round((wx + wy) / 2 - wz + oy), + ] + const m = blank(W, H) + + // основание 28×20, стены высотой 12, конёк wz=20 (wy=10), свес крыши 2 + const B = p(28, 0, 0)! // правый угол основания + const C = p(28, 20, 0)! // передний + const D = p(0, 20, 0)! // левый + const B2 = p(28, 0, 12)! + const C2 = p(28, 20, 12)! + const K1 = p(0, 10, 22)! // конёк: левый конец + const K2 = p(28, 10, 22)! // конёк: правый конец + + // стены: нижне-левая (тёмная) и нижне-правая (светлая) + wall(m, D[0], D[1], C[0], C[1], 12, 'N') + wall(m, C[0], C[1], B[0], B[1], 12, 'n') + // цоколь — тёмная кромка по низу стен + for (let i = 0; i <= 28; i++) { + const q1 = p(i, 20, 0) + const q2 = p(28, Math.round(20 - (i * 20) / 28), 0) + if (q1) put(m, q1[0], q1[1], 'O') + if (q2) put(m, q2[0], q2[1], 'O') } - hline(m, 10, 53, 21, 'R') - // дверь (10×16, по центру, до земли) - for (let y = 32; y < 48; y++) - for (let x = 27; x < 37; x++) { - const edge = x === 27 || x === 36 || y === 32 - put(m, x, y, edge ? 'O' : 'd') - } - // окна 8×8 с отблеском - for (const wx of [10, 46]) - for (let y = 28; y < 36; y++) - for (let x = wx; x < wx + 8; x++) { - const edge = y === 28 || y === 35 || x === wx || x === wx + 7 - put(m, x, y, edge ? 'N' : y === 29 && x === wx + 2 ? 'C' : 'c') - } + + // скат над верхне-правой гранью AB (виден сверху, освещён) + const M1 = p(0, -2, 12)! + const M2 = p(28, -2, 12)! + fillPoly(m, [K1, K2, M2, M1], 'r') + // скат над нижне-левой гранью DC (фронтальный, в тени) + const P1 = p(0, 22, 12)! + const P2 = p(28, 22, 12)! + fillPoly(m, [K1, K2, P2, P1], 'R') + // кромки карнизов + for (const [a, b] of [ + [M1, M2], + [P1, P2], + ] as const) + for (let t = 0; t <= 1; t += 1 / 40) + put(m, Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), 'O') + // конёк + for (let t = 0; t <= 1; t += 1 / 48) + put(m, Math.round(K1[0] + (K2[0] - K1[0]) * t), Math.round(K1[1] + (K2[1] - K1[1]) * t), 'r') + + // фронтон над правой гранью CB (щипец) + fillPoly(m, [C2, B2, K2], 'n') + + // труба на дальнем скате (низ «утоплен» в скат) + fillPoly( + m, + [ + [34, 2], + [39, 2], + [39, 17], + [34, 14], + ], + 'n', + ) + hline(m, 34, 39, 1, 'o') + hline(m, 34, 39, 2, 'O') + + // дверь на светлой (правой) грани: wy 7..13, высота 10 + const dr1 = p(28, 13, 0)! + const dr2 = p(28, 7, 0)! + fillPoly(m, [dr1, dr2, p(28, 7, 10)!, p(28, 13, 10)!], 'd') + // окно на тёмной (левой) грани: wx 16..22, wz 5..11 + const wn1 = p(16, 20, 5)! + const wn2 = p(22, 20, 5)! + fillPoly(m, [wn1, wn2, p(22, 20, 11)!, p(16, 20, 11)!], 'c') + // отблеск на окне и двери + put(m, wn1[0] + 2, wn1[1] - 3, 'C') + put(m, dr2[0] + 2, dr2[1] - 4, 'C') return m })() // --- Растения (6 видов × 3 стадии) --- // Вид по item_key с бэка; стадии 0/1/2. anchor спрайта (0.5, 0.95). +// Пропорции: деревья заметно выше кустов и цветов (крона над садом). /** Стебель с листиками (общая база цветочных видов). */ function flowerBase(w: number, h: number, topY: number): Matrix { @@ -234,9 +325,9 @@ function plantEvergreen(stage: number): Matrix { const sizes: [number, number][] = [ - [12, 12], - [16, 20], - [24, 28], + [14, 14], + [20, 26], + [28, 38], ] const [w, h] = sizes[stage]! const m = blank(w, h) @@ -284,8 +375,8 @@ function plantCactus(stage: number): Matrix { const sizes: [number, number][] = [ [12, 12], - [16, 20], - [24, 28], + [16, 22], + [22, 30], ] const [w, h] = sizes[stage]! const m = blank(w, h) @@ -324,9 +415,9 @@ function plantOak(stage: number): Matrix { const sizes: [number, number][] = [ - [12, 12], - [16, 20], - [24, 28], + [16, 16], + [22, 28], + [32, 44], ] const [w, h] = sizes[stage]! const m = blank(w, h) @@ -470,6 +561,13 @@ export const RING_RARE = ring('y') export const RING_EPIC = ring('q') +/** Мягкая овальная тень под объектом (рисуется с alpha ~0.45). */ +export const SHADOW: Matrix = (() => { + const m = blank(24, 10) + ellipse(m, 12, 5, 11, 4, 'm') + return m +})() + export const SPARKLE: Matrix = ['.q.', 'qqq', '.q.'] /** Рамка выделенной клетки (32×32, рисуется с alpha ~0.4). */ diff --git a/frontend/src/game/textures.ts b/frontend/src/game/textures.ts index cba1aac..7b0bcec 100644 --- a/frontend/src/game/textures.ts +++ b/frontend/src/game/textures.ts @@ -18,6 +18,7 @@ RING_EPIC, RING_RARE, SELECT_OUTLINE, + SHADOW, WILD, plantFlower, } from './sprites' @@ -91,4 +92,9 @@ /** Рамка выделенной/целевой клетки. */ export function selectOutlineTexture(): Texture { return textureFor('select-outline', SELECT_OUTLINE, PAL) +} + +/** Овальная тень под объектом (ширина матрицы 24, высота 10). */ +export function shadowTexture(): Texture { + return textureFor('shadow', SHADOW, PAL) } \ No newline at end of file