phase 10: schools — Mechanic, Naturalist, Navigator with unlock system and bonuses

Add 3 new schools with real scientific principles (Lever & Moment, Photosynthesis,
Angular Measurement). Data-driven unlock conditions check codex elements, creature
discoveries, and completed runs. Each school provides passive gameplay bonuses
(projectile damage, movement speed, creature aggro range, reaction efficiency)
applied through system multiplier parameters. CradleScene shows all 4 schools with
locked ones grayed out and showing unlock hints. 24 new tests (511 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 18:35:15 +03:00
parent 1b2cc0cd86
commit 0cd995c817
10 changed files with 573 additions and 76 deletions

View File

@@ -17,7 +17,7 @@ import { getAvailableBonuses, purchaseBonus, canAffordBonus, resetShopSession }
import { getGraphStats } from '../mycelium/graph';
import type { BonusEffect } from '../mycelium/types';
const schools = schoolsData as SchoolData[];
const schools = schoolsData as unknown as SchoolData[];
const biomes = biomeDataArray as BiomeData[];
export class CradleScene extends Phaser.Scene {
@@ -155,29 +155,34 @@ export class CradleScene extends Phaser.Scene {
// Biome selection row
this.createBiomeSelector(cx, 105);
// School cards
const cardWidth = 320;
// School cards — show ALL schools, grayed out if locked
const cardWidth = 280;
const cardHeight = 220;
const startY = 150;
const unlockedSchools = schools.filter(s => isSchoolUnlocked(this.meta, s.id));
const cardSpacing = 12;
for (let i = 0; i < unlockedSchools.length; i++) {
const school = unlockedSchools[i];
const cardX = cx - (unlockedSchools.length - 1) * (cardWidth + 20) / 2 + i * (cardWidth + 20);
for (let i = 0; i < schools.length; i++) {
const school = schools[i];
const unlocked = isSchoolUnlocked(this.meta, school.id);
const totalCardWidth = schools.length * (cardWidth + cardSpacing) - cardSpacing;
const cardX = cx - totalCardWidth / 2 + cardWidth / 2 + i * (cardWidth + cardSpacing);
const cardY = startY + cardHeight / 2;
const container = this.add.container(cardX, cardY);
container.setDepth(10);
const schoolColor = parseInt(school.color.replace('#', ''), 16);
// Card background
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight, 0x0a1a0f, 0.9);
bg.setStrokeStyle(2, parseInt(school.color.replace('#', ''), 16));
const bg = this.add.rectangle(0, 0, cardWidth, cardHeight,
unlocked ? 0x0a1a0f : 0x0a0a0a, 0.9);
bg.setStrokeStyle(2, unlocked ? schoolColor : 0x333333);
container.add(bg);
// School name
const nameText = this.add.text(0, -cardHeight / 2 + 20, school.nameRu, {
fontSize: '22px',
color: school.color,
fontSize: '20px',
color: unlocked ? school.color : '#555555',
fontFamily: 'monospace',
fontStyle: 'bold',
});
@@ -185,71 +190,119 @@ export class CradleScene extends Phaser.Scene {
container.add(nameText);
// Principle
const principleText = this.add.text(0, -cardHeight / 2 + 50, school.principleRu, {
fontSize: '12px',
color: '#88aa88',
const principleText = this.add.text(0, -cardHeight / 2 + 48, school.principleRu, {
fontSize: '11px',
color: unlocked ? '#88aa88' : '#444444',
fontFamily: 'monospace',
});
principleText.setOrigin(0.5);
container.add(principleText);
// Starting elements
const elemList = school.startingElements.map(sym => {
const qty = school.startingQuantities[sym] ?? 1;
return `${sym} ×${qty}`;
}).join(' ');
const elemText = this.add.text(0, -cardHeight / 2 + 80, elemList, {
fontSize: '13px',
color: '#aaffaa',
fontFamily: 'monospace',
});
elemText.setOrigin(0.5);
container.add(elemText);
if (unlocked) {
// ─── Unlocked school: full info ───
// Playstyle
const playText = this.add.text(0, -cardHeight / 2 + 120,
this.wrapText(school.playstyleRu, 34), {
// Starting elements
const elemList = school.startingElements.map(sym => {
const qty = school.startingQuantities[sym] ?? 1;
return `${sym} ×${qty}`;
}).join(' ');
const elemText = this.add.text(0, -cardHeight / 2 + 72, elemList, {
fontSize: '12px',
color: '#778877',
color: '#aaffaa',
fontFamily: 'monospace',
lineSpacing: 4,
});
playText.setOrigin(0.5, 0);
container.add(playText);
elemText.setOrigin(0.5);
container.add(elemText);
// Description
const descText = this.add.text(0, cardHeight / 2 - 60,
this.wrapText(school.descriptionRu, 34), {
fontSize: '11px',
color: '#556655',
fontFamily: 'monospace',
lineSpacing: 3,
// Bonus indicator
const bonusKeys = Object.keys(school.bonuses);
if (bonusKeys.length > 0) {
const bonusLabels: Record<string, string> = {
reactionEfficiency: 'Реакции',
projectileDamage: 'Урон',
creatureAggroRange: 'Агрессия',
movementSpeed: 'Скорость',
};
const bonusText = bonusKeys.map(k => {
const label = bonusLabels[k] ?? k;
const val = school.bonuses[k];
const pct = val >= 1 ? `+${Math.round((val - 1) * 100)}%` : `-${Math.round((1 - val) * 100)}%`;
return `${label} ${pct}`;
}).join(' ');
const bText = this.add.text(0, -cardHeight / 2 + 95, bonusText, {
fontSize: '11px',
color: '#ffcc44',
fontFamily: 'monospace',
});
bText.setOrigin(0.5);
container.add(bText);
}
// Playstyle
const playText = this.add.text(0, -cardHeight / 2 + 118,
this.wrapText(school.playstyleRu, 30), {
fontSize: '11px',
color: '#778877',
fontFamily: 'monospace',
lineSpacing: 3,
});
playText.setOrigin(0.5, 0);
container.add(playText);
// Description
const descText = this.add.text(0, cardHeight / 2 - 55,
this.wrapText(school.descriptionRu, 30), {
fontSize: '10px',
color: '#556655',
fontFamily: 'monospace',
lineSpacing: 2,
});
descText.setOrigin(0.5, 0);
container.add(descText);
// Click to select
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => {
bg.setStrokeStyle(3, 0x00ff88);
this.selectedIndex = i;
});
descText.setOrigin(0.5, 0);
container.add(descText);
bg.on('pointerout', () => {
bg.setStrokeStyle(2, schoolColor);
});
bg.on('pointerdown', () => {
this.startRun(school);
});
} else {
// ─── Locked school: grayed out with unlock hint ───
// Lock icon
const lockText = this.add.text(0, -10, '🔒', {
fontSize: '32px',
});
lockText.setOrigin(0.5);
container.add(lockText);
// Unlock hint
const hint = school.unlockCondition?.hintRu ?? 'Заблокировано';
const hintText = this.add.text(0, 30, hint, {
fontSize: '11px',
color: '#666666',
fontFamily: 'monospace',
align: 'center',
});
hintText.setOrigin(0.5);
container.add(hintText);
}
// Fade in
container.setAlpha(0);
this.tweens.add({
targets: container,
alpha: 1,
alpha: unlocked ? 1 : 0.6,
duration: 600,
delay: 200 + i * 150,
});
// Click to select
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => {
bg.setStrokeStyle(3, 0x00ff88);
this.selectedIndex = i;
});
bg.on('pointerout', () => {
bg.setStrokeStyle(2, parseInt(school.color.replace('#', ''), 16));
});
bg.on('pointerdown', () => {
this.startRun(school);
});
this.schoolCards.push(container);
}

View File

@@ -45,8 +45,9 @@ import {
import { query } from 'bitecs';
// Run cycle imports
import type { MetaState, SchoolData, RunState } from '../run/types';
import type { MetaState, SchoolData, RunState, ResolvedSchoolBonuses } from '../run/types';
import { RunPhase, RUN_PHASE_NAMES_RU, PHASE_DURATIONS } from '../run/types';
import { getSchoolBonuses } from '../run/meta';
import { createRunState, advancePhase, updateEscalation, recordDiscovery } from '../run/state';
import {
createCrisisState,
@@ -138,6 +139,9 @@ export class GameScene extends Phaser.Scene {
// Biome selection
private biomeId = 'catalytic-wastes';
// School bonuses (resolved from school data)
private schoolBonuses!: ResolvedSchoolBonuses;
init(data: { meta: MetaState; schoolId: string; runId: number; purchasedEffects?: import('../mycelium/types').BonusEffect[]; biomeId?: string }): void {
this.meta = data.meta;
this.runState = createRunState(data.runId, data.schoolId);
@@ -148,6 +152,7 @@ export class GameScene extends Phaser.Scene {
this.memoryFlashTimer = 0;
this.purchasedEffects = data.purchasedEffects ?? [];
this.biomeId = data.biomeId ?? 'catalytic-wastes';
this.schoolBonuses = getSchoolBonuses(data.schoolId);
}
create(): void {
@@ -354,7 +359,7 @@ export class GameScene extends Phaser.Scene {
/** Give the player their school's starting elements */
private giveStartingKit(): void {
const schools = schoolsData as SchoolData[];
const schools = schoolsData as unknown as SchoolData[];
const school = schools.find(s => s.id === this.runState.schoolId);
if (!school) return;
@@ -389,8 +394,8 @@ export class GameScene extends Phaser.Scene {
interact: this.keys.E.isDown,
};
// 3. Player input → velocity
playerInputSystem(this.gameWorld.world, input);
// 3. Player input → velocity (Navigator school gets speed bonus)
playerInputSystem(this.gameWorld.world, input, this.schoolBonuses.movementSpeed);
// 4. Movement (all entities including projectiles)
movementSystem(this.gameWorld.world, delta);
@@ -454,10 +459,11 @@ export class GameScene extends Phaser.Scene {
this.tryLaunchProjectile();
}
// 9a. Creature AI — adjust aggression based on escalation
// 9a. Creature AI — aggro range affected by Naturalist school bonus
aiSystem(
this.gameWorld.world, delta,
this.speciesLookup, this.gameWorld.time.tick,
this.schoolBonuses.creatureAggroRange,
);
// 9b. Creature metabolism (feeding, energy drain)
@@ -486,9 +492,10 @@ export class GameScene extends Phaser.Scene {
}
}
// 9e. Projectile-creature collision
// 9e. Projectile-creature collision (Mechanic school gets damage bonus)
creatureProjectileSystem(
this.gameWorld.world, this.projectileData, this.speciesLookup,
this.schoolBonuses.projectileDamage,
);
// 9f. Creature attacks on player