fifty_skill_tree 0.2.2
fifty_skill_tree: ^0.2.2 copied to clipboard
Interactive skill tree widget for Flutter games - customizable, animated, and game-ready.
Fifty Skill Tree #
Interactive skill trees for Flutter games. Five layouts, full node control, save/load in two lines.
A complete skill tree system -- layout algorithms, prerequisite chains, point management, unlock animations, and JSON persistence -- with full visual control via nodeBuilder and SkillTreeTheme. Part of Fifty Flutter Kit.
| Home | Basic Tree | Node Unlock | RPG Skill Tree |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Why fifty_skill_tree #
- Five layout algorithms out of the box -- Vertical, horizontal, radial, grid, and custom positioning; switch layouts with a single line.
- Full UI control via nodeBuilder -- Provide a nodeBuilder callback on SkillTreeView to render any widget for any node state; the engine handles unlock logic, prerequisites, and animations.
- Generic data on every node -- Attach any custom type T to SkillNode
- Save/load in two lines -- controller.exportProgress() and controller.importProgress() handle game persistence; pair with any storage solution.
Installation #
dependencies:
fifty_skill_tree: ^0.2.2
For Contributors #
dependencies:
fifty_skill_tree:
path: ../fifty_skill_tree
Dependencies: fifty_tokens
Quick Start #
1. Create a Skill Tree #
import 'package:fifty_skill_tree/fifty_skill_tree.dart';
// Create a skill tree
final tree = SkillTree<void>(
id: 'warrior',
name: 'Warrior Skills',
);
// Add nodes
tree.addNode(SkillNode(
id: 'slash',
name: 'Slash',
description: 'A basic sword attack',
costs: [1],
tier: 0,
));
tree.addNode(SkillNode(
id: 'power_slash',
name: 'Power Slash',
description: 'A powerful overhead strike',
costs: [2],
prerequisites: ['slash'],
tier: 1,
));
tree.addNode(SkillNode(
id: 'whirlwind',
name: 'Whirlwind',
description: 'Spin attack hitting all nearby enemies',
costs: [3],
prerequisites: ['power_slash'],
tier: 2,
));
// Add connections for visualization
tree.addConnection(SkillConnection(fromId: 'slash', toId: 'power_slash'));
tree.addConnection(SkillConnection(fromId: 'power_slash', toId: 'whirlwind'));
// Give the player some points
tree.addPoints(10);
2. Create a Controller #
final controller = SkillTreeController<void>(
tree: tree,
theme: SkillTreeTheme.dark(),
);
// Listen for changes
controller.addListener(() {
print('Points: ${controller.availablePoints}');
});
3. Display the Tree #
SkillTreeView<void>(
controller: controller,
layout: const VerticalTreeLayout(),
onNodeTap: (node) async {
final result = await controller.unlock(node.id);
if (result.success) {
print('Unlocked ${node.name}!');
} else {
print('Cannot unlock: ${result.reason}');
}
},
)
Architecture #
SkillTreeView<T> (Widget)
|
+-- SkillTreeController<T>
| State management, unlock logic, point tracking
|
+-- SkillTree<T>
| +-- SkillNode<T> (nodes with conditions, costs, levels)
| +-- SkillConnection (prerequisite relationships)
|
+-- TreeLayout (Abstract)
| +-- VerticalTreeLayout
| +-- HorizontalTreeLayout
| +-- RadialTreeLayout
| +-- GridLayout
| +-- CustomLayout
|
+-- SkillTreeTheme
Visual styling (dark/light/custom/FDL defaults)
Core Components #
| Component | Description |
|---|---|
SkillTree<T> |
Container for nodes and connections |
SkillNode<T> |
Individual skill with costs, levels, prerequisites |
SkillConnection |
Defines relationships between nodes |
SkillTreeController<T> |
State management, unlock logic, serialization |
SkillTreeView<T> |
Widget that renders the tree with interactions |
SkillTreeTheme |
Visual styling configuration |
Customization #
Custom Node Rendering #
Provide a nodeBuilder to replace the default node widget with any widget you want. The engine handles layout, connections, unlock logic, and animations; your builder controls only the visual per node.
SkillTreeView<void>(
controller: controller,
layout: const VerticalTreeLayout(),
nodeBuilder: (node, state) {
return Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getColorForState(state),
border: Border.all(color: Colors.white, width: 2),
),
child: Center(
child: Icon(
node.icon ?? Icons.star,
color: Colors.white,
),
),
);
},
onNodeTap: (node) => controller.unlock(node.id),
)
The nodeBuilder signature is Widget Function(SkillNode<T> node, SkillState state). The state parameter is one of SkillState.locked, available, unlocked, or maxed, so you can style each state differently. When nodeBuilder is null, the default SkillNodeWidget renders using the current SkillTreeTheme.
Theme Customization #
Built-in themes:
final controller = SkillTreeController(
tree: tree,
theme: SkillTreeTheme.dark(), // or SkillTreeTheme.light()
);
From BuildContext (reads your app's ColorScheme):
// Automatically resolves from Theme.of(context).colorScheme
// Used internally when no explicit theme is set on the controller
final theme = SkillTreeTheme.fromContext(context);
Full custom theme:
final customTheme = SkillTreeTheme(
// Node colors by state
lockedNodeColor: Colors.grey[800]!,
lockedNodeBorderColor: Colors.grey[600]!,
availableNodeColor: Colors.blue[900]!,
availableNodeBorderColor: Colors.blue[400]!,
unlockedNodeColor: Colors.green[900]!,
unlockedNodeBorderColor: Colors.green[400]!,
maxedNodeColor: Colors.amber[900]!,
maxedNodeBorderColor: Colors.amber[400]!,
// Connection colors
connectionLockedColor: Colors.grey[600]!,
connectionUnlockedColor: Colors.green[400]!,
// Sizes
nodeRadius: 28.0,
nodeBorderWidth: 2.0,
connectionWidth: 2.0,
);
// Apply at creation or swap at runtime
controller.setTheme(customTheme);
// Revert to FDL defaults
controller.setTheme(null);
When no theme is provided and no explicit SkillTreeTheme is set, the widget calls SkillTreeTheme.fromContext(context) and resolves colors from your app's ColorScheme.
API Reference #
SkillTree #
final tree = SkillTree<void>(
id: 'warrior',
name: 'Warrior Skills',
);
tree.addNode(node);
tree.addConnection(connection);
tree.addPoints(10);
SkillNode #
Basic Node:
SkillNode(
id: 'fireball',
name: 'Fireball',
description: 'Launches a ball of fire at enemies',
costs: [1], // 1 point to unlock
)
Node Types:
SkillNode(
id: 'skill',
name: 'Skill',
type: SkillType.passive, // Passive skill
// type: SkillType.active, // Active ability
// type: SkillType.ultimate, // Ultimate skill
// type: SkillType.keystone, // Keystone/capstone
)
SkillConnection #
Basic Connection:
tree.addConnection(SkillConnection(
fromId: 'slash',
toId: 'power_slash',
));
Connection Types:
// Required connection (must unlock parent first)
SkillConnection(
fromId: 'a',
toId: 'b',
type: ConnectionType.required,
)
// Optional connection (can skip parent)
SkillConnection(
fromId: 'a',
toId: 'b',
type: ConnectionType.optional,
)
// Exclusive connection (only one can be unlocked)
SkillConnection(
fromId: 'a',
toId: 'b',
type: ConnectionType.exclusive,
)
SkillTreeController #
final controller = SkillTreeController<void>(
tree: tree,
theme: SkillTreeTheme.dark(),
);
controller.addListener(() {
print('Points: ${controller.availablePoints}');
});
SkillTreeView #
SkillTreeView<void>(
controller: controller,
layout: const VerticalTreeLayout(),
onNodeTap: (node) {
controller.unlock(node.id);
},
onNodeLongPress: (node) {
showSkillDetails(context, node);
},
)
Layouts #
Vertical Layout (Default) - Traditional top-to-bottom skill tree layout:
SkillTreeView(
controller: controller,
layout: const VerticalTreeLayout(),
)
Horizontal Layout - Left-to-right skill tree layout:
SkillTreeView(
controller: controller,
layout: const HorizontalTreeLayout(),
)
Radial Layout - Circular layout with skills radiating from center:
SkillTreeView(
controller: controller,
layout: const RadialTreeLayout(
startAngle: -90, // Start from top
sweepAngle: 360, // Full circle
),
)
Grid Layout - Organize skills in a grid pattern:
SkillTreeView(
controller: controller,
layout: const GridLayout(
columns: 4,
rows: 3,
),
)
Custom Layout - Provide your own positioning logic:
SkillTreeView(
controller: controller,
layout: CustomLayout(
positionBuilder: (node, index, total, size) {
// Return custom Offset for each node
return Offset(node.position?.dx ?? 0, node.position?.dy ?? 0);
},
),
)
Enums #
| Enum | Values |
|---|---|
SkillState |
locked, available, unlocked, maxed |
SkillType |
passive, active, ultimate, keystone, minor |
ConnectionType |
required, optional, exclusive |
ConnectionStyle |
solid, dashed, animated |
Serialization #
Save Progress:
// Export only progress (compact, for save games)
final progress = controller.exportProgress();
final jsonString = jsonEncode(progress);
await saveToFile(jsonString);
// Export full tree (includes structure)
final fullExport = controller.exportTree();
Load Progress:
final jsonString = await loadFromFile();
final progress = jsonDecode(jsonString) as Map<String, dynamic>;
controller.importProgress(progress);
Example Progress JSON:
{
"availablePoints": 5,
"nodes": {
"slash": 1,
"power_slash": 2,
"whirlwind": 1
}
}
Usage Patterns #
Node Interactions #
SkillTreeView(
controller: controller,
onNodeTap: (node) {
// Handle tap - typically unlock the skill
controller.unlock(node.id);
},
onNodeLongPress: (node) {
// Handle long press - show details
showSkillDetails(context, node);
},
)
View Controls #
// Enable/disable interactions
SkillTreeView(
controller: controller,
enablePan: true, // Allow panning
enableZoom: true, // Allow zooming
minZoom: 0.5, // Minimum zoom level
maxZoom: 2.0, // Maximum zoom level
initialZoom: 1.0, // Starting zoom
)
Programmatic Control #
// Zoom to specific level
controller.zoomTo(1.5);
// Pan to offset
controller.panTo(Offset(100, 50));
// Focus on a specific node
controller.focusNode(
'fireball',
nodePositions: calculatedPositions,
viewSize: viewSize,
);
// Reset view
controller.resetView();
Point Management #
// Add points (e.g., on level up)
controller.addPoints(5);
// Remove points
controller.removePoints(2);
// Set points directly
controller.setPoints(10);
// Check available points
print('Available: ${controller.availablePoints}');
print('Spent: ${controller.spentPoints}');
Unlocking Skills #
// Attempt to unlock
final result = await controller.unlock('fireball');
if (result.success) {
// Skill was unlocked
print('Unlocked ${result.node?.name} (Level ${result.newLevel})');
print('Points spent: ${result.pointsSpent}');
} else {
// Unlock failed - check reason
switch (result.reason) {
case UnlockFailureReason.nodeNotFound:
print('Node does not exist');
break;
case UnlockFailureReason.alreadyMaxed:
print('Skill is already at max level');
break;
case UnlockFailureReason.prerequisitesNotMet:
print('Prerequisites not met');
break;
case UnlockFailureReason.insufficientPoints:
print('Not enough points');
break;
case UnlockFailureReason.lockedByExclusive:
print('Locked by exclusive choice');
break;
}
}
Reset Functions #
// Reset entire tree (refunds all points)
controller.reset();
// Reset single node (refunds its points)
controller.resetNode('fireball');
Multi-Level Nodes #
SkillNode(
id: 'fireball',
name: 'Fireball',
maxLevel: 5,
costs: [1, 1, 2, 2, 3], // Cost for each level
)
Node with Custom Data #
// Define your data class
class AbilityData {
final int damage;
final double cooldown;
AbilityData({required this.damage, required this.cooldown});
}
// Create typed tree and node
final tree = SkillTree<AbilityData>(id: 'mage', name: 'Mage Skills');
tree.addNode(SkillNode<AbilityData>(
id: 'fireball',
name: 'Fireball',
data: AbilityData(damage: 50, cooldown: 2.0),
));
// Access data later
final node = tree.getNode('fireball');
print('Damage: ${node?.data?.damage}');
Platform Support #
| Platform | Support | Notes |
|---|---|---|
| Android | Yes | |
| iOS | Yes | |
| macOS | Yes | |
| Linux | Yes | |
| Windows | Yes | |
| Web | Yes |
Fifty Design Language Integration #
This package is part of Fifty Flutter Kit:
- FDL defaults - When no theme is provided, widgets use Fifty Design Language defaults automatically
- Theme presets - Built-in dark/light themes, or full custom theming
- Token alignment - Compatible with
fifty_tokens,fifty_theme,fifty_ui
Version #
Current: 0.2.2
License #
MIT License - see LICENSE for details.
Part of Fifty Flutter Kit.




