unity_kit 2.0.0
unity_kit: ^2.0.0 copied to clipboard
Flutter plugin for Unity 3D integration — typed bridge, binary protocol, performance + AR, on Android, iOS, web & desktop.
import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:unity_kit/unity_kit.dart';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const String _kDefaultScene = 'MainScene';
// Parameter names sent to Unity
const String _kParamRotationX = 'rotationX';
const String _kParamRotationY = 'rotationY';
const String _kParamScale = 'scale';
const String _kParamSpeed = 'speed';
const String _kParamIntensity = 'intensity';
const String _kParamPositionX = 'positionX';
const String _kParamPositionY = 'positionY';
// Gesture sensitivity multipliers
const double _kRotationSensitivity = 0.5;
const double _kPositionSensitivity = 0.01;
const double _kScaleMin = 0.1;
const double _kScaleMax = 5.0;
// ---------------------------------------------------------------------------
// App entry point
// ---------------------------------------------------------------------------
void main() {
runApp(const UnityKitExampleApp());
}
class UnityKitExampleApp extends StatelessWidget {
const UnityKitExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Unity Kit Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.deepPurple,
useMaterial3: true,
brightness: Brightness.dark,
),
home: const MainScreen(),
);
}
}
// ---------------------------------------------------------------------------
// Main screen -- Unity view + sliders
// ---------------------------------------------------------------------------
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
bool _unityReady = false;
UnityBridge? _bridge;
// Feature toggles (2.0.0)
UnityArMode _arMode = UnityArMode.none;
bool _useBinary = false;
UnityPerformanceStats? _perf;
StreamSubscription<UnityPerformanceStats>? _perfSub;
// Gesture-controlled values
double _rotationX = 0;
double _rotationY = 0;
double _positionX = 0;
double _positionY = 0;
double _scale = 1.0;
double _baseScale = 1.0;
// Slider-controlled values
double _speed = 1.0;
double _intensity = 0.5;
@override
void dispose() {
_perfSub?.cancel();
super.dispose();
}
/// Sends [message] over the binary protocol or JSON depending on the toggle.
void _send(UnityMessage message) {
final bridge = _bridge;
if (bridge == null) return;
if (_useBinary && _unityReady) {
bridge.sendBinary(message);
} else {
bridge.sendWhenReady(message);
}
}
void _onArModeSelected(UnityArMode mode) {
if (mode == _arMode) return;
setState(() {
_arMode = mode;
// Recreating the platform view resets readiness.
_unityReady = false;
_bridge = null;
_perf = null;
});
}
// ---------------------------------------------------------------------------
// Gesture handlers
// ---------------------------------------------------------------------------
void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
if (!_unityReady) return;
setState(() {
if (details.pointerCount == 1) {
// Single finger drag = rotation
_rotationY += details.focalPointDelta.dx * _kRotationSensitivity;
_rotationX -= details.focalPointDelta.dy * _kRotationSensitivity;
} else if (details.pointerCount >= 2) {
// Two fingers: pinch = scale, pan = position
_scale = (_baseScale * details.scale).clamp(_kScaleMin, _kScaleMax);
_positionX += details.focalPointDelta.dx * _kPositionSensitivity;
_positionY -= details.focalPointDelta.dy * _kPositionSensitivity;
}
});
_sendTransform();
}
void _sendTransform() {
if (_bridge == null) return;
final params = {
_kParamRotationX: _rotationX,
_kParamRotationY: _rotationY,
_kParamScale: _scale,
_kParamPositionX: _positionX,
_kParamPositionY: _positionY,
};
for (final entry in params.entries) {
_send(
UnityMessage.command('SetParameter', {
'param': entry.key,
'value': entry.value,
}),
);
}
}
// ---------------------------------------------------------------------------
// Slider change handlers
// ---------------------------------------------------------------------------
void _onSliderChanged(String param, double value) {
_send(
UnityMessage.command('SetParameter', {
'param': param,
'value': value,
}),
);
}
void _onSpeedChanged(double value) {
setState(() => _speed = value);
_onSliderChanged(_kParamSpeed, value);
}
void _onIntensityChanged(double value) {
setState(() => _intensity = value);
_onSliderChanged(_kParamIntensity, value);
}
void _resetAll() {
setState(() {
_rotationX = 0;
_rotationY = 0;
_positionX = 0;
_positionY = 0;
_scale = 1.0;
_baseScale = 1.0;
_speed = 1.0;
_intensity = 0.5;
});
_send(UnityMessage.command('ResetParameters', {}));
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: const Text('Unity Kit'),
backgroundColor: Colors.transparent,
actions: [
_StatusChip(isReady: _unityReady),
const SizedBox(width: 4),
IconButton(
onPressed: () => setState(() => _useBinary = !_useBinary),
icon: Icon(_useBinary ? Icons.memory : Icons.data_object),
color: _useBinary ? Colors.tealAccent : null,
tooltip: _useBinary ? 'Binary protocol (on)' : 'JSON protocol',
),
PopupMenuButton<UnityArMode>(
icon: Icon(
_arMode == UnityArMode.none
? Icons.view_in_ar_outlined
: Icons.view_in_ar,
color: _arMode == UnityArMode.none ? null : Colors.tealAccent,
),
tooltip: 'AR mode',
onSelected: _onArModeSelected,
itemBuilder: (context) => [
for (final mode in UnityArMode.values)
CheckedPopupMenuItem(
value: mode,
checked: mode == _arMode,
child: Text('AR: ${mode.name}'),
),
],
),
IconButton(
onPressed: _resetAll,
icon: const Icon(Icons.refresh),
tooltip: 'Reset all',
),
const SizedBox(width: 4),
],
),
body: Column(
children: [
// Unity view with gesture detection
Expanded(child: _buildUnityArea()),
// Gesture info + remaining sliders
_ControlsPanel(
rotationX: _rotationX,
rotationY: _rotationY,
positionX: _positionX,
positionY: _positionY,
scale: _scale,
speed: _speed,
intensity: _intensity,
onSpeedChanged: _onSpeedChanged,
onIntensityChanged: _onIntensityChanged,
enabled: _unityReady,
),
],
),
);
}
Widget _buildUnityArea() {
if (kIsWeb ||
!(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS)) {
return const UnityPlaceholder(
message: 'Unity view is not available on this platform.',
backgroundColor: Colors.black,
indicatorColor: Colors.deepPurple,
);
}
final config = _arMode == UnityArMode.none
? const UnityConfig(
sceneName: _kDefaultScene,
fullscreen: false,
platformViewMode: PlatformViewMode.hybridComposition,
targetFrameRate: 60,
unloadOnDispose: true,
)
: UnityConfig.ar(sceneName: _kDefaultScene, mode: _arMode);
return Stack(
children: [
GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: UnityView(
// Recreate the platform view when the AR mode changes.
key: ValueKey(_arMode),
config: config,
gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>{
Factory<ScaleGestureRecognizer>(ScaleGestureRecognizer.new),
},
placeholder: const UnityPlaceholder(
message: 'Loading 3D view...',
backgroundColor: Colors.black,
indicatorColor: Colors.deepPurple,
),
onReady: (bridge) {
developer.log('UnityView ready', name: 'example');
_perfSub?.cancel();
_perfSub = bridge.performanceStream.listen((stats) {
if (mounted) setState(() => _perf = stats);
});
setState(() {
_unityReady = true;
_bridge = bridge;
});
},
onMessage: (message) {
developer.log(
'Unity message: ${message.type} ${message.data ?? ''}',
name: 'example',
);
},
),
),
if (_perf != null)
Positioned(
top: 8,
left: 8,
child: _PerformanceOverlay(stats: _perf!),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Performance overlay -- live UnityPerformanceStats
// ---------------------------------------------------------------------------
class _PerformanceOverlay extends StatelessWidget {
const _PerformanceOverlay({required this.stats});
final UnityPerformanceStats stats;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${stats.fps.toStringAsFixed(0)} fps • '
'${stats.frameTimeMs.toStringAsFixed(1)} ms • '
'${stats.usedMemoryMb.toStringAsFixed(0)} MB',
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: Colors.tealAccent,
),
),
);
}
}
// ---------------------------------------------------------------------------
// Status chip
// ---------------------------------------------------------------------------
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.isReady});
final bool isReady;
@override
Widget build(BuildContext context) {
final color = isReady ? Colors.green : Colors.orange;
final label = isReady ? 'ready' : 'loading';
return Chip(
avatar: CircleAvatar(backgroundColor: color, radius: 6),
label: Text(label, style: const TextStyle(fontSize: 12)),
visualDensity: VisualDensity.compact,
);
}
}
// ---------------------------------------------------------------------------
// Controls panel -- gesture readouts + remaining sliders
// ---------------------------------------------------------------------------
class _ControlsPanel extends StatelessWidget {
const _ControlsPanel({
required this.rotationX,
required this.rotationY,
required this.positionX,
required this.positionY,
required this.scale,
required this.speed,
required this.intensity,
required this.onSpeedChanged,
required this.onIntensityChanged,
required this.enabled,
});
final double rotationX;
final double rotationY;
final double positionX;
final double positionY;
final double scale;
final double speed;
final double intensity;
final ValueChanged<double> onSpeedChanged;
final ValueChanged<double> onIntensityChanged;
final bool enabled;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.grey[700],
borderRadius: BorderRadius.circular(2),
),
),
// Gesture readouts
_GestureInfo(
rotationX: rotationX,
rotationY: rotationY,
positionX: positionX,
positionY: positionY,
scale: scale,
),
const SizedBox(height: 8),
// Remaining sliders
_SliderRow(
label: 'Speed',
value: speed,
min: 0,
max: 5.0,
onChanged: enabled ? onSpeedChanged : null,
valueLabel: '${speed.toStringAsFixed(1)}x',
),
_SliderRow(
label: 'Intensity',
value: intensity,
min: 0,
max: 1.0,
onChanged: enabled ? onIntensityChanged : null,
valueLabel: '${(intensity * 100).toStringAsFixed(0)}%',
),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Gesture info readout
// ---------------------------------------------------------------------------
class _GestureInfo extends StatelessWidget {
const _GestureInfo({
required this.rotationX,
required this.rotationY,
required this.positionX,
required this.positionY,
required this.scale,
});
final double rotationX;
final double rotationY;
final double positionX;
final double positionY;
final double scale;
@override
Widget build(BuildContext context) {
const labelStyle = TextStyle(
fontSize: 11,
color: Colors.white38,
);
const valueStyle = TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: Colors.white60,
);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_InfoItem(
label: 'Rot',
value:
'${rotationX.toStringAsFixed(0)}, ${rotationY.toStringAsFixed(0)}',
labelStyle: labelStyle,
valueStyle: valueStyle,
),
_InfoItem(
label: 'Pos',
value:
'${positionX.toStringAsFixed(2)}, ${positionY.toStringAsFixed(2)}',
labelStyle: labelStyle,
valueStyle: valueStyle,
),
_InfoItem(
label: 'Scale',
value: '${scale.toStringAsFixed(2)}x',
labelStyle: labelStyle,
valueStyle: valueStyle,
),
],
);
}
}
class _InfoItem extends StatelessWidget {
const _InfoItem({
required this.label,
required this.value,
required this.labelStyle,
required this.valueStyle,
});
final String label;
final String value;
final TextStyle labelStyle;
final TextStyle valueStyle;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: labelStyle),
const SizedBox(height: 2),
Text(value, style: valueStyle),
],
);
}
}
// ---------------------------------------------------------------------------
// Single slider row
// ---------------------------------------------------------------------------
class _SliderRow extends StatelessWidget {
const _SliderRow({
required this.label,
required this.value,
required this.min,
required this.max,
required this.onChanged,
required this.valueLabel,
});
final String label;
final double value;
final double min;
final double max;
final ValueChanged<double>? onChanged;
final String valueLabel;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 80,
child: Text(
label,
style: TextStyle(
fontSize: 12,
color: onChanged != null ? Colors.white70 : Colors.white30,
),
),
),
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
activeTrackColor: Colors.deepPurple[300],
inactiveTrackColor: Colors.grey[800],
thumbColor: Colors.deepPurple[200],
disabledActiveTrackColor: Colors.grey[700],
disabledInactiveTrackColor: Colors.grey[850],
disabledThumbColor: Colors.grey[600],
),
child: Slider(
value: value,
min: min,
max: max,
onChanged: onChanged,
),
),
),
SizedBox(
width: 48,
child: Text(
valueLabel,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: onChanged != null ? Colors.white70 : Colors.white30,
),
),
),
],
),
);
}
}