Merge pull request #3929 from ScottNZ/translation

Add translation support
This commit is contained in:
Paul Chote
2013-10-13 23:57:12 -07:00
29 changed files with 360 additions and 171 deletions

View File

@@ -1,6 +1,6 @@
#region Copyright & License Information #region Copyright & License Information
/* /*
* Copyright 2007-2011 The OpenRA Developers (see AUTHORS) * Copyright 2007-2013 The OpenRA Developers (see AUTHORS)
* This file is part of OpenRA, which is free software. It is made * This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License * available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation. For more information, * as published by the Free Software Foundation. For more information,
@@ -14,6 +14,7 @@ using System.Drawing;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions;
namespace OpenRA.FileFormats namespace OpenRA.FileFormats
{ {
@@ -38,31 +39,31 @@ namespace OpenRA.FileFormats
object val; object val;
if (kv.Value != null) if (kv.Value != null)
val = kv.Value(kv.Key.Name, kv.Key.FieldType, my); val = kv.Value(kv.Key.Name, kv.Key.FieldType, my);
else if (!TryGetValueFromYaml(kv.Key.Name, kv.Key.FieldType, my, out val)) else if (!TryGetValueFromYaml(kv.Key, my, out val))
continue; continue;
kv.Key.SetValue(self, val); kv.Key.SetValue(self, val);
} }
} }
static bool TryGetValueFromYaml(string fieldName, Type fieldType, MiniYaml yaml, out object ret) static bool TryGetValueFromYaml(FieldInfo field, MiniYaml yaml, out object ret)
{ {
ret = null; ret = null;
var n = yaml.Nodes.Where(x => x.Key == fieldName).ToList(); var n = yaml.Nodes.Where(x => x.Key == field.Name).ToList();
if (n.Count == 0) if (n.Count == 0)
return false; return false;
if (n.Count == 1 && n[0].Value.Nodes.Count == 0) if (n.Count == 1 && n[0].Value.Nodes.Count == 0)
{ {
ret = GetValue(fieldName, fieldType, n[0].Value.Value); ret = GetValue(field.Name, field.FieldType, n[0].Value.Value, field);
return true; return true;
} }
else if (n.Count > 1) else if (n.Count > 1)
{ {
throw new InvalidOperationException("The field {0} has multiple definitions:\n{1}" throw new InvalidOperationException("The field {0} has multiple definitions:\n{1}"
.F(fieldName, n.Select(m => "\t- " + m.Location).JoinWith("\n"))); .F(field.Name, n.Select(m => "\t- " + m.Location).JoinWith("\n")));
} }
throw new InvalidOperationException("TryGetValueFromYaml: unable to load field {0} (of type {1})".F(fieldName, fieldType)); throw new InvalidOperationException("TryGetValueFromYaml: unable to load field {0} (of type {1})".F(field.Name, field.FieldType));
} }
public static T Load<T>(MiniYaml y) where T : new() public static T Load<T>(MiniYaml y) where T : new()
@@ -80,7 +81,7 @@ namespace OpenRA.FileFormats
if (field != null) if (field != null)
{ {
if (!field.HasAttribute<FieldFromYamlKeyAttribute>()) if (!field.HasAttribute<FieldFromYamlKeyAttribute>())
field.SetValue(self, GetValue(field.Name, field.FieldType, value)); field.SetValue(self, GetValue(field.Name, field.FieldType, value, field));
return; return;
} }
@@ -89,7 +90,7 @@ namespace OpenRA.FileFormats
if (prop != null) if (prop != null)
{ {
if (!prop.HasAttribute<FieldFromYamlKeyAttribute>()) if (!prop.HasAttribute<FieldFromYamlKeyAttribute>())
prop.SetValue(self, GetValue(prop.Name, prop.PropertyType, value), NoIndexes); prop.SetValue(self, GetValue(prop.Name, prop.PropertyType, value, prop), NoIndexes);
return; return;
} }
@@ -98,61 +99,70 @@ namespace OpenRA.FileFormats
public static T GetValue<T>(string field, string value) public static T GetValue<T>(string field, string value)
{ {
return (T)GetValue(field, typeof(T), value); return (T)GetValue(field, typeof(T), value, null);
} }
public static object GetValue(string field, Type fieldType, string x) public static object GetValue(string fieldName, Type fieldType, string value)
{ {
if (x != null) x = x.Trim(); return GetValue(fieldName, fieldType, value, null);
}
public static object GetValue(string fieldName, Type fieldType, string value, MemberInfo field)
{
if (value != null) value = value.Trim();
if (fieldType == typeof(int)) if (fieldType == typeof(int))
{ {
int res; int res;
if (int.TryParse(x, out res)) if (int.TryParse(value, out res))
return res; return res;
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(ushort)) else if (fieldType == typeof(ushort))
{ {
ushort res; ushort res;
if (ushort.TryParse(x, out res)) if (ushort.TryParse(value, out res))
return res; return res;
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(float)) else if (fieldType == typeof(float))
{ {
float res; float res;
if (float.TryParse(x.Replace("%", ""), NumberStyles.Any, NumberFormatInfo.InvariantInfo, out res)) if (float.TryParse(value.Replace("%", ""), NumberStyles.Any, NumberFormatInfo.InvariantInfo, out res))
return res * (x.Contains('%') ? 0.01f : 1f); return res * (value.Contains('%') ? 0.01f : 1f);
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(decimal)) else if (fieldType == typeof(decimal))
{ {
decimal res; decimal res;
if (decimal.TryParse(x.Replace("%", ""), NumberStyles.Any, NumberFormatInfo.InvariantInfo, out res)) if (decimal.TryParse(value.Replace("%", ""), NumberStyles.Any, NumberFormatInfo.InvariantInfo, out res))
return res * (x.Contains('%') ? 0.01m : 1m); return res * (value.Contains('%') ? 0.01m : 1m);
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(string)) else if (fieldType == typeof(string))
return x; {
if (field != null && field.HasAttribute<TranslateAttribute>())
return Regex.Replace(value, "@[^@]+@", m => Translate(m.Value.Substring(1, m.Value.Length - 2)), RegexOptions.Compiled);
return value;
}
else if (fieldType == typeof(Color)) else if (fieldType == typeof(Color))
{ {
var parts = x.Split(','); var parts = value.Split(',');
if (parts.Length == 3) if (parts.Length == 3)
return Color.FromArgb(int.Parse(parts[0]).Clamp(0, 255), int.Parse(parts[1]).Clamp(0, 255), int.Parse(parts[2]).Clamp(0, 255)); return Color.FromArgb(int.Parse(parts[0]).Clamp(0, 255), int.Parse(parts[1]).Clamp(0, 255), int.Parse(parts[2]).Clamp(0, 255));
if (parts.Length == 4) if (parts.Length == 4)
return Color.FromArgb(int.Parse(parts[0]).Clamp(0, 255), int.Parse(parts[1]).Clamp(0, 255), int.Parse(parts[2]).Clamp(0, 255), int.Parse(parts[3]).Clamp(0, 255)); return Color.FromArgb(int.Parse(parts[0]).Clamp(0, 255), int.Parse(parts[1]).Clamp(0, 255), int.Parse(parts[2]).Clamp(0, 255), int.Parse(parts[3]).Clamp(0, 255));
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(HSLColor)) else if (fieldType == typeof(HSLColor))
{ {
var parts = x.Split(','); var parts = value.Split(',');
// Allow old ColorRamp format to be parsed as HSLColor // Allow old ColorRamp format to be parsed as HSLColor
if (parts.Length == 3 || parts.Length == 4) if (parts.Length == 3 || parts.Length == 4)
@@ -161,21 +171,21 @@ namespace OpenRA.FileFormats
(byte)int.Parse(parts[1]).Clamp(0, 255), (byte)int.Parse(parts[1]).Clamp(0, 255),
(byte)int.Parse(parts[2]).Clamp(0, 255)); (byte)int.Parse(parts[2]).Clamp(0, 255));
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(WRange)) else if (fieldType == typeof(WRange))
{ {
WRange res; WRange res;
if (WRange.TryParse(x, out res)) if (WRange.TryParse(value, out res))
return res; return res;
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(WVec)) else if (fieldType == typeof(WVec))
{ {
var parts = x.Split(','); var parts = value.Split(',');
if (parts.Length == 3) if (parts.Length == 3)
{ {
WRange rx, ry, rz; WRange rx, ry, rz;
@@ -183,12 +193,12 @@ namespace OpenRA.FileFormats
return new WVec(rx, ry, rz); return new WVec(rx, ry, rz);
} }
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(WPos)) else if (fieldType == typeof(WPos))
{ {
var parts = x.Split(','); var parts = value.Split(',');
if (parts.Length == 3) if (parts.Length == 3)
{ {
WRange rx, ry, rz; WRange rx, ry, rz;
@@ -196,62 +206,62 @@ namespace OpenRA.FileFormats
return new WPos(rx, ry, rz); return new WPos(rx, ry, rz);
} }
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(WAngle)) else if (fieldType == typeof(WAngle))
{ {
int res; int res;
if (int.TryParse(x, out res)) if (int.TryParse(value, out res))
return new WAngle(res); return new WAngle(res);
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType == typeof(WRot)) else if (fieldType == typeof(WRot))
{ {
var parts = x.Split(','); var parts = value.Split(',');
if (parts.Length == 3) if (parts.Length == 3)
{ {
int rr, rp, ry; int rr, rp, ry;
if (int.TryParse(x, out rr) && int.TryParse(x, out rp) && int.TryParse(x, out ry)) if (int.TryParse(value, out rr) && int.TryParse(value, out rp) && int.TryParse(value, out ry))
return new WRot(new WAngle(rr), new WAngle(rp), new WAngle(ry)); return new WRot(new WAngle(rr), new WAngle(rp), new WAngle(ry));
} }
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
} }
else if (fieldType.IsEnum) else if (fieldType.IsEnum)
{ {
if (!Enum.GetNames(fieldType).Select(a => a.ToLower()).Contains(x.ToLower())) if (!Enum.GetNames(fieldType).Select(a => a.ToLower()).Contains(value.ToLower()))
return InvalidValueAction(x, fieldType, field); return InvalidValueAction(value, fieldType, fieldName);
return Enum.Parse(fieldType, x, true); return Enum.Parse(fieldType, value, true);
} }
else if (fieldType == typeof(bool)) else if (fieldType == typeof(bool))
return ParseYesNo(x, fieldType, field); return ParseYesNo(value, fieldType, fieldName);
else if (fieldType.IsArray) else if (fieldType.IsArray)
{ {
if (x == null) if (value == null)
return Array.CreateInstance(fieldType.GetElementType(), 0); return Array.CreateInstance(fieldType.GetElementType(), 0);
var parts = x.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); var parts = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
var ret = Array.CreateInstance(fieldType.GetElementType(), parts.Length); var ret = Array.CreateInstance(fieldType.GetElementType(), parts.Length);
for (int i = 0; i < parts.Length; i++) for (int i = 0; i < parts.Length; i++)
ret.SetValue(GetValue(field, fieldType.GetElementType(), parts[i].Trim()), i); ret.SetValue(GetValue(fieldName, fieldType.GetElementType(), parts[i].Trim(), field), i);
return ret; return ret;
} }
else if (fieldType == typeof(int2)) else if (fieldType == typeof(int2))
{ {
var parts = x.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); var parts = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
return new int2(int.Parse(parts[0]), int.Parse(parts[1])); return new int2(int.Parse(parts[0]), int.Parse(parts[1]));
} }
else if (fieldType == typeof(float2)) else if (fieldType == typeof(float2))
{ {
var parts = x.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); var parts = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
float xx = 0; float xx = 0;
float yy = 0; float yy = 0;
float res; float res;
@@ -264,13 +274,13 @@ namespace OpenRA.FileFormats
else if (fieldType == typeof(Rectangle)) else if (fieldType == typeof(Rectangle))
{ {
var parts = x.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); var parts = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
return new Rectangle(int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]), int.Parse(parts[3])); return new Rectangle(int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]), int.Parse(parts[3]));
} }
else if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Bits<>)) else if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Bits<>))
{ {
var parts = x.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); var parts = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
var argTypes = new Type[] { typeof(string[]) }; var argTypes = new Type[] { typeof(string[]) };
var argValues = new object[] { parts }; var argValues = new object[] { parts };
return fieldType.GetConstructor(argTypes).Invoke(argValues); return fieldType.GetConstructor(argTypes).Invoke(argValues);
@@ -279,11 +289,11 @@ namespace OpenRA.FileFormats
else if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Nullable<>)) else if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Nullable<>))
{ {
var innerType = fieldType.GetGenericArguments().First(); var innerType = fieldType.GetGenericArguments().First();
var innerValue = GetValue("Nullable<T>", innerType, x); var innerValue = GetValue("Nullable<T>", innerType, value, field);
return fieldType.GetConstructor(new []{ innerType }).Invoke(new []{ innerValue }); return fieldType.GetConstructor(new[] { innerType }).Invoke(new[] { innerValue });
} }
UnknownFieldAction("[Type] {0}".F(x), fieldType); UnknownFieldAction("[Type] {0}".F(value), fieldType);
return null; return null;
} }
@@ -312,7 +322,7 @@ namespace OpenRA.FileFormats
if (loadUsing.Length != 0) if (loadUsing.Length != 0)
ret[field] = (_1, fieldType, yaml) => loadUsing[0].LoaderFunc(field)(yaml); ret[field] = (_1, fieldType, yaml) => loadUsing[0].LoaderFunc(field)(yaml);
else if (fromYamlKey.Length != 0) else if (fromYamlKey.Length != 0)
ret[field] = (f, ft, yaml) => GetValue(f, ft, yaml.Value); ret[field] = (f, ft, yaml) => GetValue(f, ft, yaml.Value, field);
else if (ignore.Length == 0) else if (ignore.Length == 0)
ret[field] = null; ret[field] = null;
} }
@@ -342,81 +352,24 @@ namespace OpenRA.FileFormats
return loaderFuncCache; return loaderFuncCache;
} }
} }
public static string Translate(string key)
{
if (Translations == null || string.IsNullOrEmpty(key))
return key;
string value;
if (!Translations.TryGetValue(key, out value))
return key;
return value;
}
public static Dictionary<string, string> Translations = new Dictionary<string, string>();
} }
public static class FieldSaver [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
{ public class TranslateAttribute : Attribute { }
public static MiniYaml Save(object o)
{
var nodes = new List<MiniYamlNode>();
string root = null;
foreach (var f in o.GetType().GetFields(BindingFlags.Public | BindingFlags.Instance))
{
if (f.HasAttribute<FieldFromYamlKeyAttribute>())
root = FormatValue(o, f);
else
nodes.Add(new MiniYamlNode(f.Name, FormatValue(o, f)));
}
return new MiniYaml(root, nodes);
}
public static MiniYaml SaveDifferences(object o, object from)
{
if (o.GetType() != from.GetType())
throw new InvalidOperationException("FieldLoader: can't diff objects of different types");
var fields = o.GetType().GetFields(BindingFlags.Public | BindingFlags.Instance)
.Where(f => FormatValue(o, f) != FormatValue(from, f));
return new MiniYaml(null, fields.Select(f => new MiniYamlNode(f.Name, FormatValue(o, f))).ToList());
}
public static MiniYamlNode SaveField(object o, string field)
{
return new MiniYamlNode(field, FieldSaver.FormatValue(o, o.GetType().GetField(field)));
}
public static string FormatValue(object v, Type t)
{
if (v == null)
return "";
// Color.ToString() does the wrong thing; force it to format as an array
if (t == typeof(Color))
{
var c = (Color)v;
return "{0},{1},{2},{3}".F(((int)c.A).Clamp(0, 255),
((int)c.R).Clamp(0, 255),
((int)c.G).Clamp(0, 255),
((int)c.B).Clamp(0, 255));
}
// Don't save floats in settings.yaml using country-specific decimal separators which can be misunderstood as group seperators.
if (t == typeof(float))
return ((float)v).ToString(CultureInfo.InvariantCulture);
if (t == typeof(Rectangle))
{
var r = (Rectangle)v;
return "{0},{1},{2},{3}".F(r.X, r.Y, r.Width, r.Height);
}
if (t.IsArray)
{
var elems = ((Array)v).OfType<object>();
return elems.JoinWith(",");
}
return v.ToString();
}
public static string FormatValue(object o, FieldInfo f)
{
return FormatValue(f.GetValue(o), f.FieldType);
}
}
public class FieldFromYamlKeyAttribute : Attribute { } public class FieldFromYamlKeyAttribute : Attribute { }

View File

@@ -0,0 +1,93 @@
#region Copyright & License Information
/*
* Copyright 2007-2013 The OpenRA Developers (see AUTHORS)
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation. For more information,
* see COPYING.
*/
#endregion
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Reflection;
namespace OpenRA.FileFormats
{
public static class FieldSaver
{
public static MiniYaml Save(object o)
{
var nodes = new List<MiniYamlNode>();
string root = null;
foreach (var f in o.GetType().GetFields(BindingFlags.Public | BindingFlags.Instance))
{
if (f.HasAttribute<FieldFromYamlKeyAttribute>())
root = FormatValue(o, f);
else
nodes.Add(new MiniYamlNode(f.Name, FormatValue(o, f)));
}
return new MiniYaml(root, nodes);
}
public static MiniYaml SaveDifferences(object o, object from)
{
if (o.GetType() != from.GetType())
throw new InvalidOperationException("FieldLoader: can't diff objects of different types");
var fields = o.GetType().GetFields(BindingFlags.Public | BindingFlags.Instance)
.Where(f => FormatValue(o, f) != FormatValue(from, f));
return new MiniYaml(null, fields.Select(f => new MiniYamlNode(f.Name, FormatValue(o, f))).ToList());
}
public static MiniYamlNode SaveField(object o, string field)
{
return new MiniYamlNode(field, FieldSaver.FormatValue(o, o.GetType().GetField(field)));
}
public static string FormatValue(object v, Type t)
{
if (v == null)
return "";
// Color.ToString() does the wrong thing; force it to format as an array
if (t == typeof(Color))
{
var c = (Color)v;
return "{0},{1},{2},{3}".F(((int)c.A).Clamp(0, 255),
((int)c.R).Clamp(0, 255),
((int)c.G).Clamp(0, 255),
((int)c.B).Clamp(0, 255));
}
// Don't save floats in settings.yaml using country-specific decimal separators which can be misunderstood as group seperators.
if (t == typeof(float))
return ((float)v).ToString(CultureInfo.InvariantCulture);
if (t == typeof(Rectangle))
{
var r = (Rectangle)v;
return "{0},{1},{2},{3}".F(r.X, r.Y, r.Width, r.Height);
}
if (t.IsArray)
{
var elems = ((Array)v).OfType<object>();
return elems.JoinWith(",");
}
return v.ToString();
}
public static string FormatValue(object o, FieldInfo f)
{
return FormatValue(f.GetValue(o), f.FieldType);
}
}
}

