twine_parser 0.1.0 copy "twine_parser: ^0.1.0" to clipboard
twine_parser: ^0.1.0 copied to clipboard

A Dart library for parsing and evaluating Twine stories, with support for the Harlowe story format.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:twine_parser/twine_parser.dart';
import 'package:url_launcher/url_launcher.dart';

void main() {
  runApp(const TwineParserExampleApp());
}

class TwineParserExampleApp extends StatelessWidget {
  const TwineParserExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Twine Parser Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const StorySelectionScreen(),
    );
  }
}

/// Example Twine stories bundled from rheteric.org
/// Original author: Eric Detweiler (https://rheteric.org/twine)
class ExampleStory {
  final String title;
  final String description;
  final String assetPath;
  final List<String> features;

  const ExampleStory({
    required this.title,
    required this.description,
    required this.assetPath,
    required this.features,
  });
}

const exampleStories = [
  ExampleStory(
    title: 'Sandwich Distribution Simulator',
    description:
        'Collect and give away sandwiches using numerical variables and if/else macros.',
    assetPath: 'assets/stories/sandwich.html',
    features: ['(set:)', '(if:)', '(else:)'],
  ),
  ExampleStory(
    title: 'Traveler',
    description:
        'Go in four cardinal directions while the game tracks where you\'ve been.',
    assetPath: 'assets/stories/traveler.html',
    features: ['(set:)', '(visited:)', 'passage tags'],
  ),
  ExampleStory(
    title: 'Bus Stop',
    description:
        'Experience a haunting encounter at a bus stop with random text generation.',
    assetPath: 'assets/stories/busstop.html',
    features: ['(a:)', '(print:)', '(random:)', '(set:)'],
  ),
  ExampleStory(
    title: 'Epic Journey',
    description:
        'A fantasy quest using true/false and numerical variables for combat and inventory.',
    assetPath: 'assets/stories/journey.html',
    features: ['(if:)', '(else:)', '(set:)'],
  ),
];

class StorySelectionScreen extends StatefulWidget {
  const StorySelectionScreen({super.key});

  @override
  State<StorySelectionScreen> createState() => _StorySelectionScreenState();
}

class _StorySelectionScreenState extends State<StorySelectionScreen> {
  final _urlController = TextEditingController();
  bool _isLoading = false;
  String? _error;

  @override
  void dispose() {
    _urlController.dispose();
    super.dispose();
  }

