Sonntag, 29. Mai 2016

Game Localization using Java Bundles


I toyed around with two ways how to implement Localization in Java. Let me present you the two ways I've implemented and which are better for you to use. I am assuming you know how to use Java Resource Bundles. If not, the check oracle the tutorial here or check this video I made here. In my examples, I will be using LibGDX, however, the code is almost the same.


The methods have both up and downsides which I'll discuss later.


Using Object Reflection

This method I worked out runs over all fields of one class and translates String types of fields (including fields in superclasses). The value of that field is being used as localization variable. Fields to be translated are marked with an annotation. This is helpful when you don't want to translate every single String in a class, and say, use untranslated Strings for object IDs.

You have a localization annotation like this:

/**
 * Marks a field to be localized.
 * The content of the field is being used to localize it.
 * @author B5cully
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Localized {
   
}


A very simple class. It's just a marker, after all.

The object is being localized like this:

  
    private static I18NBundle translations= ... // init the the bundle. This is libgdx specific, 
                                             // but works the same with a java resource bundle
                                             // the call may look a bit different
    /**
     * Localizes an object.
     * @param o
     */
    public static void localize(Object o) {
        Class current = o.getClass();
        do{
            try {             
                Field[] fields = current.getDeclaredFields();
                for( Field field : fields ) {

                    //the following is a hack to make the field temporarily accessible
                    boolean accessible = field.isAccessible();
                    field.setAccessible(true);
                    //check if the annotation exist
                    Localized annotation = field.getAnnotation(Localized.class);
                    if( annotation != null ) {

                        //localize the content of the field here
                        String localized = translations.get((String) field.get(o));
                        field.set(o, localized);
                    }
                    field.setAccessible(accessible);
                }
            } catch (SecurityException ex) {
                ex.printStackTrace();
            } catch (IllegalArgumentException ex) {
                Logger.getLogger(Localization.class.getName()).log(Level.SEVERE, null, ex);
            } catch (IllegalAccessException ex) {
                Logger.getLogger(Localization.class.getName()).log(Level.SEVERE, null, ex);
            }
            current = current.getSuperclass();
        } while( current != null );       
    }


An object I want to translate may look like this:

public class CTWindow {

    @Localized
    public String title = "loc_window";

    public String id = "Window001";
}

Using this code, only the title will be translated by the localize() method. However, this may not work as elegantly for switching languages in a live system.



Using Field Names as identifiers

The other method is to use canonical names in your properties file. For example, your property file has an entry like this: com.neutronio.Infantry.hitpoints = Hitpoints. When you translate the field of an object, all you have to do is to get its class and field to obtain the localization.

What sounds nice in theory, isn't as easy in practice. We need multiple methods to achieve this.

    private static I18NBundle translations= ... // init the the bundle. This is libgdx specific, 
                                             // but works the same with a java resource bundle
                                             // the call may look a bit different
    /**
     * Gets the standard file path for a locale. This is required to

     * to turn a path of a properties file into desired format using its tag.
     * @param locale
     * @return
     */
    public static String convertPath( Locale locale) {
        String tag = locale.toLanguageTag().replace('-', '_');
        return tag;              
    }
  
    /**
     * Obtains a localized variable by
     * a simple field name and class.
     * @param field
     * @return
     */
    public static String localizedVar(Field field) {
        if( field == null) return "";
        return field.getDeclaringClass()

                    .getCanonicalName()+"."+field.getName();    
    }
  
    /**
     * Gets a field by name and class. Null if none was found.

     * This method works recursively in all superclasses.
     * @param name
     * @return
     */
    public static Field getField(Class clazz, String name) {
        Exception exception1 = null;
        Exception exception2 = null;
        Class current = clazz;
        Field result = null;
        do{
            try {              
                Field[] fields = current.getDeclaredFields();
                for( Field field : fields ) {
                    boolean accessible = field.isAccessible();
                    field.setAccessible(true);
                    if( Objects.equals(field.getName(), name) ) {
                        result = field;
                        break;
                    }
                    field.setAccessible(accessible);
                }
            } catch (SecurityException ex) {
                ex.printStackTrace();
                exception2 = ex;
                Logger.getLogger(Localization.class.getName()).log(Level.SEVERE,
                        "No acess to Field " + name + " in " +           

                         clazz.getCanonicalName() + ".", ex);
            }
            current = current.getSuperclass();
            if( result != null) break;
        } while( current != null );
        return result;
    }
  
    /**
     * This is the method that ultimately translates the field.

     * Obtains a statically defined localized string.
     */
    public static String getLocale(Class clazz, String name) {
        Field field = getField(clazz, name);
        if( field == null) return name;
        return translations.get(localizedVar(field));
    }


While this sounds like a handy approach there is one huge downside to it: Say my CTWindow class is extended with a MainMenuScreen. So what happens? Due to the nature of this algorithm, I can't define anything like MainMenuScreen.title = Main Menu instead of CTWindow.title= Some Title in my properties file. That means the String in the CTWindow.title local variable is stuck and cannot be redefined by subclasses. That is because of the field.getDeclaringClass() part in localizedVar(). You can work around this if you change the methods by providing the class of the object instead class where the field is being declared, like so:

    /**
     * Obtains a localized variable by
     * a simple field name and class.
     * @param field
     * @return
     */
    public static String localizedVar(Class declaring, Field field) {
         if( field == null) return "";

         if( declaring == null) return "";
         return declaring.getCanonicalName()+"."+field.getName(); 
    }

I haven't tried this out yet, so I cannot say for sure if this works! Let me know if it does. ;)

You also can still use the localize() method ontop of this, as described further above, so both of these methods can be combined together. If combined, they may actually offer the possibility to change language in a live system, as the field names are used directly for translation.

But how do you treat lists or collections of Strings?!

That's a topic for another article!


I hope I brought you closer to the topic of localization. Thanks for reading! I hope you enjoyed it.

Keine Kommentare:

Kommentar veröffentlichen