View File

@@ -21,7 +21,7 @@ namespace OpenRA.FileFormats
public readonly string[] public readonly string[]
Mods, Folders, Rules, ServerTraits, Mods, Folders, Rules, ServerTraits,
Sequences, VoxelSequences, Cursors, Chrome, Assemblies, ChromeLayout, Sequences, VoxelSequences, Cursors, Chrome, Assemblies, ChromeLayout,
Weapons, Voices, Notifications, Music, Movies, TileSets, Weapons, Voices, Notifications, Music, Movies, Translations, TileSets,
ChromeMetrics, PackageContents; ChromeMetrics, PackageContents;
public readonly Dictionary<string, string> Packages; public readonly Dictionary<string, string> Packages;
@@ -53,6 +53,7 @@ namespace OpenRA.FileFormats
Notifications = YamlList(yaml, "Notifications"); Notifications = YamlList(yaml, "Notifications");
Music = YamlList(yaml, "Music"); Music = YamlList(yaml, "Music");
Movies = YamlList(yaml, "Movies"); Movies = YamlList(yaml, "Movies");
Translations = YamlList(yaml, "Translations");
TileSets = YamlList(yaml, "TileSets"); TileSets = YamlList(yaml, "TileSets");
ChromeMetrics = YamlList(yaml, "ChromeMetrics"); ChromeMetrics = YamlList(yaml, "ChromeMetrics");
PackageContents = YamlList(yaml, "PackageContents"); PackageContents = YamlList(yaml, "PackageContents");