  Future<void> _loadStoryFromAsset(String assetPath) async {
    setState(() {
      _isLoading = true;
      _error = null;
    });

    try {
      final storyHtml = await rootBundle.loadString(assetPath);

      final parser = TwineParser();
      await parser.parseStory(storyHtml);

      if (parser.passages.isEmpty) {
        throw Exception(
            'No passages found in the story. Make sure the URL points to a Twine HTML file.');
      }

      if (mounted) {
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => StoryPlayerScreen(
              parser: parser,
              storyTitle: assetPath.split('/').last.replaceAll('.html', ''),
            ),
          ),
        );
      }
    } catch (e) {
      setState(() {
        _error = e.toString();
      });
    } finally {
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  Future<void> _loadStoryFromUrl(String url) async {
    setState(() {
      _isLoading = true;
      _error = null;
    });

    try {
      final response = await http.get(Uri.parse(url));
      if (response.statusCode != 200) {
        throw Exception('Failed to load story: HTTP ${response.statusCode}');
      }

      final parser = TwineParser();
      await parser.parseStory(response.body);

      if (parser.passages.isEmpty) {
        throw Exception(
            'No passages found in the story. Make sure the URL points to a Twine HTML file.');
      }

      if (mounted) {
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => StoryPlayerScreen(
              parser: parser,
              storyTitle:
                  Uri.parse(url).pathSegments.lastOrNull ?? 'Custom Story',
            ),
          ),
        );
      }
    } catch (e) {
      setState(() {
        _error = e.toString();
      });
    } finally {
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  Future<void> _openAuthorPage() async {
    final uri = Uri.parse('https://rheteric.org/twine');
    if (await canLaunchUrl(uri)) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    }
  }

  void _showCustomUrlDialog() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Enter Story URL'),
        content: TextField(
          controller: _urlController,
          decoration: const InputDecoration(
            hintText: 'https://example.com/story.html',
            labelText: 'Twine Story URL',
          ),
          keyboardType: TextInputType.url,
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              Navigator.pop(context);
              if (_urlController.text.isNotEmpty) {
                _loadStoryFromUrl(_urlController.text);
              }
            },
            child: const Text('Load'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Twine Parser Example'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: _isLoading
          ? const Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  CircularProgressIndicator(),
                  SizedBox(height: 16),
                  Text('Loading story...'),
                ],
              ),
            )
          : ListView(
              padding: const EdgeInsets.all(16),
              children: [
                if (_error != null)
                  Card(
                    color: Theme.of(context).colorScheme.errorContainer,
                    child: Padding(
                      padding: const EdgeInsets.all(16),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            'Error',
                            style: TextStyle(
                              fontWeight: FontWeight.bold,
                              color: Theme.of(context)
                                  .colorScheme
                                  .onErrorContainer,
                            ),
                          ),
                          const SizedBox(height: 8),
                          Text(
                            _error!,
                            style: TextStyle(
                              color: Theme.of(context)
                                  .colorScheme
                                  .onErrorContainer,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
                Card(
                  child: ListTile(
                    leading: const Icon(Icons.link),
                    title: const Text('Load Custom URL'),
                    subtitle: const Text('Enter the URL of any Twine story'),
                    trailing: const Icon(Icons.arrow_forward_ios),
                    onTap: _showCustomUrlDialog,
                  ),
                ),
                const SizedBox(height: 24),
                Text(
                  'Example Stories',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                const SizedBox(height: 8),
                InkWell(
                  onTap: _openAuthorPage,
                  child: Text(
                    'Tiny Twine Examples by Eric Detweiler (rheteric.org/twine)',
                    style: Theme.of(context).textTheme.bodySmall?.copyWith(
                          color: Theme.of(context).colorScheme.primary,
                          decoration: TextDecoration.underline,
                        ),
                  ),
                ),
                const SizedBox(height: 16),
                ...exampleStories.map((story) => Card(
                      margin: const EdgeInsets.only(bottom: 12),
                      child: InkWell(
                        onTap: () => _loadStoryFromAsset(story.assetPath),
                        borderRadius: BorderRadius.circular(12),
                        child: Padding(
                          padding: const EdgeInsets.all(16),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Text(
                                story.title,
                                style: Theme.of(context).textTheme.titleMedium,
                              ),
                              const SizedBox(height: 8),
                              Text(
                                story.description,
                                style: Theme.of(context).textTheme.bodyMedium,
                              ),
                              const SizedBox(height: 12),
                              Wrap(
                                spacing: 8,
                                runSpacing: 4,
                                children: story.features
                                    .map((f) => Chip(
                                          label: Text(f),
                                          visualDensity: VisualDensity.compact,
                                        ))
                                    .toList(),
                              ),
                            ],
                          ),
                        ),
                      ),
                    )),
              ],
            ),
    );
  }
}

class StoryPlayerScreen extends StatefulWidget {
  final TwineParser parser;
  final String storyTitle;

  const StoryPlayerScreen({
    super.key,
    required this.parser,
    required this.storyTitle,
  });

  @override
  State<StoryPlayerScreen> createState() => _StoryPlayerScreenState();
}

class _StoryPlayerScreenState extends State<StoryPlayerScreen> {
  late Passage _currentPassage;
  final Map<String, dynamic> _gameState = {};
  final List<String> _history = [];

  @override
  void initState() {
    super.initState();
    _initializeStory();
  }

