Compare commits

..

2 Commits

Author SHA1 Message Date
deadvey 5f294cceb2 documentation 2026-05-18 13:49:35 +01:00
deadvey 051bfe46e4 redesigned how file is tokenised into string, keyword, identifier, bracket and character
added support for GOTOs and removed object recursive calls
2026-05-18 13:22:29 +01:00
12 changed files with 924 additions and 774 deletions
+1
View File
@@ -216,6 +216,7 @@ terms of section 4, provided that you also meet all of these conditions:
b) The work must carry prominent notices stating that it is b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to 7. This requirement modifies the requirement in section 4 to
"keep intact all notices". "keep intact all notices".
+35 -85
View File
@@ -2,6 +2,8 @@
An interactive story telling software. An interactive story telling software.
Read and write people's stories Read and write people's stories
For help with making a story, see [this documentation](/docs/MAKING_A_STORY.md)
For help with syntax, see [this documentation](/docs/SYNTAX.md)
## Install ## Install
@@ -9,11 +11,7 @@ This is not really out of development, but to run it, clone the repo, go into /s
## Technical Details ## Technical Details
### Back-end server -- Rust ### File layout
The server component of Happening will be written in Rust.<br/>
Parses the code and sends it via the API.<br/>
File layout:
- animations/ - animations/
- features/ - features/
@@ -30,13 +28,24 @@ File layout:
- scenes/ - scenes/
- images/ - images/
The variables are stored as a hashmap, characters are objects.<br/> ### Back-end server -- Rust
The server component of Happening will be written in Rust.<br/>
Parses the code and sends it via the API.<br/>
File layout:
The variables are stored as a hashmap, characters are structs in a hashmap.<br/>
### API ### API
Using the network interface, port 20264.<br/> Using the network interface, port 20264.<br/>
Characters are sent to the frontend and stored there when the character is created on the frontend.<br/> Characters are sent to the frontend and stored there when the character is created on the frontend.<br/>
There are 4 endpoints.
#### /happening
GET Returns what is happening on the next cycle:
``` ```
{ {
@@ -47,35 +56,41 @@ content: String,
character: String, character: String,
choice: [String,String,String,...]
} }
``` ```
#### /character/\<character name\>
GET Returns the struct of the character, ideally this should only be gotten when a new character is made or a character changes a feature.
#### /choice
POST the choice as a JSON formatting number, eg 1 to choose choice index 1, returns "ack" as acknowledgment.
#### /input
POST not yet fully implemented on the syntax side but allows client to send a user input to the server as text. returns "ack" as acknowlegment.
### Frontend -- Python ### Frontend -- Python
Things the frontend can do: Things the frontend should be able to:
- Output text - Output text
- Display the character - Display the character
- Move characters around - Move characters around
- Display a choice to the user that gets sent back to the server - Display a choice to the user that gets sent back to the server
### Syntax ### Files
#### Setup For more info on these files see [Making a Story](/docs/MAKING_A_STORY.md)
This is done in the about.json file, #### story.ha
``` This contains the code for the story, syntax for writing the code can be seen in [the syntax section](/docs/SYNTAX.md)
{
"title": "My Great Story", #### about.json
"description": "please read!" This file contains details about your story such as the title and description.
} #### characters.json
```
#### Characters
See [Character documentation](/docs/CHARACTER.md) for more info See [Character documentation](/docs/CHARACTER.md) for more info
Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/> Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/>
@@ -85,72 +100,6 @@ Move a character with @CHARACTER to fr
> [!NOTE] > [!NOTE]
> fr means front-right for instance. > fr means front-right for instance.
#### Outputs
```
@CHARACTER says "this string
is multi-line
and ends with a"
```
#### Variables
Variables are referenced with the \$, only integers will be supported.<br/>
#### Selection
Condition based:
```
if (condition) {
}
elif (condition) {
}
else
}
```
Choice based:
```
choice "choice 1" {
}
or "choice 2" {
}
or "choice 3" {
}
```
#### Positioning
```
@CHARACTER to position
PAN to position
```
#### Other
```
label:
GOTO label
```
#### Ending
`END` to exit out of the story
## Implemented stuff ## Implemented stuff
### Commands ### Commands
@@ -160,7 +109,7 @@ GOTO label
| END | Yes | | END | Yes |
| CHOICE/OR/OR | Yes | | CHOICE/OR/OR | Yes |
| IF/ELSE IF/ELSE | No | | IF/ELSE IF/ELSE | No |
| GOTO | No | | GOTO | Yes |
| PAN | No | | PAN | No |
### Character sub-commands ### Character sub-commands
@@ -178,6 +127,7 @@ GOTO label
| ---------- | ----------- | | ---------- | ----------- |
| Variables | No | | Variables | No |
| about.json | No | | about.json | No |
| INPUT | Partially |
## Error codes ## Error codes
+34
View File
@@ -0,0 +1,34 @@
# Making a Story
## Setting up
You need these three files to make a story:
- about.json
- story.ha
- characters.json
## Filling out the files
Inside about.json should be the following content:
```
{
"title": "My Great Story",
"description": "please read!"
}
```
Change the title and description to whatever you would like.<br/>
Inside story.ha should be the code for your story, see [the documentation on coding](/docs/SYNTAX.md) for help with this.<br/>
Finally, inside characters.json should be a JSON formatted object containing all your characters. See [the documentation on characters](/docs/CHARACTERS.md) for help with this file.<br/>
## Making a story file
A story file is just a zip file of these files, so zip up story.ha, characters.json and about.json into a zip file (make sure these are in the root of the zip file).
## Playing your story
To play your story, simply run cargo run \<your zip file\>
+91
View File
@@ -0,0 +1,91 @@
# Syntax
## Setup
This is done in the about.json file,
```
{
"title": "My Great Story",
"description": "please read!"
}
```
## Characters
See [Character documentation](/docs/CHARACTER.md) for more info
Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/>
Customisation is done with @CHARACTER change \<feature\> into \<feature name\><br/>
Move a character with @CHARACTER to fr
> [!NOTE]
> fr means front-right for instance.
## Outputs
```
@CHARACTER says "this string
is multi-line
and ends with a"
```
## Variables
Variables are referenced with the \$, only integers will be supported.<br/>
## Selection
Condition based:
```
if (condition) {
}
elif (condition) {
}
else
}
```
Choice based:
```
choice "choice 1" {
}
or "choice 2" {
}
or "choice 3" {
}
```
## Positioning
```
@CHARACTER to position
PAN to position
```
## Other
```
label:
GOTO label
```
## Ending
`END` to exit out of the story
+12 -2
View File
@@ -17,7 +17,9 @@ pub struct Character
{ {
name: String, name: String,
gender: String, gender: String,
eye_color: String, eye_color: Colour,
hair_color: Colour,
skin_color: Colour,
pronoun_subject: String, pronoun_subject: String,
pronoun_object: String, pronoun_object: String,
pronoun_deppos: String, pronoun_deppos: String,
@@ -28,7 +30,6 @@ pub struct Character
torso_shape: String, torso_shape: String,
arm_shape: String, arm_shape: String,
leg_shape: String, leg_shape: String,
hair_color: String, // TODO RGB enum
clothing: Clothing, clothing: Clothing,
} }
#[derive(Debug,Deserialize,Serialize,Clone,Default)] #[derive(Debug,Deserialize,Serialize,Clone,Default)]
@@ -39,6 +40,15 @@ pub struct Clothing
bottom: String, bottom: String,
shoes: String, shoes: String,
} }
#[derive(Debug,Deserialize,Serialize,Clone,Default)]
#[serde(default)]
pub struct Colour
{
red: u8,
green: u8,
blue: u8,
}
impl Character { impl Character {
// Big ass ugly match case // Big ass ugly match case
pub fn set_field(&mut self, field: &str, value: &str) -> Result<(), String> { pub fn set_field(&mut self, field: &str, value: &str) -> Result<(), String> {
+5 -5
View File
@@ -51,7 +51,7 @@ async fn main()
error!("No filename specified"); error!("No filename specified");
exit(10); exit(10);
}); });
let file = File::open(format!("../stories/{file_name}")) // Get the file let file = File::open(file_name) // Get the file
.unwrap_or_exit("Failed to read file.", 11); .unwrap_or_exit("Failed to read file.", 11);
let mut archive = ZipArchive::new(file) // Open the archive let mut archive = ZipArchive::new(file) // Open the archive
.unwrap_or_exit("Failed to open archive", 12); .unwrap_or_exit("Failed to open archive", 12);
@@ -93,7 +93,7 @@ async fn main()
// setup the parsing stuff // // setup the parsing stuff //
// Read the file and split it into tokens // Read the file and split it into tokens
// file read from a zip file /story.ha // file read relative to current directory
let mut story_file = archive.by_name("story.ha") let mut story_file = archive.by_name("story.ha")
.unwrap_or_exit("Unable to read story file", 14); .unwrap_or_exit("Unable to read story file", 14);
let mut file_contents = String::new(); let mut file_contents = String::new();
@@ -106,18 +106,18 @@ async fn main()
let space_seperated: Vec<&str> = file_contents let space_seperated: Vec<&str> = file_contents
.split_whitespace() .split_whitespace()
.collect(); .collect();
if ! space_seperated.contains(&"END") if ! space_seperated.contains(&"END") // TODO remove
{ {
warn!("No END statement, story may exit unexpectedly"); warn!("No END statement, story may exit unexpectedly");
} }
let (tokens, labels,_) = tokenise::tokenise(&space_seperated, 0) let (tokens, labels,_) = tokenise::tokenise(&space_seperated, 0)
.unwrap_or_exit("Unable to tokenise data", 15); .unwrap_or_exit("Unable to tokenise data", 15);
debug!("{tokens:?}"); debug!("{tokens:?}\n{labels:?}");
let data_clone2 = Arc::clone(&data_to_send); let data_clone2 = Arc::clone(&data_to_send);
let characters_clone2 = Arc::clone(&characters); let characters_clone2 = Arc::clone(&characters);
// Run the parsing process for the DSL // Run the parsing process for the DSL
info!("DSL parsing begun"); info!("DSL parsing begun");
match parsing::token_parse(&tokens, &characters_clone2, &data_clone2, &rx) match parsing::token_parse(&tokens, &labels, &characters_clone2, &data_clone2, &rx)
{ {
// Exit with error or success // Exit with error or success
Ok(()) => Ok(()) =>
+62 -20
View File
@@ -21,6 +21,7 @@ mod character_parse;
// Returns success or an error string // Returns success or an error string
pub fn token_parse( pub fn token_parse(
tokens: &[tokenise::Token], tokens: &[tokenise::Token],
labels: &HashMap<String,usize>,
characters: &Arc<Mutex<HashMap::<String, character::Character>>>, characters: &Arc<Mutex<HashMap::<String, character::Character>>>,
data_to_send: &Arc<Mutex<api::DataToSend>>, data_to_send: &Arc<Mutex<api::DataToSend>>,
rx: &Receiver<(bool,usize,String)>, rx: &Receiver<(bool,usize,String)>,
@@ -36,16 +37,29 @@ pub fn token_parse(
// Run an infinite loop // Run an infinite loop
'parse_loop: loop 'parse_loop: loop
{ {
debug!("Reading {index}");
// Get the next token // Get the next token
let token = tokenise::get_string_token(tokens, index)?; let token: String = match tokens.get(index)
debug!("{index}: {token}");
// The instructions are related to characters
if token.starts_with('@')
{ {
let character_name: String = token.chars().skip(1).collect(); Some(tokenise::Token::Keyword(s)) => s.to_string(),
debug!("Doing something with a character: {character_name}"); // Ignore closing braces and jump over opening brace blocks
// The index is incremented to after the character's instructions Some(tokenise::Token::Bracket((bracket,new_index))) =>
index = match character_parse::character_parse(index+1, tokens, character_name, characters, data_to_send) {
if bracket == &tokenise::Bracket::Closing
{
index += 1;
}
else
{
warn!("Unexpected brace block, jumping over...");
index = new_index + 1;
}
continue 'parse_loop
},
// Handle a character
Some(tokenise::Token::Character(character_name)) => // TODO add support for narrator
{
index = match character_parse::character_parse(index+1,tokens,character_name.to_string(),characters,data_to_send)
{ {
Ok(increment) => increment, Ok(increment) => increment,
Err((err,increment)) => Err((err,increment)) =>
@@ -54,10 +68,18 @@ pub fn token_parse(
increment increment
}, },
}; };
continue 'parse_loop
} }
// Miscelleneous instructions Some(_) =>
else
{ {
warn!("Unexpected token");
index += 1;
continue 'parse_loop
},
None => return Err("File unexpectedly reached termination point".to_string()),
};
debug!("{index}: {token}");
// The instructions are related to characters
match token.to_lowercase().as_str() match token.to_lowercase().as_str()
{ {
"end" => "end" =>
@@ -67,8 +89,7 @@ pub fn token_parse(
}, },
"choice" => "choice" =>
{ {
debug!("Doing CHOICE"); let mut next_token: String = token;
let mut next_token = token;
let mut choices: Vec<String> = Vec::new(); let mut choices: Vec<String> = Vec::new();
let mut choice_indeces: Vec<usize> = Vec::new(); let mut choice_indeces: Vec<usize> = Vec::new();
while next_token == "or" || next_token == "choice" while next_token == "or" || next_token == "choice"
@@ -78,9 +99,14 @@ pub fn token_parse(
( (
tokenise::get_string_token(tokens, index)? tokenise::get_string_token(tokens, index)?
); );
index += 1;
choice_indeces.push(index+1); choice_indeces.push(index+1);
index += 2; index = match tokenise::get_closing_index(tokens,index)
next_token = match tokenise::get_string_token(tokens, index) {
Ok(new_index) => new_index + 1,
Err(_) => break,
};
next_token = match tokenise::get_keyword_token(tokens, index)
{ {
Ok(string) => string, Ok(string) => string,
Err(_) => break, Err(_) => break,
@@ -99,22 +125,38 @@ pub fn token_parse(
0 0
} }
}; };
let object = tokenise::get_object_token(tokens, choice)?; // TODO make more efficient index = choice;
debug!("{object:?}");
// Don't propogate exit error
let _ = token_parse(object, characters, data_to_send, rx);
continue 'parse_loop continue 'parse_loop
}, },
"or" => "or" =>
{ {
info!("OR command, jumping over"); info!("OR command, jumping over");
index += 2; index += 2;
continue let new_index = tokenise::get_closing_index(tokens, index)?;
index = new_index;
continue 'parse_loop
}, },
// Jump to a particular index based on a label eg GOTO character_check
"goto" =>
{
index += 1;
let label = tokenise::get_keyword_token(tokens, index)?;
index = match labels.get(&label)
{
Some(label_index) => *label_index,
None =>
{
warn!("Label {label} does not exist");
index
}
};
debug!("Jumping to {index}");
continue 'parse_loop
}
_ => _ =>
{ {
warn!("Invalid command: {token}"); warn!("Invalid command: {token}");
} index += 1;
} }
} }
if rx.recv().is_err() if rx.recv().is_err()
+7 -10
View File
@@ -28,12 +28,9 @@ pub fn character_parse
{ {
let mut sum_index: usize = index; let mut sum_index: usize = index;
// Ensure the index is valid (the index is not beyond the vector) // Ensure the index is valid (the index is not beyond the vector)
let token = match tokens.get(sum_index) { let keyword = tokenise::get_keyword_token(tokens, sum_index)
Some(tokenise::Token::String(s)) => s.clone(), .map_err(|err| (err, sum_index))?;
Some(_) => return Err(("Unexpected token".to_string(), sum_index + 1)), match keyword.to_lowercase().as_str()
None => return Err(("File unexpectedly reached termination point".to_string(), sum_index)),
};
match token.to_lowercase().as_str()
{ {
// The character is saying something, so grab the text and pass it // The character is saying something, so grab the text and pass it
// to the client // to the client
@@ -51,7 +48,7 @@ pub fn character_parse
"change" => "change" =>
{ {
sum_index += 1; sum_index += 1;
let feature = tokenise::get_string_token(tokens, sum_index) let feature = tokenise::get_keyword_token(tokens, sum_index)
.map_err(|err| (err, index))?; .map_err(|err| (err, index))?;
sum_index += 1; sum_index += 1;
let string = tokenise::get_string_token(tokens, sum_index) let string = tokenise::get_string_token(tokens, sum_index)
@@ -69,13 +66,13 @@ pub fn character_parse
"to"|"animate" => "to"|"animate" =>
{ {
sum_index += 1; sum_index += 1;
let content = tokenise::get_string_token(tokens, sum_index) let content = tokenise::get_keyword_token(tokens, sum_index)
.map_err(|err| (err, index))?; .map_err(|err| (err, index))?;
api::modify_data(data_to_send, token.to_lowercase(), content, character_name, vec![]); api::modify_data(data_to_send, keyword.to_lowercase(), content, character_name, vec![]);
}, },
// Catch all condition, if the instruction is unrecognised as a // Catch all condition, if the instruction is unrecognised as a
// character command // character command
_ => return Err((format!("Invalid command: {token}"),sum_index)), _ => return Err((format!("Invalid command: {keyword}"),sum_index)),
} }
sum_index += 1; sum_index += 1;
Ok(sum_index) Ok(sum_index)
+57 -21
View File
@@ -1,13 +1,23 @@
use crate:: use crate::
{ {
exit, exit,
HashMap,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Token pub enum Token
{ {
String(String), String(String),
Object(Vec<Self>), Keyword(String), // Keywords aren't checked for validity in this stage
Identifier(String),
Bracket((Bracket,usize)), // Stores the index of the matching deliminator
Character(String),
}
#[derive(Debug,Clone,PartialEq)]
pub enum Bracket
{
Opening,
Closing,
} }
pub fn get_string_token(tokens: &[Token], index: usize) pub fn get_string_token(tokens: &[Token], index: usize)
@@ -19,12 +29,20 @@ pub fn get_string_token(tokens: &[Token], index: usize)
None => Err("File unexpectedly reached termination point".to_string()), None => Err("File unexpectedly reached termination point".to_string()),
} }
} }
pub fn get_keyword_token(tokens: &[Token], index: usize)
pub fn get_object_token(tokens: &[Token], index: usize) -> Result<String, String>
-> Result<&Vec<Token>, String>
{ {
match tokens.get(index) { match tokens.get(index) {
Some(Token::Object(v)) => Ok(v), Some(Token::Keyword(s)) => Ok(s.clone()),
Some(_) => Err("Unexpected token".to_string()),
None => Err("File unexpectedly reached termination point".to_string()),
}
}
pub fn get_closing_index(tokens: &[Token], index: usize)
-> Result<usize, String>
{
match tokens.get(index) {
Some(Token::Bracket((_, index))) => Ok(*index), // TODO check for closing
Some(_) => Err("Unexpected token".to_string()), Some(_) => Err("Unexpected token".to_string()),
None => Err("File unexpectedly reached termination point".to_string()), None => Err("File unexpectedly reached termination point".to_string()),
} }
@@ -34,37 +52,55 @@ pub fn get_object_token(tokens: &[Token], index: usize)
// It can contain sub-objects (using recursive calls) // It can contain sub-objects (using recursive calls)
// TODO check for END // TODO check for END
pub fn tokenise(space_seperated: &Vec<&str>, mut index: usize) pub fn tokenise(space_seperated: &Vec<&str>, mut index: usize)
-> Result<(Vec<Token>,Vec<(String,usize)>,usize),String> -> Result<(Vec<Token>,HashMap<String,usize>,usize),String>
{ {
let mut tokenised_data: Vec<Token> = Vec::new(); let mut tokenised_data: Vec<Token> = Vec::new();
let mut labels: Vec<(String,usize)> = Vec::new(); let mut labels: HashMap<String, usize> = HashMap::new();
let mut bracket_stack: Vec<usize> = Vec::new();
while index < space_seperated.len() while index < space_seperated.len()
{ {
let mut item = space_seperated[index].to_string(); let mut item = space_seperated[index].to_string();
let object: Vec<Token>; // Characters
if item.starts_with('"') // TODO support ' if item.starts_with('@')
{ {
(index, item) = match tokenise_string(space_seperated, index) let mut chars = item.chars();
chars.next();
tokenised_data.push(Token::Character(chars.as_str().to_string()));
}
// Strings
else if item.starts_with('"') // TODO support '
{ {
Some((index,item)) => (index,item), let Some((new_index, new_item)) = tokenise_string(space_seperated, index)
None => exit(1), else { exit(1) };
}; index = new_index;
item = new_item;
tokenised_data.push(Token::String(item)); tokenised_data.push(Token::String(item));
} }
// Labels
else if item.ends_with(':')
{
let mut chars = item.chars();
chars.next_back();
let label = chars.as_str();
labels.insert
(
label.to_string(),
tokenised_data.len(),
);
}
// On a new indentation level, do a recursive call
else if item == "{" else if item == "{"
{ {
(object, labels, index) = match tokenise(space_seperated, index+1) // !WARNING! recursive call bracket_stack.push(tokenised_data.len());
{ tokenised_data.push(Token::Bracket((Bracket::Opening,0))); // TODO fix no closing brace edge case
Ok((object,labels,index)) => (object,labels,index),
Err(err) => return Err(err),
};
tokenised_data.push(Token::Object(object));
} }
else if item == "}" else if item == "}"
{ {
return Ok((tokenised_data,labels,index)); let prev_index = bracket_stack.pop().unwrap(); // TODO eh
tokenised_data[prev_index] = Token::Bracket((Bracket::Opening, tokenised_data.len()));
tokenised_data.push(Token::Bracket((Bracket::Closing,prev_index)));
} }
else { tokenised_data.push(Token::String(item)); } else { tokenised_data.push(Token::Keyword(item)); }
index += 1; index += 1;
} }
Ok((tokenised_data, labels, 0)) Ok((tokenised_data, labels, 0))
+3 -3
View File
@@ -2,9 +2,9 @@
"tim": { "tim": {
"name": "Timothy Sharpshooter", "name": "Timothy Sharpshooter",
"gender": "Male", "gender": "Male",
"skin_color": "", "skin_color": [0,0,0],
"eye_color": "", "eye_color": [0,0,0],
"hair_color": "", "hair_color": [0,0,0],
"pronoun_subject": "He", "pronoun_subject": "He",
"pronoun_object": "Him", "pronoun_object": "Him",
"pronoun_deppos": "His", "pronoun_deppos": "His",
+2 -13
View File
@@ -1,12 +1,7 @@
@tim says "hello world, it's a good day" @tim says "hello world, it's a good day"
@tim change name "Timothy Fineshooter" @tim change name "Timothy Fineshooter"
@tim change boop "Timothy Fineshooter" label:
@tim to fr
choice "choice numero uno" { choice "choice numero uno" {
@tim animate wave
@tim says "super sad"
choice "choice numero uno" {
@tim animate wave
@tim says "super sad" @tim says "super sad"
} }
or "choice numero duo" { or "choice numero duo" {
@@ -14,12 +9,6 @@ choice "choice numero uno" {
} }
or "choice numero tres" { or "choice numero tres" {
@tim says "hola mi amigos" @tim says "hola mi amigos"
} goto label
}
or "choice numero duo" {
@tim says "super unsad"
}
or "choice numero tres" {
@tim says "hola mi amigos"
} }
END END
BIN
View File
Binary file not shown.