View File

@@ -80,6 +80,7 @@
<Compile Include="Evaluator.cs" /> <Compile Include="Evaluator.cs" />
<Compile Include="Exts.cs" /> <Compile Include="Exts.cs" />
<Compile Include="FieldLoader.cs" /> <Compile Include="FieldLoader.cs" />
<Compile Include="FieldSaver.cs" />
<Compile Include="FileFormats\AudLoader.cs" /> <Compile Include="FileFormats\AudLoader.cs" />
<Compile Include="FileFormats\Blast.cs" /> <Compile Include="FileFormats\Blast.cs" />
<Compile Include="FileFormats\Blowfish.cs" /> <Compile Include="FileFormats\Blowfish.cs" />

View File

@@ -98,6 +98,9 @@ namespace OpenRA.GameRules
public int BatchSize = 8192; public int BatchSize = 8192;
public int NumTempBuffers = 8; public int NumTempBuffers = 8;
public int SheetSize = 2048; public int SheetSize = 2048;
public string Language = "english";
public string DefaultLanguage = "english";
} }
public class SoundSettings public class SoundSettings

View File

@@ -99,6 +99,7 @@ namespace OpenRA
[FieldLoader.Ignore] public List<MiniYamlNode> Weapons = new List<MiniYamlNode>(); [FieldLoader.Ignore] public List<MiniYamlNode> Weapons = new List<MiniYamlNode>();
[FieldLoader.Ignore] public List<MiniYamlNode> Voices = new List<MiniYamlNode>(); [FieldLoader.Ignore] public List<MiniYamlNode> Voices = new List<MiniYamlNode>();
[FieldLoader.Ignore] public List<MiniYamlNode> Notifications = new List<MiniYamlNode>(); [FieldLoader.Ignore] public List<MiniYamlNode> Notifications = new List<MiniYamlNode>();
[FieldLoader.Ignore] public List<MiniYamlNode> Translations = new List<MiniYamlNode>();
// Binary map data // Binary map data
[FieldLoader.Ignore] public byte TileFormat = 1; [FieldLoader.Ignore] public byte TileFormat = 1;
@@ -191,6 +192,7 @@ namespace OpenRA
Weapons = MiniYaml.NodesOrEmpty(yaml, "Weapons"); Weapons = MiniYaml.NodesOrEmpty(yaml, "Weapons");
Voices = MiniYaml.NodesOrEmpty(yaml, "Voices"); Voices = MiniYaml.NodesOrEmpty(yaml, "Voices");
Notifications = MiniYaml.NodesOrEmpty(yaml, "Notifications"); Notifications = MiniYaml.NodesOrEmpty(yaml, "Notifications");
Translations = MiniYaml.NodesOrEmpty(yaml, "Translations");
CustomTerrain = new string[MapSize.X, MapSize.Y]; CustomTerrain = new string[MapSize.X, MapSize.Y];
@@ -247,6 +249,7 @@ namespace OpenRA
root.Add(new MiniYamlNode("Weapons", null, Weapons)); root.Add(new MiniYamlNode("Weapons", null, Weapons));
root.Add(new MiniYamlNode("Voices", null, Voices)); root.Add(new MiniYamlNode("Voices", null, Voices));
root.Add(new MiniYamlNode("Notifications", null, Notifications)); root.Add(new MiniYamlNode("Notifications", null, Notifications));
root.Add(new MiniYamlNode("Translations", null, Translations));
var entries = new Dictionary<string, byte[]>(); var entries = new Dictionary<string, byte[]>();
entries.Add("map.bin", SaveBinaryData()); entries.Add("map.bin", SaveBinaryData());