  void _initializeStory() {
    // Get the start passage name first
    final startPassageName = widget.parser.getStartPassage().name;
    // Then parse it with game state to capture state changes
    // Pass empty history since this is the first passage
    final startPassage = widget.parser.getPassage(startPassageName,
        gameState: _gameState, visitedPassages: _history);
    if (startPassage != null) {
      // Apply initial state changes (e.g., variable initialization)
      if (startPassage.stateChanges != null) {
        _gameState.addAll(startPassage.stateChanges!);
      }
      _currentPassage = startPassage;
      _history.add(_currentPassage.name);
    } else {
      _currentPassage = widget.parser.getStartPassage();
      _history.add(_currentPassage.name);
    }
  }

  void _navigateToPassage(String passageName) {
    // Always re-parse the passage to get fresh random values
    // Pass the history to support (visited:) macro evaluation
    final passage = widget.parser.getPassage(passageName,
        gameState: _gameState, visitedPassages: _history);
    if (passage != null) {
      // Apply state changes
      if (passage.stateChanges != null) {
        _gameState.addAll(passage.stateChanges!);
      }
      setState(() {
        // Force a rebuild even if navigating to the same passage name
        _currentPassage = passage;
        _history.add(passageName);
      });
    }
  }

  void _restart() {
    _gameState.clear();
    _history.clear();
    _initializeStory();
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_currentPassage.name),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          IconButton(
            icon: const Icon(Icons.restart_alt),
            tooltip: 'Restart',
            onPressed: _restart,
          ),
          IconButton(
            icon: const Icon(Icons.info_outline),
            tooltip: 'Story Info',
            onPressed: () => _showStoryInfo(context),
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Story content - use key to force rebuild on navigation
            Card(
              key: ValueKey('passage_${_history.length}'),
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: SelectableText(
                  _currentPassage.content,
                  style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                        height: 1.6,
                      ),
                ),
              ),
            ),
            const SizedBox(height: 24),
            // Choices
            if (_currentPassage.choices.isNotEmpty) ...[
              Text(
                'What do you do?',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 12),
              ..._currentPassage.choices.map((choice) => Padding(
                    padding: const EdgeInsets.only(bottom: 8),
                    child: FilledButton.tonal(
                      onPressed: () => _navigateToPassage(choice.targetPassage),
                      child: Padding(
                        padding: const EdgeInsets.symmetric(vertical: 12),
                        child: Text(choice.text),
                      ),
                    ),
                  )),
            ] else ...[
              const Center(
                child: Text(
                  '— The End —',
                  style: TextStyle(
                    fontStyle: FontStyle.italic,
                    fontSize: 18,
                  ),
                ),
              ),
              const SizedBox(height: 16),
              Center(
                child: FilledButton(
                  onPressed: _restart,
                  child: const Text('Play Again'),
                ),
              ),
            ],
            // Debug: Show game state
            if (_gameState.isNotEmpty) ...[
              const SizedBox(height: 32),
              ExpansionTile(
                title: const Text('Game State (Debug)'),
                children: [
                  Padding(
                    padding: const EdgeInsets.all(16),
                    child: Text(
                      _gameState.entries
                          .map((e) => '${e.key}: ${e.value}')
                          .join('\n'),
                      style: const TextStyle(fontFamily: 'monospace'),
                    ),
                  ),
                ],
              ),
            ],
          ],
        ),
      ),
    );
  }

  void _showStoryInfo(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Story Info'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Story: ${widget.storyTitle}'),
            const SizedBox(height: 8),
            const Text('Author: Eric Detweiler (rheteric.org/twine)'),
            const SizedBox(height: 8),
            Text('Passages: ${widget.parser.passages.length}'),
            const SizedBox(height: 8),
            Text('Visited: ${_history.length}'),
            const SizedBox(height: 16),
            const Text('Passage List:'),
            const SizedBox(height: 8),
            ...widget.parser.passages.keys.map((name) => Text('• $name')),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }
}
0
likes
160
points
142
downloads

Publisher

unverified uploader

Weekly Downloads

A Dart library for parsing and evaluating Twine stories, with support for the Harlowe story format.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

html

More

Packages that depend on twine_parser