feat: fishing and cooking

This commit is contained in:
2026-09-05 17:46:15 +05:00
parent 6273f2849c
commit 32527ec2ed
10 changed files with 518 additions and 113 deletions
+32 -8
View File
@@ -9,6 +9,7 @@ const BAR_FG = 0x9be870;
const HANDLE = 0x8a6238;
const STEEL = 0xb8c4d0;
const RUSTY = 0x9a6a3f;
const AURA = 0xffb347;
interface Chip {
g: Graphics;
@@ -27,14 +28,19 @@ function makeTool(tool: string): Graphics {
g.poly([-1, -11, -8, -15, -8, -12, -1, -8]).fill(head);
} else if (tool === 'hammer') {
g.rect(-4, -15, 8, 4).fill(0x55606c);
} else if (tool === 'spoon') {
g.ellipse(0, -13, 2.5, 3.5).fill(RUSTY);
} else if (tool === 'rod') {
g.rect(-0.5, -20, 1.5, 20).fill(0x7d5a38);
g.circle(0.25, -20, 1.5).fill(STEEL);
}
return g;
}
/**
* Аватар M2: процедурный человечек, в руках — текущий инструмент
* (axe/pick тирами или молот у наковальни). Прогресс цикла клиент считает сам
* из actionStart/actionDur.
* Аватар M3: процедурный человечек с инструментом по действию (топор/кирка
* тирами, молот, ложка, удочка), аурой баффа отдыха и прогресс-баром цикла.
* Прогресс клиент считает сам из actionStart/actionDur.
*/
export class AvatarView {
readonly container = new Container();
@@ -43,6 +49,7 @@ export class AvatarView {
private readonly legR: Graphics;
private readonly toolHolder = new Container();
private readonly tools = new Map<string, Graphics>();
private readonly aura = new Graphics();
private readonly label: Text;
private readonly bar: Graphics;
private chips: Chip[] = [];
@@ -61,7 +68,7 @@ export class AvatarView {
const body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color);
const head = new Graphics().circle(0, -23, 6.5).fill(SKIN);
this.toolHolder.position.set(6, -14);
for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer']) {
for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer', 'spoon', 'rod']) {
const g = makeTool(tool);
g.visible = false;
this.tools.set(tool, g);
@@ -80,9 +87,11 @@ export class AvatarView {
this.label.anchor.set(0.5, 1);
this.label.y = -42;
this.bar = new Graphics();
this.aura.ellipse(0, 0, 15, 6.5).fill({ color: AURA, alpha: 0.3 });
this.aura.visible = false;
this.rig.addChild(shadow, this.legL, this.legR, body, head, this.toolHolder);
this.container.addChild(this.rig, this.bar, this.label);
this.container.addChild(this.aura, this.rig, this.bar, this.label);
this.container.position.set(av.x, av.y);
this.applyLabel(av);
@@ -127,8 +136,13 @@ export class AvatarView {
this.lastTool = av.tool;
for (const [id, g] of this.tools) g.visible = id === av.tool;
}
const working = av.action === 'chop' || av.action === 'mine' || av.action === 'smith';
const working =
av.action === 'chop' || av.action === 'mine' || av.action === 'smith' || av.action === 'cook' || av.action === 'fish';
this.toolHolder.visible = working && av.tool !== 'none';
this.aura.visible = av.restStacks > 0;
if (this.aura.visible) {
this.aura.alpha = 0.5 + av.restStacks * 0.2 + Math.sin(now / 300) * 0.08;
}
const t = now / 1000;
if (av.moving) {
@@ -136,7 +150,16 @@ export class AvatarView {
this.legL.rotation = swing * 0.5;
this.legR.rotation = -swing * 0.5;
this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5;
this.toolHolder.rotation = 0;
this.bar.visible = false;
} else if (av.action === 'rest') {
// отдых: спокойное дыхание
this.rig.y = Math.sin(t * 1.5) * 0.6;
this.legL.rotation = 0;
this.legR.rotation = 0;
this.toolHolder.rotation = 0;
this.bar.visible = false;
this.lastChopPhase = 0;
} else if (working && av.actionStart !== null && av.actionDur) {
const p = ((((now - av.actionStart) / av.actionDur) % 1) + 1) % 1;
const swing = Math.sin((Math.min(p, 0.35) / 0.35) * Math.PI);
@@ -148,10 +171,11 @@ export class AvatarView {
this.bar.clear();
this.bar.roundRect(-12, -36, 24, 3.5, 1.5).fill({ color: BAR_BG, alpha: 0.6 });
this.bar.roundRect(-12, -36, 24 * p, 3.5, 1.5).fill(BAR_FG);
if (this.lastChopPhase < 0.33 && p >= 0.33) this.spawnChips();
const chipsy = av.action === 'chop' || av.action === 'mine';
if (chipsy && this.lastChopPhase < 0.33 && p >= 0.33) this.spawnChips();
this.lastChopPhase = p < this.lastChopPhase ? 0 : p;
} else {
// отдых: лёгкое «дыхание»
// отдых в сторону: лёгкое «дыхание»
this.rig.y = Math.sin(t * 2) * 0.8;
this.legL.rotation = 0;
this.legR.rotation = 0;
+43
View File
@@ -0,0 +1,43 @@
import { Container, Graphics } from 'pixi.js';
import type { NodeState } from '@idle/shared';
const WOOD = 0x8a6238;
const WOOD_DARK = 0x6e4d2c;
const RIPPLE = 0xbfe3ff;
/** Рыбное место: мостки в воду и круги на воде; выработанное место — тихое. */
export class FishSpotView {
readonly container = new Container();
private readonly ripple = new Graphics();
private lastDown = false;
constructor(private st: NodeState) {
const pier = new Graphics()
.rect(-14, -2, 28, 6)
.fill(WOOD)
.rect(-14, 4, 28, 3)
.fill(WOOD_DARK)
.rect(-12, 4, 3, 8)
.fill(WOOD_DARK)
.rect(9, 4, 3, 8)
.fill(WOOD_DARK);
this.container.addChild(pier, this.ripple);
this.redraw();
}
apply(st: NodeState): void {
this.st = st;
this.container.zIndex = st.y;
this.redraw();
}
private redraw(): void {
const down = this.st.respawnAt !== null;
if (down === this.lastDown) return;
this.lastDown = down;
this.ripple.clear();
if (down) return;
this.ripple.circle(0, 8, 6).stroke({ color: RIPPLE, width: 1, alpha: 0.7 });
this.ripple.circle(0, 8, 11).stroke({ color: RIPPLE, width: 1, alpha: 0.4 });
}
}
+1
View File
@@ -1,4 +1,5 @@
export * from './avatar';
export * from './fish';
export * from './rock';
export * from './tree';
export * from './world';
+92 -18
View File
@@ -10,7 +10,9 @@ import type {
WelcomeMsg,
ZoneDef,
} from '@idle/shared';
import { RIVER_WATER_Y } from '@idle/shared';
import { AvatarView } from './avatar';
import { FishSpotView } from './fish';
import { RockView } from './rock';
import { TreeView } from './tree';
@@ -38,10 +40,15 @@ interface ZonePalette {
dot: number;
}
const WATER = 0x4a7fae;
const WAVE = 0x9cc7e8;
const ZONE_PALETTES: Record<string, ZonePalette> = {
forest: { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 },
mine: { base: 0x7d6b52, patch: 0x6e5d46, dot: 0x8d7a60 },
forge: { base: 0x5f5f68, patch: 0x55555e, dot: 0x6d6d78 },
river: { base: 0x5c8a52, patch: 0x527c49, dot: 0x6b9c60 },
kitchen: { base: 0x6d5c4b, patch: 0x5f4f40, dot: 0x7d6b58 },
};
const GROUND_FALLBACK: ZonePalette = { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 };
@@ -68,20 +75,27 @@ function drawGround(w: number, h: number, spawn: { x: number; y: number }, zones
for (const zone of zones) {
const p = ZONE_PALETTES[zone.id] ?? GROUND_FALLBACK;
const rng = mulberry32(hashStr(zone.id));
const isRiver = zone.id === 'river';
const topY = isRiver ? RIVER_WATER_Y : zone.y;
const landH = zone.h - (topY - zone.y);
g.rect(zone.x, zone.y, zone.w, zone.h).fill(p.base);
if (isRiver) {
g.rect(zone.x, zone.y, zone.w, RIVER_WATER_Y).fill(WATER);
for (let i = 0; i < 24; i++) {
g
.circle(zone.x + rng() * zone.w, zone.y + rng() * RIVER_WATER_Y, 1 + rng() * 1.5)
.fill({ color: WAVE, alpha: 0.5 });
}
g.rect(zone.x, topY, zone.w, 5).fill({ color: 0x8a7a5a, alpha: 0.8 });
}
for (let i = 0; i < 26; i++) {
g
.ellipse(
zone.x + rng() * zone.w,
zone.y + rng() * zone.h,
15 + rng() * 45,
10 + rng() * 30,
)
.ellipse(zone.x + rng() * zone.w, topY + rng() * landH, 15 + rng() * 45, 10 + rng() * 30)
.fill({ color: p.patch, alpha: 0.5 });
}
for (let i = 0; i < 60; i++) {
g
.circle(zone.x + rng() * zone.w, zone.y + rng() * zone.h, 1 + rng() * 1.5)
.circle(zone.x + rng() * zone.w, topY + rng() * landH, 1 + rng() * 1.5)
.fill({ color: p.dot, alpha: 0.7 });
}
}
@@ -91,13 +105,16 @@ function drawGround(w: number, h: number, spawn: { x: number; y: number }, zones
return g;
}
/** Наковальня с горном — рисуется по пропсам из протокола. */
function drawAnvil(x: number, y: number): Container {
interface Flicker {
g: Graphics;
base: number;
phase: number;
}
function drawAnvil(): Container {
const c = new Container();
c.position.set(x, y);
// горн позади
const furnace = new Graphics()
.rect(-58, -34, 34, 36, )
.rect(-58, -34, 34, 36)
.fill(0x3a3a40)
.rect(-54, -26, 26, 20)
.fill(0x26262c)
@@ -105,7 +122,6 @@ function drawAnvil(x: number, y: number): Container {
.fill({ color: 0xd86a2a, alpha: 0.9 })
.circle(-41, -16, 3)
.fill({ color: 0xffc46b, alpha: 0.9 });
// сама наковальня
const anvil = new Graphics()
.rect(-7, -8, 14, 8)
.fill(0x2f333b)
@@ -120,12 +136,56 @@ function drawAnvil(x: number, y: number): Container {
label.anchor.set(0.5, 1);
label.y = -40;
c.addChild(furnace, anvil, label);
c.zIndex = y;
return c;
}
function drawCampfire(flickers: Flicker[]): Container {
const c = new Container();
const stones = new Graphics();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
stones.circle(Math.cos(a) * 16, Math.sin(a) * 7, 4).fill(0x6d6d78);
}
const logA = new Graphics().rect(-10, -2, 20, 4).fill(0x6e4d2c);
logA.rotation = 0.35;
const logB = new Graphics().rect(-10, -2, 20, 4).fill(0x7d5a38);
logB.rotation = -0.35;
const glow = new Graphics().ellipse(0, -8, 24, 14).fill({ color: 0xff9a3b, alpha: 0.3 });
const flame1 = new Graphics().poly([0, -26, 8, -8, -8, -8]).fill(0xff8c3b);
const flame2 = new Graphics().poly([0, -18, 5, -6, -5, -6]).fill(0xffd166);
flickers.push(
{ g: glow, base: 0.75, phase: 0.8 },
{ g: flame1, base: 0.95, phase: 0 },
{ g: flame2, base: 0.9, phase: 1.7 },
);
c.addChild(glow, stones, logA, logB, flame1, flame2);
return c;
}
function drawStove(flickers: Flicker[]): Container {
const c = new Container();
const body = new Graphics()
.rect(-24, -46, 48, 46)
.fill(0x55555e)
.rect(-27, -50, 54, 6)
.fill(0x6d6d78)
.rect(-16, -68, 10, 22)
.fill(0x4a4f58)
.rect(-10, -22, 20, 16)
.fill(0x26262c);
const fire = new Graphics().ellipse(0, -14, 8, 5).fill({ color: 0xff8c3b, alpha: 0.9 });
const pot = new Graphics()
.circle(0, -52, 9)
.fill(0x2f333b)
.circle(0, -52, 6)
.fill(0x4a4f58);
flickers.push({ g: fire, base: 0.85, phase: 2.6 });
c.addChild(body, fire, pot);
return c;
}
/**
* Мир на канвасе: зоны, ноды, аватары и серверная камера-видоискатель.
* Мир на канвасе: зоны, ноды, пропсы, аватары и серверная камера-видоискатель.
* Состояние приходит из протокола; движение и прогресс дорисовываются локально.
*/
export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise<WorldHandle> {
@@ -147,6 +207,8 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
worldLayer.addChild(entityLayer);
app.stage.addChild(worldLayer);
const flickers: Flicker[] = [];
let clockOffset = 0; // serverNow - Date.now()
let camTarget: CameraState | null = null;
const camCur = { x: 0, y: 0, w: 900, h: 560 };
@@ -154,7 +216,7 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
const avatars = new Map<string, AvatarState>();
const avatarViews = new Map<string, AvatarView>();
const nodes = new Map<string, NodeState>();
const nodeViews = new Map<string, TreeView | RockView>();
const nodeViews = new Map<string, TreeView | RockView | FishSpotView>();
const serverNow = (): number => Date.now() + clockOffset;
@@ -173,7 +235,8 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
nodes.set(st.id, st);
let view = nodeViews.get(st.id);
if (!view) {
view = st.kind === 'tree' ? new TreeView(st) : new RockView(st);
view =
st.kind === 'tree' ? new TreeView(st) : st.kind === 'rock' ? new RockView(st) : new FishSpotView(st);
nodeViews.set(st.id, view);
entityLayer.addChild(view.container);
}
@@ -208,6 +271,9 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
app.ticker.add((ticker) => {
const now = serverNow();
for (const f of flickers) {
f.g.alpha = f.base + Math.sin(now / 150 + f.phase) * 0.15;
}
for (const [id, view] of avatarViews) {
const av = avatars.get(id);
if (av) {
@@ -240,7 +306,15 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
ground.zIndex = -1000;
entityLayer.addChild(ground);
for (const prop of w.world.props) {
if (prop.kind === 'anvil') entityLayer.addChild(drawAnvil(prop.x, prop.y));
const view =
prop.kind === 'anvil'
? drawAnvil()
: prop.kind === 'campfire'
? drawCampfire(flickers)
: drawStove(flickers);
view.position.set(prop.x, prop.y);
view.zIndex = prop.y;
entityLayer.addChild(view);
}
}
},