View File

@@ -44,6 +44,7 @@ namespace OpenRA
public ModData(params string[] mods) public ModData(params string[] mods)
{ {
Languages = new string[0];
Manifest = new Manifest(mods); Manifest = new Manifest(mods);
ObjectCreator = new ObjectCreator(Manifest); ObjectCreator = new ObjectCreator(Manifest);
LoadScreen = ObjectCreator.CreateObject<ILoadScreen>(Manifest.LoadScreen.Value); LoadScreen = ObjectCreator.CreateObject<ILoadScreen>(Manifest.LoadScreen.Value);
@@ -71,6 +72,47 @@ namespace OpenRA
CursorProvider.Initialize(Manifest.Cursors); CursorProvider.Initialize(Manifest.Cursors);
} }
public IEnumerable<string> Languages { get; private set; }
void LoadTranslations(Map map)
{
var selectedTranslations = new Dictionary<string, string>();
var defaultTranslations = new Dictionary<string, string>();
if (!Manifest.Translations.Any())
{
Languages = new string[0];
FieldLoader.Translations = new Dictionary<string, string>();
return;
}
var yaml = Manifest.Translations.Select(MiniYaml.FromFile).Aggregate(MiniYaml.MergeLiberal);
Languages = yaml.Select(t => t.Key).ToArray();
yaml = MiniYaml.MergeLiberal(map.Translations, yaml);
foreach (var y in yaml)
{
if (y.Key == Game.Settings.Graphics.Language)
selectedTranslations = y.Value.NodesDict.ToDictionary(x => x.Key, x => x.Value.Value ?? "");
if (y.Key == Game.Settings.Graphics.DefaultLanguage)
defaultTranslations = y.Value.NodesDict.ToDictionary(x => x.Key, x => x.Value.Value ?? "");
}
var translations = new Dictionary<string, string>();
foreach (var tkv in defaultTranslations.Concat(selectedTranslations))
{
if (translations.ContainsKey(tkv.Key))
continue;
if (selectedTranslations.ContainsKey(tkv.Key))
translations.Add(tkv.Key, selectedTranslations[tkv.Key]);
else
translations.Add(tkv.Key, tkv.Value);
}
FieldLoader.Translations = translations;
}
public Map PrepareMap(string uid) public Map PrepareMap(string uid)
{ {
LoadScreen.Display(); LoadScreen.Display();
@@ -78,6 +120,8 @@ namespace OpenRA
throw new InvalidDataException("Invalid map uid: {0}".F(uid)); throw new InvalidDataException("Invalid map uid: {0}".F(uid));
var map = new Map(AvailableMaps[uid].Path); var map = new Map(AvailableMaps[uid].Path);
LoadTranslations(map);
// Reinit all our assets // Reinit all our assets
InitializeLoaders(); InitializeLoaders();
FileSystem.LoadFromManifest(Manifest); FileSystem.LoadFromManifest(Manifest);

View File

@@ -25,7 +25,7 @@ namespace OpenRA.Widgets
set { GetKey = _ => value; } set { GetKey = _ => value; }
} }
public string Text = ""; [Translate] public string Text = "";
public bool Depressed = false; public bool Depressed = false;
public int VisualHeight = ChromeMetrics.Get<int>("ButtonDepth"); public int VisualHeight = ChromeMetrics.Get<int>("ButtonDepth");
public string Font = ChromeMetrics.Get<string>("ButtonFont"); public string Font = ChromeMetrics.Get<string>("ButtonFont");

View File

@@ -10,6 +10,7 @@
using System; using System;
using System.Drawing; using System.Drawing;
using OpenRA.FileFormats;
using OpenRA.Graphics; using OpenRA.Graphics;
namespace OpenRA.Widgets namespace OpenRA.Widgets
@@ -19,7 +20,7 @@ namespace OpenRA.Widgets
public class LabelWidget : Widget public class LabelWidget : Widget
{ {
public string Text = null; [Translate] public string Text = null;
public TextAlign Align = TextAlign.Left; public TextAlign Align = TextAlign.Left;
public TextVAlign VAlign = TextVAlign.Middle; public TextVAlign VAlign = TextVAlign.Middle;
public string Font = "Regular"; public string Font = "Regular";

View File

@@ -104,6 +104,10 @@ namespace OpenRA.Mods.Cnc.Widgets.Logic
var windowHeight = generalPane.Get<TextFieldWidget>("WINDOW_HEIGHT"); var windowHeight = generalPane.Get<TextFieldWidget>("WINDOW_HEIGHT");
windowHeight.Text = graphicsSettings.WindowedSize.Y.ToString(); windowHeight.Text = graphicsSettings.WindowedSize.Y.ToString();
var languageDropDownButton = generalPane.Get<DropDownButtonWidget>("LANGUAGE_DROPDOWNBUTTON");
languageDropDownButton.OnMouseDown = _ => SettingsMenuLogic.ShowLanguageDropdown(languageDropDownButton);
languageDropDownButton.GetText = () => FieldLoader.Translate(Game.Settings.Graphics.Language);
// Audio // Audio
var soundSlider = generalPane.Get<SliderWidget>("SOUND_SLIDER"); var soundSlider = generalPane.Get<SliderWidget>("SOUND_SLIDER");
soundSlider.OnChange += x => { soundSettings.SoundVolume = x; Sound.SoundVolume = x; }; soundSlider.OnChange += x => { soundSettings.SoundVolume = x; Sound.SoundVolume = x; };

View File

@@ -54,7 +54,7 @@ namespace OpenRA.Mods.Cnc.Widgets.Logic
nameLabel.GetText = () => tooltip.Name; nameLabel.GetText = () => tooltip.Name;
var prereqs = buildable.Prerequisites.Select(a => ActorName(a)); var prereqs = buildable.Prerequisites.Select(a => ActorName(a));
var requiresString = prereqs.Any() ? "Requires {0}".F(prereqs.JoinWith(", ")) : ""; var requiresString = prereqs.Any() ? requiresLabel.Text.F(prereqs.JoinWith(", ")) : "";
requiresLabel.GetText = () => requiresString; requiresLabel.GetText = () => requiresString;
var power = bi != null ? bi.Power : 0; var power = bi != null ? bi.Power : 0;

View File

@@ -37,8 +37,8 @@ namespace OpenRA.Mods.Cnc.Widgets
public readonly string TooltipContainer; public readonly string TooltipContainer;
public readonly string TooltipTemplate = "PRODUCTION_TOOLTIP"; public readonly string TooltipTemplate = "PRODUCTION_TOOLTIP";
public readonly string ReadyText = ""; [Translate] public readonly string ReadyText = "";
public readonly string HoldText = ""; [Translate] public readonly string HoldText = "";
public string TooltipActor { get; private set; } public string TooltipActor { get; private set; }
public readonly World World; public readonly World World;

View File

@@ -16,8 +16,8 @@ namespace OpenRA.Mods.RA
[Desc("Shown in the build palette widget.")] [Desc("Shown in the build palette widget.")]
public class TooltipInfo : ITraitInfo public class TooltipInfo : ITraitInfo
{ {
public readonly string Description = ""; [Translate] public readonly string Description = "";
public readonly string Name = ""; [Translate] public readonly string Name = "";
[Desc("Sequence of the actor that contains the cameo.")] [Desc("Sequence of the actor that contains the cameo.")]
public readonly string Icon = "icon"; public readonly string Icon = "icon";

View File

@@ -26,9 +26,9 @@ namespace OpenRA.Mods.RA.Widgets
public int Columns = 3; public int Columns = 3;
public int Rows = 5; public int Rows = 5;
public string ReadyText = ""; [Translate] public string ReadyText = "";
public string HoldText = ""; [Translate] public string HoldText = "";
public string RequiresText = ""; [Translate] public string RequiresText = "";
public int IconWidth = 64; public int IconWidth = 64;
public int IconHeight = 48; public int IconHeight = 48;
@@ -484,7 +484,7 @@ namespace OpenRA.Mods.RA.Widgets
var prereqs = buildable.Prerequisites.Select(Description); var prereqs = buildable.Prerequisites.Select(Description);
if (prereqs.Any()) if (prereqs.Any())
{ {
Game.Renderer.Fonts["Regular"].DrawText("{0} {1}".F(RequiresText, prereqs.JoinWith(", ")), p.ToInt2(), Color.White); Game.Renderer.Fonts["Regular"].DrawText(RequiresText.F(prereqs.JoinWith(", ")), p.ToInt2(), Color.White);
p += new int2(0, 8); p += new int2(0, 8);
} }

View File

@@ -11,6 +11,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenRA.FileFormats;
using OpenRA.FileFormats.Graphics; using OpenRA.FileFormats.Graphics;
using OpenRA.GameRules; using OpenRA.GameRules;
using OpenRA.Widgets; using OpenRA.Widgets;
@@ -142,6 +143,10 @@ namespace OpenRA.Mods.RA.Widgets.Logic
var maxFrameRate = display.Get<TextFieldWidget>("MAX_FRAMERATE"); var maxFrameRate = display.Get<TextFieldWidget>("MAX_FRAMERATE");
maxFrameRate.Text = gs.MaxFramerate.ToString(); maxFrameRate.Text = gs.MaxFramerate.ToString();
var languageDropDownButton = display.Get<DropDownButtonWidget>("LANGUAGE_DROPDOWNBUTTON");
languageDropDownButton.OnMouseDown = _ => ShowLanguageDropdown(languageDropDownButton);
languageDropDownButton.GetText = () => FieldLoader.Translate(Game.Settings.Graphics.Language);
// Keys // Keys
var keys = bg.Get("KEYS_PANE"); var keys = bg.Get("KEYS_PANE");
var keyConfig = Game.Settings.Keys; var keyConfig = Game.Settings.Keys;
@@ -270,6 +275,20 @@ namespace OpenRA.Mods.RA.Widgets.Logic
return true; return true;
} }
public static bool ShowLanguageDropdown(DropDownButtonWidget dropdown)
{
Func<string, ScrollItemWidget, ScrollItemWidget> setupItem = (o, itemTemplate) =>
{
var item = ScrollItemWidget.Setup(itemTemplate,
() => Game.Settings.Graphics.Language == o,
() => Game.Settings.Graphics.Language = o);
item.Get<LabelWidget>("LABEL").GetText = () => FieldLoader.Translate(o);
return item;
};
dropdown.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 500, Game.modData.Languages, setupItem);
return true;
}
public static bool ShowSoundTickDropdown(DropDownButtonWidget dropdown, SoundSettings audio) public static bool ShowSoundTickDropdown(DropDownButtonWidget dropdown, SoundSettings audio)
{ {

View File

@@ -279,8 +279,8 @@ Container@PLAYER_WIDGETS:
X:WINDOW_RIGHT - 204 X:WINDOW_RIGHT - 204
Y:287 Y:287
TooltipContainer:TOOLTIP_CONTAINER TooltipContainer:TOOLTIP_CONTAINER
ReadyText:Ready ReadyText:@ready@
HoldText:On Hold HoldText:@on-hold@
Background@FMVPLAYER: Background@FMVPLAYER:
Width:WINDOW_RIGHT Width:WINDOW_RIGHT
Height:WINDOW_BOTTOM Height:WINDOW_BOTTOM

View File

@@ -3,7 +3,7 @@ Container@SETTINGS_PANEL:
X:(WINDOW_RIGHT - WIDTH)/2 X:(WINDOW_RIGHT - WIDTH)/2
Y:(WINDOW_BOTTOM - 250)/2 Y:(WINDOW_BOTTOM - 250)/2
Width:740 Width:740
Height:535 Height:565
Children: Children:
ColorPreviewManager@COLOR_MANAGER: ColorPreviewManager@COLOR_MANAGER:
Label@TITLE: Label@TITLE:
@@ -15,7 +15,7 @@ Container@SETTINGS_PANEL:
Text:Settings Text:Settings
Background@GENERAL_CONTROLS: Background@GENERAL_CONTROLS:
Width:740 Width:740
Height:290 Height:320
Background:panel-black Background:panel-black
Children: Children:
Label@TITLE: Label@TITLE:
@@ -66,35 +66,35 @@ Container@SETTINGS_PANEL:
Text:Shellmap Music Text:Shellmap Music
Label@DEBUG_TITLE: Label@DEBUG_TITLE:
X:15 X:15
Y:150 Y:180
Width:340 Width:340
Font:Bold Font:Bold
Text:Debug Text:Debug
Align:Center Align:Center
Checkbox@PERFTEXT_CHECKBOX: Checkbox@PERFTEXT_CHECKBOX:
X:15 X:15
Y:170 Y:200
Width:300 Width:300
Height:20 Height:20
Font:Regular Font:Regular
Text:Show Performance Text Text:Show Performance Text
Checkbox@PERFGRAPH_CHECKBOX: Checkbox@PERFGRAPH_CHECKBOX:
X:15 X:15
Y:200 Y:230
Width:300 Width:300
Height:20 Height:20
Font:Regular Font:Regular
Text:Show Performance Graph Text:Show Performance Graph
Checkbox@CHECKUNSYNCED_CHECKBOX: Checkbox@CHECKUNSYNCED_CHECKBOX:
X:15 X:15
Y:230 Y:260
Width:300 Width:300
Height:20 Height:20
Font:Regular Font:Regular
Text:Check Sync around Unsynced Code Text:Check Sync around Unsynced Code
Checkbox@SHOW_FATAL_ERROR_DIALOG_CHECKBOX: Checkbox@SHOW_FATAL_ERROR_DIALOG_CHECKBOX:
X:15 X:15
Y:260 Y:290
Width:300 Width:300
Height:20 Height:20
Font:Regular Font:Regular
@@ -149,70 +149,82 @@ Container@SETTINGS_PANEL:
Width:45 Width:45
Height:25 Height:25
MaxLength:5 MaxLength:5
Label@LANGUAGE_LABEL:
X:375
Y:70
Width:75
Height:25
Align:Right
Text:Language:
DropDownButton@LANGUAGE_DROPDOWNBUTTON:
X:455
Y:70
Width:140
Height:25
Label@VIDEO_DESC: Label@VIDEO_DESC:
X:375 X:375
Y:68 Y:100
Width:340 Width:340
Height:25 Height:25
Font:Tiny Font:Tiny
Align:Center Align:Center
Text:Mode/Resolution changes will be applied after the game is restarted Text:Mode/Resolution/Language changes will be applied after the game is restarted
Checkbox@PIXELDOUBLE_CHECKBOX: Checkbox@PIXELDOUBLE_CHECKBOX:
X:375 X:375
Y:110 Y:140
Width:200 Width:200
Height:20 Height:20
Font:Regular Font:Regular
Text:Enable Pixel Doubling Text:Enable Pixel Doubling
Label@AUDIO_TITLE: Label@AUDIO_TITLE:
X:375 X:375
Y:150 Y:180
Width:340 Width:340
Font:Bold Font:Bold
Text:Sound Text:Sound
Align:Center Align:Center
Label@SOUND_LABEL: Label@SOUND_LABEL:
X:375 X:375
Y:164 Y:194
Width:95 Width:95
Height:25 Height:25
Align:Right Align:Right
Text:Sound Volume: Text:Sound Volume:
Slider@SOUND_SLIDER: Slider@SOUND_SLIDER:
X:475 X:475
Y:170 Y:200
Width:240 Width:240
Height:20 Height:20
Ticks:5 Ticks:5
Label@MUSIC_LABEL: Label@MUSIC_LABEL:
X:375 X:375
Y:194 Y:224
Width:95 Width:95
Height:25 Height:25
Align:Right Align:Right
Text:Music Volume: Text:Music Volume:
Slider@MUSIC_SLIDER: Slider@MUSIC_SLIDER:
X:475 X:475
Y:200 Y:230
Width:240 Width:240
Height:20 Height:20
Ticks:5 Ticks:5
Label@AUDIO_DEVICE_LABEL: Label@AUDIO_DEVICE_LABEL:
X:375 X:375
Y:229 Y:259
Width:75 Width:75
Height:20 Height:20
Text:Audio Device: Text:Audio Device:
DropDownButton@AUDIO_DEVICE: DropDownButton@AUDIO_DEVICE:
X:475 X:475
Y:230 Y:260
Width:240 Width:240
Height:25 Height:25
Font:Regular Font:Regular
Text:Default Device Text:Default Device
Label@AUDIO_DESC: Label@AUDIO_DESC:
X:375 X:375
Y:258 Y:288
Width:340 Width:340
Height:25 Height:25
Font:Tiny Font:Tiny
@@ -296,20 +308,20 @@ Container@SETTINGS_PANEL:
Font:Regular Font:Regular
Text:Shift-Enter Toggles Team Chat Text:Shift-Enter Toggles Team Chat
Button@GENERAL_BUTTON: Button@GENERAL_BUTTON:
Y:289 Y:319
Width:140 Width:140
Height:35 Height:35
Text:General Text:General
Button@INPUT_BUTTON: Button@INPUT_BUTTON:
X:150 X:150
Y:289 Y:319
Width:140 Width:140
Height:35 Height:35
Text:Input Text:Input
Button@BACK_BUTTON: Button@BACK_BUTTON:
Key:escape Key:escape
X:600 X:600
Y:289 Y:319
Width:140 Width:140
Height:35 Height:35
Text:Back Text:Back

View File

@@ -60,6 +60,7 @@ Background@PRODUCTION_TOOLTIP:
Y:19 Y:19
Height:23 Height:23
Font:TinyBold Font:TinyBold
Text:@requires@
Label@DESC: Label@DESC:
X:5 X:5
Y:39 Y:39

View File

@@ -0,0 +1,6 @@
english:
english: English
ready: Ready
on-hold: On Hold
requires: Requires {0}

View File

@@ -94,6 +94,9 @@ Movies:
mods/cnc/movies-gdi.yaml mods/cnc/movies-gdi.yaml
mods/cnc/movies-nod.yaml mods/cnc/movies-nod.yaml
Translations:
mods/cnc/languages/english.yaml
Voices: Voices:
mods/cnc/voices.yaml mods/cnc/voices.yaml

View File

@@ -155,9 +155,9 @@ Container@PLAYER_WIDGETS:
Y:280 Y:280
Width:238 Width:238
Height:500 Height:500
ReadyText: READY ReadyText:@ready@
HoldText: ON HOLD HoldText:@on-hold@
RequiresText: Requires RequiresText:@requires@
IconWidth: 60 IconWidth: 60
IconHeight: 48 IconHeight: 48
Columns: 3 Columns: 3

View File

@@ -0,0 +1,6 @@
english:
english: English
ready: READY
on-hold: ON HOLD
requires: Requires {0}

View File

@@ -95,6 +95,9 @@ Music:
Movies: Movies:
Translations:
mods/d2k/languages/english.yaml
LoadScreen: DefaultLoadScreen LoadScreen: DefaultLoadScreen
Image: mods/d2k/uibits/loadscreen.png Image: mods/d2k/uibits/loadscreen.png
InstallerMenuWidget: INSTALL_PANEL InstallerMenuWidget: INSTALL_PANEL

View File

@@ -148,16 +148,16 @@ Container@PLAYER_WIDGETS:
SupportPowerBin@INGAME_POWERS_BIN: SupportPowerBin@INGAME_POWERS_BIN:
X:0 X:0
Y:25 Y:25
ReadyText: READY ReadyText: @ready@
HoldText: ON HOLD HoldText: @on-hold@
BuildPalette@INGAME_BUILD_PALETTE: BuildPalette@INGAME_BUILD_PALETTE:
X:WINDOW_RIGHT - 250 X:WINDOW_RIGHT - 250
Y:280 Y:280
Width:250 Width:250
Height:500 Height:500
ReadyText: READY ReadyText: @ready@
HoldText: ON HOLD HoldText: @on-hold@
RequiresText: Requires RequiresText: @requires@
Container@OBSERVER_WIDGETS: Container@OBSERVER_WIDGETS:
Children: Children:

View File

@@ -138,7 +138,7 @@ Background@SETTINGS_MENU:
Label@SOUND_VOLUME_LABEL: Label@SOUND_VOLUME_LABEL:
X:0 X:0
Y:10 Y:10
Text: Sound Volume Text: Sound Volume
Slider@SOUND_VOLUME: Slider@SOUND_VOLUME:
X:100 X:100
Y:0 Y:0
@@ -255,7 +255,7 @@ Background@SETTINGS_MENU:
Height:25 Height:25
Font:Tiny Font:Tiny
Align:Center Align:Center
Text:Renderer/Mode/Resolution changes will be applied after the game is restarted. Text:Mode/Resolution changes will be applied after the game is restarted.
Checkbox@PIXELDOUBLE_CHECKBOX: Checkbox@PIXELDOUBLE_CHECKBOX:
Y:60 Y:60
Width:200 Width:200
@@ -274,6 +274,24 @@ Background@SETTINGS_MENU:
Width:45 Width:45
Height:25 Height:25
MaxLength:3 MaxLength:3
Label@LANGUAGE_LABEL:
X:0
Y:130
Width:75
Height:25
Text:Language:
DropDownButton@LANGUAGE_DROPDOWNBUTTON:
X:80
Y:130
Width:140
Height:25
Label@LANGUAGE_DESC:
Y:160
Width:PARENT_RIGHT
Height:25
Font:Tiny
Align:Center
Text:Language changes will be applied after the game is restarted.
Container@KEYS_PANE: Container@KEYS_PANE:
X:37 X:37
Y:100 Y:100

View File

@@ -0,0 +1,6 @@
english:
english: English
ready: READY
on-hold: ON HOLD
requires: Requires {0}

View File

@@ -113,6 +113,9 @@ Movies:
mods/ra/movies1.yaml mods/ra/movies1.yaml
mods/ra/movies2.yaml mods/ra/movies2.yaml
Translations:
mods/ra/languages/english.yaml
LoadScreen: DefaultLoadScreen LoadScreen: DefaultLoadScreen
Image: mods/ra/uibits/loadscreen.png Image: mods/ra/uibits/loadscreen.png
InstallerMenuWidget: INSTALL_PANEL InstallerMenuWidget: INSTALL_PANEL

View File

@@ -0,0 +1,6 @@
english:
english: English
ready: READY
on-hold: ON HOLD
requires: Requires {0}

View File

@@ -136,6 +136,9 @@ Music:
Movies: Movies:
Translations:
mods/ts/languages/english.yaml
LoadScreen: DefaultLoadScreen LoadScreen: DefaultLoadScreen
Image: mods/ts/uibits/loadscreen.png Image: mods/ts/uibits/loadscreen.png
InstallerMenuWidget: INSTALL_PANEL InstallerMenuWidget: INSTALL_PANEL